The Standard no-login CMS path needs an ORIGINAL ink signature on paper (CMS-10114: 'Stamped, faxed or copied signatures will not be accepted'). This adds a pipeline to redraw the provider's own captured strokes in real ink with a pen on a CR-10 V2 (or any Marlin/GRBL machine) — original, in ink, never copied. - migration 090: esign_records.signature_vector (JSONB stroke paths, 0..1). - signing page now captures normalized stroke paths alongside the PNG; API stores a size-bounded vector for drawn signatures. - ink_signature_plotter.py (hardware-independent): fit strokes to the signature anchor box, PDF-pt -> bed-mm via jig offset, emit Marlin/GRBL G-code (Z pen or M280 servo/BLTouch), SVG toolpath preview, and render_signature_on_pdf (a digital twin that proves the toolpath lands on the cert line). Gated serial sender (dry_run default). - ink_signature_cli.py: end-to-end load-record -> gcode+preview, --test-box jig calibration, --plot to stream over USB. - Corrected CMS-10114 signature anchor to sit inside the Section 4A signing cell (above the bottom rule, below the label). - docs/ink-signature-plotter.md documents the CR-10 retrofit + interpretive risk. Tests: test_ink_signature.py 30/30, test_cms10114.py 27/27, test_paper_batch.py 15/15, API tsc clean, Astro build 58 pages.
157 lines
6.3 KiB
Python
157 lines
6.3 KiB
Python
#!/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()
|