From 894d989445a56b8bdc0c8e96f83ade564566c492 Mon Sep 17 00:00:00 2001 From: justin Date: Sun, 7 Jun 2026 03:45:46 -0500 Subject: [PATCH] Add portable Line-us pen-arm support to ink-signature pipeline Adds a second machine class (small fan-shaped reach arm) alongside the CR-10/AxiDraw rectangular-bed plotters, so wet signatures can be produced while away from the home station. ink_signature_plotter.py: - PlotterConfig gains dialect (marlin|lineus) + name; new LineUsConfig (native units, pen height = per-move Z, reach annulus from shoulder pivot). - Named machine profiles (cr10 default, axidraw, lineus) via load_profile(). - bed_mm_to_lineus_units(), check_reach() (annulus for lineus, rectangle for marlin), compute_jig_offset_for_box() (solves jig from the ACTUAL fitted ink extent so a wide cell line doesn't over-constrain a small arm). - emit_gcode() dispatches to emit_marlin_gcode()/emit_lineus_gcode(). - send_lineus(): WiFi TCP 1337 (NUL-terminated, ok-acked) or USB serial, dry_run=True default (same gating as the CR-10 path). ink_signature_cli.py: --profile, --solve-jig (auto-applies jig offset), --lineus-host/--lineus-usb, reach-check that refuses to --plot out-of-reach on Line-us. Tests: 43 checks (was 30) covering profiles, reach check, jig solve, lineus emitter, dry-run sender. Docs updated with profiles + portable workflow. --- docs/ink-signature-plotter.md | 59 ++- scripts/tests/test_ink_signature.py | 47 ++ scripts/workers/ink_signature_cli.py | 85 +++- .../workers/services/ink_signature_plotter.py | 423 ++++++++++++++++++ 4 files changed, 595 insertions(+), 19 deletions(-) diff --git a/docs/ink-signature-plotter.md b/docs/ink-signature-plotter.md index 049d3c9..f1172c6 100644 --- a/docs/ink-signature-plotter.md +++ b/docs/ink-signature-plotter.md @@ -74,6 +74,59 @@ uses) places the ink exactly on the cert line. 5. **Linux:** the CR-10 enumerates as a CH340 serial device (`/dev/ttyUSB0`, 115200 baud). No drivers needed — we stream G-code ourselves. +## Machine profiles + +The pipeline is machine-agnostic via named profiles (`--profile`): + +| Profile | Machine | Dialect | Pen lift | Bed/reach | +|---|---|---|---|---| +| `cr10` (default) | Creality CR-10 V2 (home station) | Marlin | Z move | 300×300 mm rectangular | +| `axidraw` | AxiDraw / iDraw A4 pen plotter | Marlin/GRBL | servo (M280) | 210×297 mm rectangular | +| `lineus` | Line-us folding pen-arm (pocket) | Line-us GCode | pen Z per move | small fan-shaped annulus | + +`load_profile()` returns a `PlotterConfig`; CLI flags (`--jig-x/-y`, +`--pen-down/-up`, `--servo-pen`) override individual fields. + +## Portable option: Line-us (pocket pen-arm) + +When away from the home CR-10, the **Line-us** is a palm-size folding 2-link arm +that draws with a real pen. It speaks its own GCode subset over **WiFi (TCP +1337)** or **USB serial**, acks each command with `"ok"`, and encodes pen height +as the **Z value of each move** (low Z = pen down, high Z = pen up), not mm. + +Because the arm's reachable area is a **small fan-shaped annulus** in front of +its shoulder pivot (not a rectangular bed), two things differ from the CR-10: + +1. **Reach checking.** `check_reach()` validates every planned point against the + annulus (`reach_min_units..reach_max_units` from the shoulder). The CLI prints + the report and **refuses to `--plot` on Line-us if any point is out of reach** + (an unreachable point would distort the signature). +2. **Jig solving.** The signature must be slid into the sweet spot. `--solve-jig` + calls `compute_jig_offset_for_box(box, cfg, vector=...)`, which aims the centre + of the **actual fitted ink extent** (not the full cell, which can be far wider + than the signature) at the annulus mid-radius and reach-checks the result. It + then applies the computed `jig_x_mm/jig_y_mm` automatically. + +A small registration jig (a corner stop + a marked cell on a card) holds the +paper so the solved offset is repeatable. + +```bash +# Generate gcode + preview for Line-us, auto-solving the jig (safe, dry-run): +python scripts/workers/ink_signature_cli.py --order CO-XXXX --doc cms10114 \ + --profile lineus --solve-jig + +# Plot over WiFi (default host line-us.local:1337), sheet in the jig: +python scripts/workers/ink_signature_cli.py --order CO-XXXX --doc cms10114 \ + --profile lineus --solve-jig --plot --lineus-host line-us.local + +# Or plot over USB serial instead of WiFi: +python scripts/workers/ink_signature_cli.py --order CO-XXXX --doc cms10114 \ + --profile lineus --solve-jig --plot --lineus-usb --port /dev/ttyACM0 +``` + +`send_lineus()` defaults to `dry_run=True` (same gating as the CR-10 path); it +only moves the arm with `--plot`. + ## Calibration ```bash @@ -103,10 +156,14 @@ python scripts/workers/ink_signature_cli.py --order CO-XXXX --doc cms10114 \ ## Verification (no hardware required) -`scripts/tests/test_ink_signature.py` (30 checks) proves the geometry: +`scripts/tests/test_ink_signature.py` (43 checks) proves the geometry: - strokes fit inside the anchor box, aspect preserved, Y flipped, ink on the rule - PDF-pt → bed-mm uses the jig offset + correct unit scale, stays on the bed - G-code framing (pen up/down, feeds, park), servo mode, over-bed warning +- machine profiles (cr10 / axidraw / lineus) load with the right dialect/bed +- Line-us reach check rejects out-of-reach plots; `--solve-jig` brings the + signature into reach; the Line-us emitter homes, encodes pen Z, and warns + on out-of-reach; `send_lineus()` is dry-run safe over both TCP and USB - `render_signature_on_pdf` stamps the strokes onto the **real CMS-10114 cert page** inside the signature cell (label ≤ y, ink ≥ bottom rule) diff --git a/scripts/tests/test_ink_signature.py b/scripts/tests/test_ink_signature.py index 45c3feb..9b1c2bb 100644 --- a/scripts/tests/test_ink_signature.py +++ b/scripts/tests/test_ink_signature.py @@ -119,6 +119,53 @@ def main(): g_big = ink.emit_gcode(ink.plan_signature(vector, big_box, cfg), cfg) check("over-bed plot warns", "outside the configured bed" in g_big) + print("profiles: cr10 / axidraw / lineus") + cr10 = ink.load_profile("cr10") + axi = ink.load_profile("axidraw") + lu = ink.load_profile("lineus") + check("cr10 is marlin Z-pen", cr10.dialect == "marlin" and not cr10.servo_pen) + check("axidraw is marlin servo + A4 bed", axi.dialect == "marlin" and axi.servo_pen and axi.bed_max_y_mm == 297.0) + check("lineus dialect + has LineUsConfig", lu.dialect == "lineus" and lu.lineus is not None) + raised = False + try: + ink.load_profile("nope") + except ValueError: + raised = True + check("unknown profile raises", raised) + + print("Line-us reach check + jig solve") + # With the default jig (0,0), the CMS box (PDF ~48,443 pt) maps far outside + # the small arm's reach -> reach check should fail. + lu_planned_raw = ink.plan_signature(vector, BOX, lu) + rep_raw = ink.check_reach(lu_planned_raw, lu) + check("raw lineus plot is out of reach", rep_raw["dialect"] == "lineus" and not rep_raw["ok"]) + # Solve the jig: should bring the whole cell into reach. + solved = ink.compute_jig_offset_for_box(BOX, lu, vector=vector) + check("solve-jig reports ok", solved["ok"] is True) + check("solve-jig returns jig offsets", "jig_x_mm" in solved and "jig_y_mm" in solved) + from dataclasses import replace as _replace + lu_fixed = _replace(lu, jig_x_mm=solved["jig_x_mm"], jig_y_mm=solved["jig_y_mm"]) + lu_planned = ink.plan_signature(vector, BOX, lu_fixed) + rep_fixed = ink.check_reach(lu_planned, lu_fixed) + check("after solve-jig the signature is in reach", rep_fixed["ok"] is True) + + print("Line-us G-code emission") + glu = ink.emit_gcode(lu_planned, lu_fixed) + check("lineus homes the arm", "G28" in glu) + check("lineus encodes pen height as Z in G01", "G01 X" in glu and f"Z{lu.lineus.pen_up_z:d}" in glu) + check("lineus uses pen-down Z", f"Z{lu.lineus.pen_down_z:d}" in glu) + check("lineus has no Marlin mm header", "G21" not in glu) + check("in-reach lineus plot has no reach warning", "fall outside the arm reach" not in glu) + # Out-of-reach plot should carry the warning comment. + glu_bad = ink.emit_gcode(lu_planned_raw, lu) + check("out-of-reach lineus plot warns", "fall outside the arm reach" in glu_bad) + + print("Line-us sender is dry-run safe") + res_tcp = ink.send_lineus(glu, dry_run=True) + check("lineus dry-run does not send (tcp)", res_tcp["sent"] is False and res_tcp["transport"] == "tcp") + res_usb = ink.send_lineus(glu, serial_port="/dev/ttyACM0", dry_run=True) + check("lineus dry-run does not send (usb)", res_usb["sent"] is False and res_usb["transport"] == "serial") + print("preview SVG") svg = ink.render_preview_svg(planned, cfg) check("svg has path(s)", " PlotterConfig: + """Home station: Creality CR-10 V2 with a corner jig (Marlin, Z pen-lift).""" + return PlotterConfig(dialect="marlin", name="cr10") + + +def _profile_axidraw() -> PlotterConfig: + """Portable A4 pen plotter (AxiDraw/iDraw class): Marlin/GRBL with a servo lift.""" + return PlotterConfig( + dialect="marlin", name="axidraw", + bed_max_x_mm=210.0, bed_max_y_mm=297.0, + servo_pen=True, servo_up_angle=70, servo_down_angle=30, + jig_x_mm=10.0, jig_y_mm=10.0, + ) + + +def _profile_lineus() -> PlotterConfig: + """Pocket station: Line-us folding pen-arm (own GCode/units, small reach). + + The arm's reach is small, so the jig must place the signature cell in the + sweet spot — use compute_jig_offset_for_box() (the CLI --solve-jig does this) + to get jig_x_mm/jig_y_mm for a given form before plotting. + """ + return PlotterConfig( + dialect="lineus", name="lineus", + # mm bookkeeping is still used internally; reach is enforced in Line-us units. + bed_max_x_mm=150.0, bed_max_y_mm=150.0, + jig_x_mm=0.0, jig_y_mm=0.0, + lineus=LineUsConfig(), + ) + + +PROFILES: dict[str, "callable[[], PlotterConfig]"] = { + "cr10": _profile_cr10, + "axidraw": _profile_axidraw, + "lineus": _profile_lineus, +} + + +def load_profile(name: str) -> PlotterConfig: + """Return a PlotterConfig for a named profile (cr10 | axidraw | lineus).""" + key = (name or "cr10").lower() + if key not in PROFILES: + raise ValueError(f"unknown plotter profile '{name}'; choices: {sorted(PROFILES)}") + return PROFILES[key]() + + # ── Geometry: strokes (0..1) -> fitted PDF points -> bed mm ────────────────── def _vector_bbox(strokes: list[list[dict]]) -> tuple[float, float, float, float]: @@ -183,9 +283,188 @@ def plan_signature(vector: dict, box: dict, cfg: PlotterConfig) -> list[PlannedS return pdf_points_to_bed_mm(fitted, cfg) +# ── Line-us coordinate mapping + reach checking ────────────────────────────── +# +# The Line-us arm has a small, fan-shaped reach. Bed-mm coordinates (the same +# internal representation every dialect uses) are affine-mapped to Line-us units +# with a uniform scale + origin, then reach-checked against the shoulder pivot. + +def bed_mm_to_lineus_units(x_mm: float, y_mm: float, lu: LineUsConfig) -> tuple[float, float]: + """Map a bed-mm point to Line-us native units.""" + return ( + lu.origin_x_units + x_mm * lu.units_per_mm, + lu.origin_y_units + y_mm * lu.units_per_mm, + ) + + +def _lineus_reach_ok(ux: float, uy: float, lu: LineUsConfig) -> bool: + """True if a Line-us-unit point lies within the arm's reach annulus.""" + d = math.hypot(ux - lu.shoulder_x_units, uy - lu.shoulder_y_units) + return lu.reach_min_units <= d <= lu.reach_max_units + + +def check_reach(planned: list[PlannedStroke], cfg: PlotterConfig) -> dict: + """Verify EVERY planned point is reachable by the configured machine. + + For "lineus" this checks the fan-shaped reach annulus; for "marlin" it checks + the rectangular bed envelope. Returns a report dict: + { ok, dialect, total_points, out_of_reach, first_bad, min_d, max_d } + Call this BEFORE plotting so a tiny machine never tries to draw where it + cannot reach (which would distort the signature). + """ + pts = [(x, y) for s in planned for (x, y) in s.points_mm] + total = len(pts) + + if cfg.dialect == "lineus": + lu = cfg.lineus or LineUsConfig() + bad = 0 + first_bad: tuple[float, float] | None = None + dists: list[float] = [] + for (x_mm, y_mm) in pts: + ux, uy = bed_mm_to_lineus_units(x_mm, y_mm, lu) + d = math.hypot(ux - lu.shoulder_x_units, uy - lu.shoulder_y_units) + dists.append(d) + if not (lu.reach_min_units <= d <= lu.reach_max_units): + bad += 1 + if first_bad is None: + first_bad = (round(x_mm, 2), round(y_mm, 2)) + return { + "ok": bad == 0, + "dialect": "lineus", + "total_points": total, + "out_of_reach": bad, + "first_bad": first_bad, + "min_d": round(min(dists), 1) if dists else None, + "max_d": round(max(dists), 1) if dists else None, + "reach": [lu.reach_min_units, lu.reach_max_units], + } + + # marlin: rectangular bed + bad = 0 + first_bad = None + for (x, y) in pts: + if x < 0 or y < 0 or x > cfg.bed_max_x_mm or y > cfg.bed_max_y_mm: + bad += 1 + if first_bad is None: + first_bad = (round(x, 2), round(y, 2)) + return { + "ok": bad == 0, + "dialect": "marlin", + "total_points": total, + "out_of_reach": bad, + "first_bad": first_bad, + "bed": [cfg.bed_max_x_mm, cfg.bed_max_y_mm], + } + + +def compute_jig_offset_for_box( + box: dict, + cfg: PlotterConfig, + *, + vector: dict | None = None, + margin_units: float = 50.0, +) -> dict: + """For a small machine, find a jig offset that brings the signature into reach. + + The signature box (PDF points) defines where ink must land on the sheet. On a + small arm we cannot reach an arbitrary page location, so we slide the paper + (i.e. choose jig_x_mm/jig_y_mm) until the signature sits in the sweet spot of + the reach annulus. This returns the recommended jig offset (mm) plus whether + the whole signature then fits. + + When ``vector`` is given, we solve + reach-check against the ACTUAL fitted ink + extent (left-aligned, sub-cell) rather than the full cell rectangle. A full + cell line (e.g. CMS-855 spans ~422pt) is far wider than the real signature, so + using the ink extent is both more correct and far more likely to fit a small + arm. When ``vector`` is None we fall back to the whole-cell corners. + + Strategy: aim the CENTRE of the (ink or cell) bbox at the mid-radius of the + reach annulus, straight ahead of the shoulder (uy = shoulder_y). Solve for the + jig offset that places it there, then reach-check the bbox corners. + """ + if cfg.dialect != "lineus": + return {"ok": True, "note": "Reach is rectangular; no jig solve needed.", + "jig_x_mm": cfg.jig_x_mm, "jig_y_mm": cfg.jig_y_mm} + + lu = cfg.lineus or LineUsConfig() + + # Determine the target bbox (PDF points): the actual ink extent if we have the + # vector, otherwise the full cell. + if vector and (vector.get("strokes")): + fitted = fit_strokes_to_box(vector, box) + ink_pts = [p for s in fitted for p in s] + if ink_pts: + bx = min(p[0] for p in ink_pts) + by = min(p[1] for p in ink_pts) + bw = max(p[0] for p in ink_pts) - bx + bh = max(p[1] for p in ink_pts) - by + else: + bx, by = float(box["x"]), float(box["y"]) + bw, bh = float(box["w"]), float(box["h"]) + else: + bx, by = float(box["x"]), float(box["y"]) + bw, bh = float(box["w"]), float(box["h"]) + + # Centre of the target bbox in PDF points -> the bed-mm point we want at the + # annulus mid-radius, directly ahead of the shoulder. + cx_pt = bx + bw / 2.0 + cy_pt = by + bh / 2.0 + cx_mm_in_sheet = cx_pt * PT_TO_MM + cy_mm_in_sheet = cy_pt * PT_TO_MM + + mid_r = (lu.reach_min_units + lu.reach_max_units) / 2.0 + target_ux = lu.shoulder_x_units + mid_r + target_uy = lu.shoulder_y_units + + # We need: origin + (jig + sheet_offset)*units_per_mm = target_units. + # => jig_mm = (target_units - origin_units)/units_per_mm - sheet_offset_mm + new_jig_x = (target_ux - lu.origin_x_units) / lu.units_per_mm - cx_mm_in_sheet + new_jig_y = (target_uy - lu.origin_y_units) / lu.units_per_mm - cy_mm_in_sheet + + test_cfg = _replace_cfg(cfg, jig_x_mm=new_jig_x, jig_y_mm=new_jig_y) + # Reach-check: against the real fitted ink if we have it, else the cell corners. + if vector and (vector.get("strokes")): + check_planned = plan_signature(vector, box, test_cfg) + else: + corners_pt = [(bx, by), (bx + bw, by), (bx + bw, by + bh), (bx, by + bh)] + check_planned = pdf_points_to_bed_mm([corners_pt], test_cfg) + rep = check_reach(check_planned, test_cfg) + + return { + "ok": rep["ok"], + "jig_x_mm": round(new_jig_x, 2), + "jig_y_mm": round(new_jig_y, 2), + "reach_report": rep, + "note": ( + "Cell fits in reach with this jig offset." + if rep["ok"] + else "Cell does NOT fully fit; the machine's reach is too small for this " + "signature-cell size — reduce the cell or use a larger machine." + ), + } + + +def _replace_cfg(cfg: PlotterConfig, **changes: Any) -> PlotterConfig: + """Return a copy of cfg with fields overridden (frozen dataclass helper).""" + from dataclasses import replace + return replace(cfg, **changes) + + # ── G-code emission (Marlin/GRBL) ──────────────────────────────────────────── def emit_gcode(planned: list[PlannedStroke], cfg: PlotterConfig) -> str: + """Emit machine code for the configured dialect. + + Dispatches to the Marlin/GRBL emitter (CR-10, AxiDraw) or the Line-us + emitter. Both consume the same bed-mm planned strokes; the per-dialect + converter runs at the end. + """ + if cfg.dialect == "lineus": + return emit_lineus_gcode(planned, cfg) + return emit_marlin_gcode(planned, cfg) + + +def emit_marlin_gcode(planned: list[PlannedStroke], cfg: PlotterConfig) -> str: """Emit Marlin/GRBL G-code that draws the planned strokes with the pen. Assumes the machine is homed (G28) so (0,0) is the bed corner; the jig offset @@ -237,6 +516,54 @@ def emit_gcode(planned: list[PlannedStroke], cfg: PlotterConfig) -> str: return "\n".join(out) + "\n" +def emit_lineus_gcode(planned: list[PlannedStroke], cfg: PlotterConfig) -> str: + """Emit Line-us GCode for the planned strokes. + + The Line-us speaks a GCode subset in its OWN units, where the pen height is + the Z value of each move (low Z = down, high Z = up). It acks each command + with "ok". We convert bed-mm to Line-us units, raise the pen between strokes, + and emit one G01 per point. A leading comment carries a reach warning if any + point falls outside the arm's envelope (call check_reach() to gate plotting). + """ + lu = cfg.lineus or LineUsConfig() + out: list[str] = [] + w = out.append + + def to_units(x_mm: float, y_mm: float) -> tuple[float, float]: + return bed_mm_to_lineus_units(x_mm, y_mm, lu) + + report = check_reach(planned, cfg) + + w("; --- Performance West ink-signature plot (Line-us) ---") + w("; Line-us native GCode; pen height is the Z value of each move.") + if not report["ok"]: + w(f"; !! WARNING: {report['out_of_reach']} of {report['total_points']} points " + f"fall outside the arm reach {report.get('reach')}. Re-jig the form " + f"(compute_jig_offset_for_box) before plotting.") + w("G28 ; home the arm") + # Start pen up at the first stroke's start to avoid a dragging line. + for s in planned: + if not s.points_mm: + continue + x0, y0 = s.points_mm[0] + ux0, uy0 = to_units(x0, y0) + # travel with pen up, then drop pen + w(f"G01 X{ux0:.0f} Y{uy0:.0f} Z{lu.pen_up_z:d} ; travel to stroke start (pen up)") + w(f"G01 X{ux0:.0f} Y{uy0:.0f} Z{lu.pen_down_z:d} ; pen down") + for (x, y) in s.points_mm[1:]: + ux, uy = to_units(x, y) + w(f"G01 X{ux:.0f} Y{uy:.0f} Z{lu.pen_down_z:d}") + # lift pen at end of stroke + lx, ly = s.points_mm[-1] + ulx, uly = to_units(lx, ly) + w(f"G01 X{ulx:.0f} Y{uly:.0f} Z{lu.pen_up_z:d} ; pen up") + # Park: pen up, return to a safe rest position ahead of the shoulder. + park_x = lu.shoulder_x_units + (lu.reach_min_units + lu.reach_max_units) / 2.0 + w(f"G01 X{park_x:.0f} Y{lu.shoulder_y_units:.0f} Z{lu.pen_up_z:d} ; park") + w("; --- end plot ---") + return "\n".join(out) + "\n" + + def emit_test_box_gcode(cfg: PlotterConfig, box: dict) -> str: """Emit G-code that draws the OUTLINE of the signature box (no signature). @@ -310,6 +637,102 @@ def send_gcode_serial( ser.close() +def send_lineus( + gcode: str, + *, + host: str = "line-us.local", + tcp_port: int = 1337, + serial_port: str | None = None, + baud: int = 115200, + dry_run: bool = True, + line_timeout: float = 30.0, +) -> dict: + """Send Line-us GCode over WiFi (TCP 1337) or USB serial. + + The Line-us connects over its own TCP socket (sends a "hello"/greeting on + connect) OR over USB serial, and acks each command with "ok". Defaults to + ``dry_run=True`` so it never moves the arm unless explicitly enabled. + + Pass serial_port to use USB instead of WiFi. + """ + lines = [ln for ln in (l.strip() for l in gcode.splitlines()) + if ln and not ln.startswith(";")] + + if dry_run: + return { + "sent": False, "dry_run": True, + "transport": "serial" if serial_port else "tcp", + "target": serial_port or f"{host}:{tcp_port}", + "lines": len(lines), + "note": "DRY RUN — no I/O. Set dry_run=False to plot.", + } + + import time + + def _wait_ok(read_line) -> None: + deadline = time.time() + line_timeout + while time.time() < deadline: + resp = read_line().decode("ascii", "ignore").strip().lower() + if resp.startswith("ok"): + return + if resp.startswith("error") or resp.startswith("!!"): + raise RuntimeError(f"Line-us error: {resp}") + raise RuntimeError("Line-us ack timeout") + + if serial_port: + try: + import serial # pyserial + except ImportError as exc: # pragma: no cover + raise RuntimeError("pyserial required for USB (pip install pyserial)") from exc + ser = serial.Serial(serial_port, baud, timeout=line_timeout) + try: + time.sleep(2.0) + ser.reset_input_buffer() + sent = 0 + for ln in lines: + ser.write((ln + "\n").encode("ascii")) + ser.flush() + _wait_ok(ser.readline) + sent += 1 + return {"sent": True, "dry_run": False, "transport": "serial", + "target": serial_port, "lines": sent} + finally: + ser.close() + + # WiFi: raw TCP socket. + import socket + sock = socket.create_connection((host, tcp_port), timeout=line_timeout) + sock.settimeout(line_timeout) + buf = b"" + + def _readline() -> bytes: + nonlocal buf + while b"\x00" not in buf and b"\n" not in buf: + chunk = sock.recv(256) + if not chunk: + break + buf += chunk + # Line-us terminates responses with a NUL byte. + sep = b"\x00" if b"\x00" in buf else b"\n" + if sep in buf: + line, _, buf = buf.partition(sep) + return line + line, buf = buf, b"" + return line + + try: + _readline() # consume the connect greeting + sent = 0 + for ln in lines: + sock.sendall(ln.encode("ascii") + b"\x00") + _wait_ok(_readline) + sent += 1 + return {"sent": True, "dry_run": False, "transport": "tcp", + "target": f"{host}:{tcp_port}", "lines": sent} + finally: + sock.close() + + # ── Preview rendering (verify WITHOUT hardware) ────────────────────────────── def render_preview_svg(