#!/usr/bin/env python3 """ink_signature_cli — produce/plot an ink signature for a signed filing. End-to-end glue for the pen-plotter pipeline. Pulls a signed esign record's captured signature_vector + signature_anchors from the DB, fits the strokes to the form's signature box, and: * writes a G-code file (for the CR-10 V2 / any Marlin/GRBL plotter) * writes an SVG toolpath preview (verify before plotting) * optionally streams the G-code to the plotter over USB serial (--plot) Calibration helper: * --test-box draws just the signature-box outline so you can align the jig This never plots unless --plot is passed (default is generate-only / dry-run), so it is safe to run anywhere. Examples: # Generate gcode + preview for order CO-ABCD1234's CMS-10114 signature: python scripts/workers/ink_signature_cli.py --order CO-ABCD1234 --doc cms10114 # Calibrate the jig (draw the box outline) to /tmp, dry-run: python scripts/workers/ink_signature_cli.py --order CO-ABCD1234 --doc cms10114 --test-box # Actually plot it (sheet loaded in jig, printer on /dev/ttyUSB0): python scripts/workers/ink_signature_cli.py --order CO-ABCD1234 --doc cms10114 \ --plot --port /dev/ttyUSB0 --home """ from __future__ import annotations import argparse import json import os import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "scripts")) # Import the plotter module directly (avoid workers.services package __init__, # which pulls in heavy browser-automation deps we don't need here). import importlib.util as _ilu _spec = _ilu.spec_from_file_location( "ink_signature_plotter", ROOT / "scripts/workers/services/ink_signature_plotter.py", ) ink = _ilu.module_from_spec(_spec) sys.modules["ink_signature_plotter"] = ink _spec.loader.exec_module(ink) def _load_record(order: str, doc: str) -> dict: import psycopg2 import psycopg2.extras dsn = os.environ.get("DATABASE_URL", "") if not dsn: sys.exit("DATABASE_URL not set") conn = psycopg2.connect(dsn) try: with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: cur.execute( """SELECT id, order_number, document_type, signature_type, signature_vector, signature_anchors, status FROM esign_records WHERE order_number = %s AND document_type = %s AND status = 'signed' ORDER BY signed_at DESC LIMIT 1""", (order, doc), ) row = cur.fetchone() finally: conn.close() if not row: sys.exit(f"No signed esign record for order={order} doc={doc}") return dict(row) def _coerce_json(val): if val is None: return None if isinstance(val, (dict, list)): return val return json.loads(val) def main(): ap = argparse.ArgumentParser(description="Generate/plot an ink signature for a signed filing.") ap.add_argument("--order", required=True, help="order number, e.g. CO-ABCD1234") ap.add_argument("--doc", default="cms10114", help="document_type (cms10114, cms855i, ...)") ap.add_argument("--field", default="signer", help="signature anchor field to draw") ap.add_argument("--out-dir", default="/tmp", help="where to write gcode/svg") ap.add_argument("--port", default="/dev/ttyUSB0") ap.add_argument("--baud", type=int, default=115200) ap.add_argument("--jig-x", type=float, default=20.0, help="jig X offset mm") ap.add_argument("--jig-y", type=float, default=20.0, help="jig Y offset mm") ap.add_argument("--pen-down", type=float, default=0.0, help="pen-down Z mm") ap.add_argument("--pen-up", type=float, default=3.0, help="pen-up Z mm") ap.add_argument("--servo-pen", action="store_true", help="use M280 servo/BLTouch pen instead of Z") ap.add_argument("--test-box", action="store_true", help="draw only the signature-box outline (calibration)") ap.add_argument("--plot", action="store_true", help="actually stream to the plotter (default: dry-run)") ap.add_argument("--home", action="store_true", help="prepend G28 home before plotting") args = ap.parse_args() rec = _load_record(args.order, args.doc) anchors = _coerce_json(rec.get("signature_anchors")) or [] box = next((a for a in anchors if a.get("field") == args.field), anchors[0] if anchors else None) if not box: sys.exit("Record has no signature_anchors — cannot position the signature.") cfg = ink.PlotterConfig( jig_x_mm=args.jig_x, jig_y_mm=args.jig_y, pen_down_mm=args.pen_down, pen_up_mm=args.pen_up, servo_pen=args.servo_pen, ) out_dir = Path(args.out_dir) out_dir.mkdir(parents=True, exist_ok=True) stem = f"{args.order}_{args.doc}" if args.test_box: gcode = ink.emit_test_box_gcode(cfg, box) gpath = out_dir / f"{stem}_testbox.gcode" gpath.write_text(gcode) print(f"[calibration] wrote test-box gcode: {gpath}") planned = ink.pdf_points_to_bed_mm( [[(box["x"], box["y"]), (box["x"] + box["w"], box["y"]), (box["x"] + box["w"], box["y"] + box["h"]), (box["x"], box["y"] + box["h"]), (box["x"], box["y"])]], cfg) else: vector = _coerce_json(rec.get("signature_vector")) if not vector: sys.exit("Record has no signature_vector (typed signature, or signed before vector capture). " "Use the digital stamp, or re-collect the signature.") planned = ink.plan_signature(vector, box, cfg) gcode = ink.emit_gcode(planned, cfg) gpath = out_dir / f"{stem}_signature.gcode" gpath.write_text(gcode) print(f"wrote signature gcode: {gpath} " f"({len(planned)} strokes, {sum(len(s.points_mm) for s in planned)} points)") svg = ink.render_preview_svg(planned, cfg) spath = out_dir / f"{stem}_preview.svg" spath.write_text(svg) print(f"wrote toolpath preview: {spath}") result = ink.send_gcode_serial( gcode, port=args.port, baud=args.baud, dry_run=not args.plot, home_first=args.home, ) print(f"serial: {json.dumps(result)}") if not args.plot: print("NOTE: dry-run (default). Load a sheet in the jig and re-run with --plot to draw.") if __name__ == "__main__": main()