Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/usr/bin/env python3
2"""
3Probe the HP 7585B for its actual limits and emit a usable test pattern.
4
5The plotter knows what sheet is loaded and what hard-clip limits apply far
6better than any TOML guess. This script:
7
8 1. opens the serial port,
9 2. sends OE; OH; OP; OF; OS; (all read-only, no movement),
10 3. parses the responses,
11 4. prints a human summary,
12 5. optionally writes a hand-rolled HPGL test pattern sized to fit *inside*
13 the reported P1/P2 area with margin (`--write-test out.hpgl`),
14 6. optionally writes a vpype paper profile snippet to merge into vpype.toml
15 (`--write-toml`).
16
17Usage:
18 query-limits.py --port /dev/cu.usbserial-110
19 query-limits.py --port /dev/cu.usbserial-110 --write-test safe.hpgl
20 query-limits.py --port /dev/cu.usbserial-110 --write-toml > paper.toml
21"""
22
23from __future__ import annotations
24
25import argparse
26import os
27import sys
28import time
29
30import serial
31
32XON, XOFF = 0x11, 0x13
33
34
35def _ask(ser: serial.Serial, cmd: bytes, settle_s: float = 1.0) -> str:
36 ser.reset_input_buffer()
37 ser.write(cmd)
38 ser.flush()
39 deadline = time.time() + settle_s
40 buf = bytearray()
41 while time.time() < deadline:
42 chunk = ser.read(64)
43 if chunk:
44 for b in chunk:
45 if b not in (XON, XOFF):
46 buf.append(b)
47 # plotter terminates output strings with CR / LF; bail early
48 if b"\n" in buf or b"\r" in buf:
49 break
50 else:
51 time.sleep(0.05)
52 return buf.decode("ascii", errors="replace").strip()
53
54
55def _ints(s: str) -> list[int]:
56 out: list[int] = []
57 for tok in s.replace(",", " ").split():
58 try:
59 out.append(int(tok))
60 except ValueError:
61 pass
62 return out
63
64
65def _decode_status(byte: int) -> str:
66 flags = []
67 if byte & 0x01:
68 flags.append("pen-down")
69 if byte & 0x02:
70 flags.append("p1p2-changed")
71 if byte & 0x04:
72 flags.append("digitized-point")
73 if byte & 0x08:
74 flags.append("initialized")
75 if byte & 0x10:
76 flags.append("ready")
77 if byte & 0x20:
78 flags.append("error")
79 return ",".join(flags) or "<none>"
80
81
82def query(port: str, baud: int) -> dict:
83 ser = serial.Serial(
84 port=port,
85 baudrate=baud,
86 bytesize=serial.EIGHTBITS,
87 parity=serial.PARITY_NONE,
88 stopbits=serial.STOPBITS_ONE,
89 xonxoff=False,
90 rtscts=True,
91 dsrdtr=True,
92 timeout=0.5,
93 )
94 ser.setDTR(True)
95 ser.setRTS(True)
96 try:
97 info = {}
98 info["id"] = _ask(ser, b"OI;")
99 info["error_raw"] = _ask(ser, b"OE;")
100 info["hardclip_raw"] = _ask(ser, b"OH;")
101 info["p1p2_raw"] = _ask(ser, b"OP;")
102 info["factor_raw"] = _ask(ser, b"OF;")
103 info["status_raw"] = _ask(ser, b"OS;")
104 finally:
105 ser.close()
106
107 info["error"] = _ints(info["error_raw"])[0] if _ints(info["error_raw"]) else None
108 hc = _ints(info["hardclip_raw"])
109 pp = _ints(info["p1p2_raw"])
110 of = _ints(info["factor_raw"])
111 st = _ints(info["status_raw"])
112 if len(hc) == 4:
113 info["hardclip"] = (hc[0], hc[1], hc[2], hc[3])
114 if len(pp) == 4:
115 info["p1p2"] = (pp[0], pp[1], pp[2], pp[3])
116 if len(of) >= 2:
117 info["factor"] = (of[0], of[1])
118 if st:
119 info["status"] = st[0]
120 info["status_flags"] = _decode_status(st[0])
121 return info
122
123
124def summarize(info: dict) -> str:
125 lines = []
126 lines.append(f"identifier: {info.get('id', '?')}")
127 err = info.get("error")
128 lines.append(f"error reg: {err} {'(clear)' if err == 0 else '(SET — investigate)'}")
129 if "hardclip" in info:
130 x1, y1, x2, y2 = info["hardclip"]
131 fx, fy = info.get("factor", (40, 40))
132 lines.append(
133 f"hard clip: X={x1}..{x2} Y={y1}..{y2} units"
134 f" ({(x2 - x1) / fx:.1f} x {(y2 - y1) / fy:.1f} mm)"
135 )
136 if "p1p2" in info:
137 x1, y1, x2, y2 = info["p1p2"]
138 fx, fy = info.get("factor", (40, 40))
139 lines.append(
140 f"P1/P2: ({x1},{y1}) ({x2},{y2}) units"
141 f" ({(x2 - x1) / fx:.1f} x {(y2 - y1) / fy:.1f} mm)"
142 )
143 if "factor" in info:
144 fx, fy = info["factor"]
145 lines.append(f"factor: {fx}, {fy} units/mm (= {1000 / fx:.3f} um/unit)")
146 if "status" in info:
147 lines.append(f"status: 0x{info['status']:02X} [{info['status_flags']}]")
148 return "\n".join(lines)
149
150
151def build_test_hpgl(p1p2: tuple[int, int, int, int], pen: int = 1) -> bytes:
152 """Hand-rolled test pattern sized to fit inside P1/P2 with 5% margin.
153
154 Frame + diagonals + centered crosshair + centered circle. All coordinates
155 are clipped to (P1+5%) .. (P2-5%) so we cannot run off-limit.
156 """
157 x1, y1, x2, y2 = p1p2
158 mx = int((x2 - x1) * 0.05)
159 my = int((y2 - y1) * 0.05)
160 fx1, fy1 = x1 + mx, y1 + my
161 fx2, fy2 = x2 - mx, y2 - my
162 cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
163 # circle radius = 35% of the smaller axis
164 r = int(min((fx2 - fx1), (fy2 - fy1)) * 0.35 / 2)
165
166 cmds = []
167 cmds.append(f"IN;SP{pen};")
168 # frame
169 cmds.append(
170 f"PA{fx1},{fy1};PD;PA{fx2},{fy1};PA{fx2},{fy2};"
171 f"PA{fx1},{fy2};PA{fx1},{fy1};PU;"
172 )
173 # diagonals
174 cmds.append(
175 f"PA{fx1},{fy1};PD;PA{fx2},{fy2};PU;"
176 f"PA{fx1},{fy2};PD;PA{fx2},{fy1};PU;"
177 )
178 # crosshair
179 cmds.append(
180 f"PA{fx1},{cy};PD;PA{fx2},{cy};PU;"
181 f"PA{cx},{fy1};PD;PA{cx},{fy2};PU;"
182 )
183 # circle
184 cmds.append(f"PA{cx},{cy};CI{r};PU;")
185 # park
186 cmds.append("PA0,0;SP0;")
187 return ("\n".join(cmds) + "\n").encode("ascii")
188
189
190def build_toml(info: dict, name: str = "current") -> str:
191 if "hardclip" not in info or "factor" not in info:
192 return "# could not query plotter — no profile emitted\n"
193 x1, y1, x2, y2 = info["hardclip"]
194 fx, fy = info["factor"]
195 paper_x_mm = (x2 - x1) / fx
196 paper_y_mm = (y2 - y1) / fy
197 return f"""# Generated from a live OH; query against the connected 7585B.
198# This reflects whatever sheet is currently loaded — re-run query-limits.py
199# whenever you change paper.
200
201[[device.hp7585b.paper]]
202name = "{name}"
203paper_size = ["{paper_x_mm:.2f}mm", "{paper_y_mm:.2f}mm"]
204x_range = [{x1}, {x2}]
205y_range = [{y1}, {y2}]
206y_axis_up = true
207origin_location = ["{paper_x_mm / 2:.2f}mm", "{paper_y_mm / 2:.2f}mm"]
208final_pu_params = "0,0"
209info = "auto-generated from plotter OH; query"
210"""
211
212
213def main() -> int:
214 p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
215 p.add_argument(
216 "--port",
217 default=os.environ.get("HP7585B_TTY", ""),
218 help="serial device (defaults to $HP7585B_TTY)",
219 )
220 p.add_argument("--baud", type=int, default=9600)
221 p.add_argument(
222 "--write-test",
223 metavar="OUT.HPGL",
224 help="write a hand-rolled, in-bounds test plot",
225 )
226 p.add_argument(
227 "--write-toml",
228 action="store_true",
229 help="emit a vpype paper profile to stdout based on the current sheet",
230 )
231 p.add_argument(
232 "--pen", type=int, default=1, help="pen number for the test plot"
233 )
234 args = p.parse_args()
235
236 if not args.port:
237 p.error("no --port and HP7585B_TTY not set")
238
239 info = query(args.port, args.baud)
240 print(summarize(info), file=sys.stderr)
241
242 if args.write_test:
243 if "p1p2" not in info:
244 print("no P1/P2 reading — refusing to generate test", file=sys.stderr)
245 return 2
246 with open(args.write_test, "wb") as f:
247 f.write(build_test_hpgl(info["p1p2"], pen=args.pen))
248 print(f"wrote {args.write_test}", file=sys.stderr)
249
250 if args.write_toml:
251 sys.stdout.write(build_toml(info))
252
253 return 0
254
255
256if __name__ == "__main__":
257 sys.exit(main())