From 3975315f5197e39dc13e66df3d83ae8670830753 Mon Sep 17 00:00:00 2001 From: ben tingle Date: Sat, 7 Nov 2026 21:22:14 +0000 Subject: [PATCH 1/9] Add MDX-40A Z0 sensor probing (b key) Press b, confirm with Y, and the firmware runs its Z0 sensor cycle for the User (RML-1) coordinate system, based on a captured VPanel session (SET 0x3902 with the 0x2001 sensor pre-checks). The TUI keeps polling during the cycle, Esc requests a stop, and any cancelled/lost/timed-out cycle is reported as unverified. Extracted from the Z-probe parts of PR #3 by btingle (probing.py, tests, docs, decode entries and the TUI hooks); the jogging and move-to changes in that PR are left out. Co-authored-by: Chris Shucksmith Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- docs/z-probing.md | 39 ++++++ rollingmill/decode.py | 5 +- rollingmill/probing.py | 151 ++++++++++++++++++++ rollingmill/tui.py | 62 +++++++- tests/fixtures/mdx40a_zprobe_status.json | 1 + tests/test_probing.py | 171 +++++++++++++++++++++++ 7 files changed, 425 insertions(+), 6 deletions(-) create mode 100644 docs/z-probing.md create mode 100644 rollingmill/probing.py create mode 100644 tests/fixtures/mdx40a_zprobe_status.json create mode 100644 tests/test_probing.py diff --git a/README.md b/README.md index bc719f5..d9700fd 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ The interactive TUI exposes the following keybindings: | c | coordinate systems (activate / move-to / overwrite) | | m | Move-To picker (presets + User Specify numeric entry) | | t | tool diameter offsets | +| b | Z0 sensor probing (User/RML-1; Y confirms, Esc stops) | | q | quit | **Cut panel** (only when `--file` is given) @@ -88,7 +89,6 @@ Gotchas/Notes using the mill * Single-stepping nc-code _partially works_, but the MDX complains about being starved of NC code and lights the View-LED when you reach a cutting operation. * The progress display during a cutting job is that of filling the machine buffer, rather than the actual block being executed, which usually runs a few blocks behind. It's a shame there are not two counters so that this could be made 100% accurate. * There might be an issue with storing A-axis values in workspace offsets and recalling them, as sometimes I get an extra +360° rotation on recall. -* I need a Z-origin sensor to test the sensor-based Z-origin setting, and decode the thickness settings — NYI. * The geometry of the mill means that without tooling, it is fairly difficult (but not impossible) to crash. The rotary axis makes this much easier as the firmware allow the collet to hit the vice area. * Be warned that bad, terrible things could happen driving your hardware with this experimental software, due to known or unknown bugs or defects, for which you entirely agree to take all risks in operating and hold the contributors blameless. diff --git a/docs/z-probing.md b/docs/z-probing.md new file mode 100644 index 0000000..f0b7b55 --- /dev/null +++ b/docs/z-probing.md @@ -0,0 +1,39 @@ +# Z0 sensor probing + +Select **User (RML-1)** with `c`, close the coordinate dialog, then press **b**. +Place the clean connected sensor on the workpiece, position the tool directly +above it, turn off the spindle, and close the cover. **Y** explicitly confirms +and starts the descent/retract cycle; **Esc** dismisses the confirmation or +requests an immediate stop during motion. The TUI keeps polling and displaying +Z/feed. Other machine commands are blocked until a fresh idle reply arrives. + +The firmware updates the User Z origin; the application then reads slot 1 again +and refreshes its coordinate display. A cancellation, device error, lost status, +unobserved motion or timeout is reported as an unverified result, never success. +Quit during an active cycle requests stop; quit again after idle to exit. + +Only mode 1 (User/RML-1) has live-capture evidence. Other WCS selections are +rejected. This feature uses the firmware's existing sensor thickness/settings; +it does not write a guessed thickness. There is no NC streaming during probing; +launch without `--file`. The fresh state must be idle, with cover closed and no +error or spindle-on flag. + +## Protocol evidence + +A passive VPanel/USB-driver capture from a physical MDX-40A showed: + +1. SET 0x03F5 payload 02, operation begin SET 0x1109 payload 00. +2. Stop spindle: SET 0x3006 uint32 big-endian zero. +3. Twice: Pattern B query 0x2001, two-byte reply 00 14. Bit 2 of byte 1 + satisfies the connected check; bit 3 is the contact condition. +4. SET **0x3902**, payload **00 01 FF FF FF FF** (`>HHH`, mode 1, -1, -1). +5. Direct GET 0x0100 32-byte status polls during descent/retraction. During + this phase the TUI does not send its normal idle heartbeat/secondary reads. +6. Motion flags clear, then re-read the User origin with Pattern B 0x030B. + +Operation cleanup restores 0x03F5/0x1109 to FF, matching VPanel's operation exit. + +The captured cycle descended at 60 mm/min, retracted at 1800 mm/min and ended +in about 16.9 seconds. Those speeds are firmware-controlled, not hard-coded +motion commands. A lowest sampled Z of -79.880 mm preceded reversal; exact +contact instant was between samples. No text success response is sent. diff --git a/rollingmill/decode.py b/rollingmill/decode.py index 449083f..81400c1 100644 --- a/rollingmill/decode.py +++ b/rollingmill/decode.py @@ -79,6 +79,9 @@ 0x3809: 'rotatary_drill', # SET 2×uint16 [1,0xFFFF]=start / [0,0]=stop (A-axis, Drill Workpiece dialog) 0x3900: 'spindle_rpm_read', # Pattern B; GET 0x0003 → 1×uint32 RPM 0x3901: 'spindle_rpm_write', # SET 1×uint32 RPM; waits ping bit21 + # ── Z0 probing ─────────────────────────────────────────────────────────── + 0x2001: 'z0_sensor_status', + 0x3902: 'z0_probe', # ── Operation bracket ──────────────────────────────────────────────────── 0x1109: 'op_bracket', # SET 1 byte: 0x00=begin, 0xff=end # ── Coordinate system selection ────────────────────────────────────────── @@ -114,7 +117,7 @@ # SET commands that prime the device; the next GET 0x0003 is their response. # Tracked by parse_lines so decode_line can dispatch 0x0003 correctly. _PATTERN_B: frozenset = frozenset({ - 0x3804, 0x2405, + 0x2001, 0x3804, 0x2405, 0x3005, 0x3003, 0x3800, 0x3b01, 0x3900, 0x0101, diff --git a/rollingmill/probing.py b/rollingmill/probing.py new file mode 100644 index 0000000..1d6b18f --- /dev/null +++ b/rollingmill/probing.py @@ -0,0 +1,151 @@ +"""Cooperative Z0 sensor cycle captured on a real MDX-40A. + +Only the User/RML-1 origin (mode 1) is supported. No sensor thickness or +uncaptured coordinate-mode settings are written. +""" +import logging +import struct +import time + +import usb.core + +from .machine import FLAG_CMD_MOVE, FLAG_DOOR, FLAG_ERROR, FLAG_MOVING, FLAG_SPINDLE, _decode_state + +log = logging.getLogger(__name__) +MOTION = FLAG_CMD_MOVE | FLAG_MOVING | 0x00002000 + + +class ZProbe: + """Call update with each *fresh* poll result; UI remains responsive.""" + + def __init__(self, machine, clock=time.monotonic): + self.machine = machine + self.clock = clock + self.active = False + self.message = "" + self._bracket = False + self._seen_motion = False + self._cancelled = False + self._started = 0.0 + self._last_fresh = 0.0 + + def start(self): + if self.active: + raise ValueError("A Z-probe cycle is already active") + if self.machine.active_wcs != 1: + raise ValueError("Select User (RML-1) in the coordinate dialog first") + state = self.machine.poll() + if state is None or not state.idle or self.machine.is_busy: + raise ValueError("Fresh idle machine status is required") + if state.flags & (FLAG_DOOR | FLAG_ERROR | FLAG_CMD_MOVE | FLAG_MOVING): + raise ValueError("Close the cover and clear machine errors/motion first") + if state.flags & FLAG_SPINDLE: + raise ValueError("Turn the spindle off before probing") + link = self.machine._link + try: + link.vend_set(0x03F5, b"\x02") + # Track before writing: an ambiguous USB failure may still reach firmware. + self._bracket = True + link.vend_set(0x1109, b"\x00") + link.vend_set(0x3006, struct.pack(">I", 0)) + for _ in range(2): + sensor = self.machine.pattern_b_read(0x2001, 2) + if len(sensor) != 2: + raise ValueError("Short Z0 sensor reply") + if not sensor[1] & 0x04: + raise ValueError("Z0 sensor is not connected") + if sensor[1] & 0x08: + raise ValueError("Z0 sensor is already in contact with the tool") + self._started = self._last_fresh = self.clock() + self._seen_motion = self._cancelled = False + self.active = True + self.message = "Z-probe starting; Esc stops motion" + link.vend_set(0x3902, struct.pack(">HHH", 1, 0xFFFF, 0xFFFF)) + except (ValueError, usb.core.USBError): + if self.active: + # Motion write outcome is unknown. Keep UI locked until fresh idle. + self.cancel("Probe command failed; stopping, outcome unverified") + else: + self._finish_bracket() + raise + log.info(self.message) + + def _finish_bracket(self): + if self._bracket: + # Try both even if one fails. Do not report a transport failure as success. + errors = [] + for cmd, data in ((0x03F5, b"\xff"), (0x1109, b"\xff")): + try: + self.machine._link.vend_set(cmd, data) + except usb.core.USBError as exc: + errors.append(str(exc)) + self._bracket = False + if errors: + raise ValueError("Probe cleanup failed: " + "; ".join(errors)) + + def cancel(self, reason="Z-probe cancelled; waiting for motion to stop"): + if self.active and not self._cancelled: + self._cancelled = True + self.machine.stop_motion() + self.message = reason + log.warning(reason) + + def poll(self): + """During the cycle use only the captured status reads, not idle heartbeat writes.""" + try: + raw = self.machine._link.vend_get(0x0100, 32) + if len(raw) != 32: + return None + state = _decode_state(raw) + self.machine._state = state + self.machine._ping_status() + return state + except usb.core.USBError: + return None + + def update(self, state): + if not self.active: + return + now = self.clock() + if state is None: + if now - self._last_fresh > 3: + self.cancel("Status lost; stop requested, waiting for reconnection") + return + self._last_fresh = now + if state.flags & FLAG_ERROR: + self.cancel("Machine error during probing; stop requested") + busy = bool(state.flags & MOTION) + self._seen_motion |= busy + if now - self._started > 120: + self.cancel("Z-probe timed out; stop requested") + if not self._seen_motion and now - self._started > 3: + self.cancel("Probe motion was not observed; result unverified") + if busy or not state.idle: + if not self._cancelled: + feed = int.from_bytes(state.raw[24:26], "big") if len(state.raw) >= 26 else 0 + self.message = f"Z-probe running: Z {state.z_mm:.3f} mm, feed {feed} mm/min; Esc stops" + return + if not self._cancelled and not self._seen_motion: + return # Never treat the first still-idle poll as completion. + try: + self._finish_bracket() + if self._cancelled: + self.message = "Z-probe stopped; origin result unverified" + else: + origin = self.machine.get_wcs_origin(1) + if origin is None: + raise ValueError("Cycle ended but User origin could not be refreshed") + self.machine._wcs_offset = origin + self.message = f"Z-probe cycle ended; User Z origin {origin[2]:.3f} mm" + log.info(self.message) + except (ValueError, usb.core.USBError) as exc: + self.message = f"Z-probe result unverified: {exc}" + log.error(self.message) + self.active = False + + def close(self): + """Best-effort stop/cleanup when the application exits unexpectedly.""" + if self.active: + self.cancel("Application exiting; Z-probe stop requested") + self._finish_bracket() + self.active = False diff --git a/rollingmill/tui.py b/rollingmill/tui.py index 8dfb69c..17ddedf 100644 --- a/rollingmill/tui.py +++ b/rollingmill/tui.py @@ -20,6 +20,7 @@ c open coordinate systems dialog (activate / move-to / overwrite) m open Move-To picker (presets + User Specify numeric entry) t open tool diameter offsets dialog + b Z0 sensor probing (Y confirms; Esc stops) q quit Cut panel (visible when --file is given): @@ -36,6 +37,8 @@ import logging import time from typing import Optional, Tuple + +import usb.core import sys from . import machine as _machine @@ -47,6 +50,7 @@ from .check import lint_file from .trace import Tracer from .cutjob import CutJob +from .probing import ZProbe # ── Jog parameters ──────────────────────────────────────────────────────────── @@ -136,6 +140,8 @@ def __init__(self, machine: _machine.MDX40A, log_buf: _LogBuffer, self._tool_data : Optional[list] = None # list[8] float|None self._tool_editing = False self._tool_edit_buf = '' + self._probe = ZProbe(machine) + self._probe_confirm = False # ── Entry point ─────────────────────────────────────────────────────────── @@ -169,11 +175,12 @@ def run(self, stdscr: curses.window) -> None: # Ping handoff — drive machine polling from the main loop. now = time.monotonic() if now - self._last_poll >= _machine.POLL_INTERVAL: - self._m.poll() + fresh = self._probe.poll() if self._probe.active else self._m.poll() + self._probe.update(fresh) self._last_poll = now self._update_jog_indicator() - if self._cut_job: + if self._cut_job and not self._probe.active: self._cut_job.service() self._draw(stdscr) @@ -210,6 +217,21 @@ def P(idx, fg, bg=-1): # ── Input ───────────────────────────────────────────────────────────────── def _handle_key(self, key: int) -> None: + if self._probe.active: + if key in (27, ord('q'), ord('Q')): + self._probe.cancel() + return # No jog, spindle, WCS edits or NC streaming during probing. + if self._probe_confirm: + if key in (27, ord('n'), ord('N'), ord('q'), ord('Q')): + self._probe_confirm = False + elif key in (ord('y'), ord('Y')): + self._probe_confirm = False + try: + self._probe.start() + self.annotate("KEY b Z0 probe confirmed") + except (ValueError, usb.core.USBError) as exc: + logging.getLogger(__name__).warning("Z-probe: %s", exc) + return # Modal dialogs swallow all keys while open if self._wcs_open: self._wcs_handle_key(key) @@ -303,6 +325,11 @@ def _handle_key(self, key: int) -> None: self._move_open_dialog() elif key in (ord('t'), ord('T')): self._tool_open_dialog() + elif key in (ord('b'), ord('B')): + if self._cut_job or self._jog_armed or self._drill_active: + logging.getLogger(__name__).warning("Close the NC job and stop jogging/drilling before Z-probing") + else: + self._probe_confirm = True elif key in (ord('p'), ord('P')): self._m.fetch_axis_snapshot() @@ -442,6 +469,8 @@ def _draw(self, stdscr: curses.window) -> None: log_h = rows - log_row self._draw_log(stdscr, log_row, log_h, cols) + if self._probe.message: + self._put(stdscr, max(0, log_row - 1), 0, self._probe.message) try: stdscr.refresh() @@ -457,6 +486,28 @@ def _draw(self, stdscr: curses.window) -> None: if self._move_open: self._draw_move_dialog(stdscr, rows, cols) + if self._probe_confirm: + self._draw_probe_dialog(stdscr, rows, cols) + + def _draw_probe_dialog(self, stdscr, rows, cols): + lines = [ + "Set User (RML-1) Z origin using Z0 sensor", + "Connect and clean the sensor; place it on the workpiece.", + "Position the tool directly above it; close the cover.", + "Firmware uses its existing sensor thickness/settings.", + "Y starts descent and retract; Esc cancels this dialog.", + ] + if rows < 10 or cols < 30: + self._put(stdscr, 0, 0, "Z-probe: enlarge terminal; Esc cancels") + stdscr.refresh() + return + width = min(cols - 2, 70) + win = curses.newwin(8, width, (rows - 8) // 2, (cols - width) // 2) + win.box() + for i, line in enumerate(lines, 1): + self._put(win, i, 2, line[:width - 4]) + win.refresh() + def _draw_state(self, win: curses.window, height: int, cols: int) -> None: s = self._m.state CP = curses.color_pair @@ -560,7 +611,7 @@ def _draw_state(self, win: curses.window, height: int, cols: int) -> None: if ref_row > row + 1: key_lines = [ ' jog: ←→ X ↑↓ Y a/z Z [] A f speed (vslow/slow/medium/fast) 1-5 step', - ' s spindle <> RPM d A-drill -/+ override% c coords m move-to t tools q quit', + ' s spindle <> RPM d A-drill -/+ override% c coords m move-to t tools b Z-probe q quit', ] for i, line in enumerate(key_lines): r = ref_row + i @@ -1186,7 +1237,10 @@ def main(argv=None) -> int: tui = TUI(m, log_buf, t) if args.file: tui.load_nc_file(args.file) - curses.wrapper(tui.run) + try: + curses.wrapper(tui.run) + finally: + tui._probe.close() if __name__ == '__main__': diff --git a/tests/fixtures/mdx40a_zprobe_status.json b/tests/fixtures/mdx40a_zprobe_status.json new file mode 100644 index 0000000..89e12cd --- /dev/null +++ b/tests/fixtures/mdx40a_zprobe_status.json @@ -0,0 +1 @@ +[[0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 130, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 10, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 252, 237, 0, 0, 0, 0, 0, 0, 17, 148, 0, 7, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 252, 233, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 252, 24, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 251, 82, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 250, 116, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 249, 167, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 248, 223, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 248, 17, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 247, 71, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 246, 122, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 245, 170, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 244, 224, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 244, 35, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 243, 83, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 242, 133, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 241, 189, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 240, 238, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 240, 40, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 239, 78, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 238, 119, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 237, 147, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 236, 203, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 236, 5, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 235, 60, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 234, 102, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 233, 162, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 232, 215, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 232, 15, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 231, 65, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 230, 113, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 229, 170, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 228, 226, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 228, 16, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 227, 67, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 226, 127, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 225, 177, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 224, 227, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 224, 36, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 223, 80, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 222, 140, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 221, 195, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 220, 243, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 220, 35, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 219, 93, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 218, 142, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 217, 186, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 216, 238, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 216, 21, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 215, 63, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 214, 109, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 213, 162, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 212, 220, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 212, 16, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 211, 64, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 210, 123, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 209, 173, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 208, 227, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 208, 22, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 207, 70, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 206, 130, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 205, 180, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 204, 231, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 204, 7, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 203, 63, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 202, 128, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 201, 176, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 200, 228, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 200, 22, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 199, 248, 0, 0, 0, 0, 0, 0, 17, 148, 0, 60, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 216, 252, 0, 0, 0, 0, 0, 0, 17, 148, 7, 8, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 254, 241, 20, 0, 0, 0, 0, 0, 0, 17, 148, 7, 8, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 255, 9, 63, 0, 0, 0, 0, 0, 0, 17, 148, 7, 8, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 255, 33, 186, 0, 0, 0, 0, 0, 0, 17, 148, 7, 8, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 255, 57, 255, 0, 0, 0, 0, 0, 0, 17, 148, 7, 8, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 255, 82, 234, 0, 0, 0, 0, 0, 0, 17, 148, 7, 8, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 255, 105, 134, 0, 0, 0, 0, 0, 0, 17, 148, 7, 8, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 255, 129, 66, 0, 0, 0, 0, 0, 0, 17, 148, 7, 8, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 255, 154, 47, 0, 0, 0, 0, 0, 0, 17, 148, 7, 8, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 255, 179, 24, 0, 0, 0, 0, 0, 0, 17, 148, 7, 8, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 255, 204, 139, 0, 0, 0, 0, 0, 0, 17, 148, 7, 8, 100, 100, 100, 0, 0, 0], [6, 210, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 255, 228, 14, 0, 0, 0, 0, 0, 0, 17, 148, 7, 8, 100, 100, 100, 0, 0, 0], [2, 146, 40, 28, 0, 1, 2, 11, 0, 0, 197, 81, 255, 255, 251, 78, 0, 0, 0, 0, 0, 0, 17, 148, 6, 24, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0], [0, 146, 8, 28, 0, 1, 2, 11, 0, 0, 197, 81, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 148, 0, 0, 100, 100, 100, 0, 0, 0]] \ No newline at end of file diff --git a/tests/test_probing.py b/tests/test_probing.py new file mode 100644 index 0000000..37b77fa --- /dev/null +++ b/tests/test_probing.py @@ -0,0 +1,171 @@ +"""Replay a real captured descent/retract plus failure and UI interlock paths.""" +import json +import struct +from pathlib import Path + +import pytest +import usb.core + +from rollingmill.machine import FLAG_DOOR, FLAG_ERROR, MDX40A, _decode_state +from rollingmill.probing import ZProbe +from rollingmill.tui import TUI, _LogBuffer +from rollingmill.usb import MdxMockUSB, MdxUSB + +IDLE = bytes.fromhex("00 92 08 1c 00 01 02 0a 00 00 c5 51 ff fe fc ed 00 00 00 00 00 00 11 94 00 00 64 64 64 00 00 00") + + +class Link(MdxMockUSB): + def __init__(self, sensor=b"\x00\x14"): + super().__init__({0x0100: IDLE, 0x2001: sensor, 0x030b: struct.pack(">4i", 66420, 161650, -94960, 8600)}) + self.sent = [] + self.fail = None + + def vend_set(self, cmd, data=b""): + self.sent.append((cmd, data)) + if cmd == self.fail: + raise usb.core.USBError("test disconnect") + super().vend_set(cmd, data) + + +def fixture(sensor=b"\x00\x14"): + link = Link(sensor) + m = MDX40A(link) + m.set_active_wcs(1) + link.sent.clear() + clock = [0.0] + probe = ZProbe(m, clock=lambda: clock[0]) + return link, m, probe, clock + + +def test_live_capture_replay_and_exact_wire_packet(): + link, m, probe, clock = fixture() + probe.start() + assert (0x3902, bytes.fromhex("00 01 ff ff ff ff")) in link.sent + assert [c for c, _ in link.sent].count(0x2001) == 2 + # The real USB layer adds exactly the captured driver header. + class Device: + def ctrl_transfer(self, *args, **kwargs): + self.args = args + dev = Device() + MdxUSB(dev, 0, None, 2000).vend_set(0x3902, bytes.fromhex("00 01 ff ff ff ff")) + assert dev.args == (0x40, 1, 0x3902, 0, bytes.fromhex("01 39 02 00 00 01 ff ff ff ff")) + probe.update(_decode_state(IDLE)) + assert probe.active # idle-before-ack is not completion + samples = json.loads((Path(__file__).parent / "fixtures/mdx40a_zprobe_status.json").read_text()) + moving = False + for data in samples: + state = _decode_state(bytes(data)) + if state.flags & 0x04000000: + moving = True + if moving: + clock[0] += 0.2 + probe.update(state) + if not probe.active: + break + assert moving and not probe.active + assert "cycle ended" in probe.message + assert m.wcs_offset[2] == pytest.approx(-94.960) + assert link.sent[-3:] == [(0x03f5, b"\xff"), (0x1109, b"\xff"), (0x030b, b"")] + + +@pytest.mark.parametrize("sensor,match", [(b"\0\0", "not connected"), (b"\0\x1c", "already in contact"), (b"\0", "Short")]) +def test_reject_sensor_without_motion(sensor, match): + link, _, probe, _ = fixture(sensor) + # Preserve short reply (default mock pads responses). + original = probe.machine.pattern_b_read + probe.machine.pattern_b_read = lambda cmd, n: sensor if cmd == 0x2001 else original(cmd, n) + with pytest.raises(ValueError, match=match): + probe.start() + assert not probe.active + assert not any(cmd == 0x3902 for cmd, _ in link.sent) + assert link.sent[-1] == (0x1109, b"\xff") + + +@pytest.mark.parametrize("flag", [FLAG_DOOR, FLAG_ERROR]) +def test_reject_unsafe_state(flag): + link, _, probe, _ = fixture() + raw = bytearray(IDLE) + raw[:4] = (int.from_bytes(raw[:4], 'big') | flag).to_bytes(4, 'big') + link._responses[0x0100] = bytes(raw) + with pytest.raises(ValueError): + probe.start() + assert not any(c == 0x3902 for c, _ in link.sent) + + +def test_unsupported_wcs_no_writes(): + link, m, probe, _ = fixture() + m.set_active_wcs(3) + link.sent.clear() + with pytest.raises(ValueError, match="RML-1"): + probe.start() + assert link.sent == [] + + +def test_no_motion_timeout_is_not_success(): + _, _, probe, clock = fixture() + probe.start() + clock[0] = 4 + probe.update(_decode_state(IDLE)) + assert not probe.active + assert "unverified" in probe.message + + +def test_lost_status_cancel_and_wait_for_fresh_idle(): + link, _, probe, clock = fixture() + probe.start() + clock[0] = 4 + probe.update(None) + assert probe.active and (0x03f3, b"") in link.sent + probe.update(_decode_state(IDLE)) + assert not probe.active and "unverified" in probe.message + + +def test_motion_write_failure_remains_locked_until_idle(): + link, _, probe, _ = fixture() + link.fail = 0x3902 + with pytest.raises(usb.core.USBError): + probe.start() + assert probe.active and (0x03f3, b"") in link.sent + probe.update(_decode_state(IDLE)) + assert not probe.active and "unverified" in probe.message + + +def test_tui_confirmation_and_motion_interlocks(): + link, m, _, _ = fixture() + tui = TUI(m, _LogBuffer()) + tui._handle_key(ord('b')) + assert tui._probe_confirm and not any(c == 0x3902 for c, _ in link.sent) + tui._handle_key(27) + assert not tui._probe_confirm + tui._handle_key(ord('b')) + tui._handle_key(ord('y')) + assert tui._probe.active + n = len(link.sent) + for key in ('z', 's', 'c', 'r', 'b'): + tui._handle_key(ord(key)) + assert len(link.sent) == n + tui._handle_key(27) + assert (0x03f3, b"") in link.sent + assert tui._probe.active # remain locked until a fresh idle response + + +def test_active_poll_has_no_idle_heartbeat_or_extra_queries(): + link, _, probe, _ = fixture() + probe.start() + link.sent.clear() + assert probe.poll() is not None + assert link.sent == [] + + +def test_cancel_keeps_controls_locked_while_moving(): + _, _, probe, _ = fixture() + probe.start() + moving = bytearray(IDLE) + moving[0] = 6 + moving[1] = 0xd2 + moving[2] = 0x28 + probe.cancel() + probe.update(_decode_state(bytes(moving))) + assert probe.active + probe.update(_decode_state(IDLE)) + assert not probe.active and "unverified" in probe.message From e5ef9b1c29b4328474a0f8d604dea686a0bddeff Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sun, 8 Nov 2026 18:02:13 +0000 Subject: [PATCH 2/9] Rename 0x3900/0x3901 from spindle RPM to Z0 sensor thickness Decoding VP_MDX40A.exe: the Setup dialog's "Thickness of Z0 Sensor:" edit box (dialog 4050, control 0x0FD9) is loaded from GET 0x3900 and saved with SET 0x3901, a big-endian uint32 in 1/1000 mm (default 15000, VPanel limits it to 14000-16000). Nothing else reads or writes them, so they were never a spindle RPM. - machine: get_spindle_rpm -> get_z0_sensor_thickness_mm, cached at connect as z0_sensor_thickness_mm; stop taking it as the target RPM, which now starts at SPINDLE_RPM_MIN; fix the spindle_on_rpm docstring (SET 0x3006) - decode: label the read/write as Z0_SENSOR_THICKNESS - docs: add a Z0 sensor section to usb-protocol.md, fix 0x3901 and 0x3a05 Co-Authored-By: Claude Sonnet 5.5 --- docs/machine-state.md | 2 +- docs/usb-protocol.md | 25 +++++++++++++---- rollingmill/decode.py | 12 ++++---- rollingmill/machine.py | 56 +++++++++++++++++++++++--------------- rollingmill/tui.py | 2 +- tests/test_z0_thickness.py | 44 ++++++++++++++++++++++++++++++ 6 files changed, 106 insertions(+), 35 deletions(-) create mode 100644 tests/test_z0_thickness.py diff --git a/docs/machine-state.md b/docs/machine-state.md index 19cebf5..f246ed0 100644 --- a/docs/machine-state.md +++ b/docs/machine-state.md @@ -75,7 +75,7 @@ struct.unpack_from('I` — RPM uint32 | Write spindle target RPM (4500–15000); poll ping bit 21 clear after | | `0x2425` | (bare) | Reset spindle rotation time counter to zero; poll ping bit 21 clear after | +### Z0 sensor + +| wValue | Payload | Notes | +|--------|---------|-------| +| `0x3901` | `>I` — thickness uint32, 1/1000 mm | Write Z0 sensor thickness (Setup dialog OK; VPanel limits it to 14000–16000); poll ping bit 21 clear after. | + +See [z-probing.md](z-probing.md) for the `SET 0x3902` operation that uses the z-origin sensor. + ### Feed rate | wValue | Payload | Notes | @@ -211,7 +226,7 @@ GET 0x0002 (machine type — expect high word 0x1234) SET 0x03f5 b'\x00' (keepalive) SET 0x05f0 → GET 0x0003 (axis scaling — 8×uint32, see machine-calibration.md) SET 0x3804 → GET 0x0003 (rotary angle correction — 6×int32) -SET 0x3900 → GET 0x0003 (read configured spindle RPM) +SET 0x3900 → GET 0x0003 (read configured Z0 sensor thickness, 1/1000 mm) ``` --- diff --git a/rollingmill/decode.py b/rollingmill/decode.py index 81400c1..3dc741d 100644 --- a/rollingmill/decode.py +++ b/rollingmill/decode.py @@ -77,10 +77,10 @@ 0x3009: 'spindle_stop', # SET bare trigger (used in drilling sequences) 0x3808: 'nc_spindle_speed', # SET 2×uint16 [RPM, mode=2]; NC job only 0x3809: 'rotatary_drill', # SET 2×uint16 [1,0xFFFF]=start / [0,0]=stop (A-axis, Drill Workpiece dialog) - 0x3900: 'spindle_rpm_read', # Pattern B; GET 0x0003 → 1×uint32 RPM - 0x3901: 'spindle_rpm_write', # SET 1×uint32 RPM; waits ping bit21 # ── Z0 probing ─────────────────────────────────────────────────────────── 0x2001: 'z0_sensor_status', + 0x3900: 'z0_sensor_thickness_read', # Pattern B; GET 0x0003 → 1×uint32, 1/1000 mm + 0x3901: 'z0_sensor_thickness_write', # SET 1×uint32, 1/1000 mm; waits ping bit21 0x3902: 'z0_probe', # ── Operation bracket ──────────────────────────────────────────────────── 0x1109: 'op_bracket', # SET 1 byte: 0x00=begin, 0xff=end @@ -270,8 +270,8 @@ def decode_line( h, m = secs // 3600, (secs // 60) % 60 return f"{ts} SPINDLE_TIME {h}h {m:02d}m ({secs} s)" if last_trigger == 0x3900 and len(data) >= 4: - rpm = struct.unpack_from('>I', data)[0] - return f"{ts} SPINDLE_RPM_CFG {rpm} RPM" + um = struct.unpack_from('>I', data)[0] + return f"{ts} Z0_SENSOR_THICKNESS {um / 1000:.3f} mm" if last_trigger is not None and ( last_trigger == 0x030b or 0x3202 <= last_trigger <= 0x320a ): @@ -325,8 +325,8 @@ def decode_line( return f"{ts} SPINDLE_STOP (bare)" if wv == 0x3901 and direction == '>' and len(data) >= 4: - rpm = struct.unpack_from('>I', data)[0] - return f"{ts} SPINDLE_RPM_WRITE {rpm} RPM" + um = struct.unpack_from('>I', data)[0] + return f"{ts} Z0_SENSOR_THICKNESS_WRITE {um / 1000:.3f} mm" if wv == 0x3808 and direction == '>' and len(data) >= 4: rpm, mode = struct.unpack_from('>HH', data) diff --git a/rollingmill/machine.py b/rollingmill/machine.py index 5113815..44894c1 100644 --- a/rollingmill/machine.py +++ b/rollingmill/machine.py @@ -80,7 +80,7 @@ def wcs_label(slot: int) -> str: _PING_BUSY_MASK = 0x00200004 # bits 21 and 2 — firmware busy (jog motion-complete gate) _PING_ERROR_MASK = 0x00100000 # bit 20 _PING_BIT21 = 0x00200000 # bit 21 alone — "command acknowledged" flag polled after - # SET 0x3901 (spindle RPM), SET 0x2425 (reset time), etc. + # SET 0x3901 (Z0 sensor thickness), SET 0x2425 (reset time), etc. # ── Machine status flags (first 4 bytes of wValue=0x0100 block, big-endian) ── # @@ -213,7 +213,8 @@ def __init__(self, link: MdxLink): self._cutting_feed_pct: int = 100 # cached cutting feed override % self._spindle_range: Optional[tuple] = None # (speed_min, speed_max) uint32 pair from GET 0x3005 self._spindle_live_speed: Optional[int] = None # latest GET 0x3003 uint32 — current spindle/feed speed - self._spindle_target_rpm: int = SPINDLE_RPM_MIN # configured target RPM (GET/SET 0x3900/0x3901) + self._spindle_target_rpm: int = SPINDLE_RPM_MIN # host-side target RPM; pushed to the mill with SET 0x3006 + self._z0_thickness_mm: Optional[float] = None # configured Z0 sensor thickness (GET 0x3900), read at connect self._spindle_secs: Optional[int] = None self._active_wcs: int = 0 # 0=MCS, 1-10=WCS1-WCS10 self._wcs_offset: tuple = (0.0, 0.0, 0.0, 0.0) # machine coords of active WCS origin (mm/deg) @@ -489,24 +490,37 @@ def spindle_live_speed(self) -> Optional[int]: """ return self._spindle_live_speed - def get_spindle_rpm(self) -> Optional[int]: - """Read configured spindle target RPM from device (Pattern B, GET 0x3900). + def get_z0_sensor_thickness_mm(self) -> Optional[float]: + """Read the configured Z0 sensor thickness from the device, in mm. - RE: get_uint32_0x3900 @ 0x41b220 — dev_trigger_read_uint32s(0x3900, buf, 1). - Returns RPM as uint32 (big-endian from device), or None on error. + Pattern B, GET 0x3900 → one big-endian uint32 in 1/1000 mm (15000 = 15 mm). + RE: get_uint32_0x3900 @ 0x0041b200 — dev_trigger_read_uint32s(0x3900, buf, 1). + Its only caller is read_nc_mode_tool_radius_settings, which fills the + "Thickness of Z0 Sensor:" edit box (dialog 4050, control 0x0FD9) of the + Setup dialog. VPanel's UI limits it to 14.000–16.000 mm. The matching + write is SET 0x3901 (MdxAction_x3901_p1, only called from the Setup + dialog's OK handler); rollingmill does not write it. The firmware + uses this value when a Z0 sensor cycle (SET 0x3902) sets the Z origin. + + Returns None on error or a short reply. """ try: data = self.pattern_b_read(0x3900, 4) if data is None or len(data) < 4: - log.warning("get_spindle_rpm: short/no response") + log.warning("get_z0_sensor_thickness_mm: short/no response") return None - rpm = struct.unpack_from('>I', bytes(data))[0] - log.debug("Spindle target RPM read: %d", rpm) - return rpm - except usb.core.USBError as e: - log.warning("get_spindle_rpm failed: %s", e) + thickness = struct.unpack_from('>I', bytes(data))[0] / 1000.0 + log.debug("Z0 sensor thickness read: %.3f mm", thickness) + return thickness + except (usb.core.USBError, ValueError) as e: + log.warning("get_z0_sensor_thickness_mm failed: %s", e) return None + @property + def z0_sensor_thickness_mm(self) -> Optional[float]: + """Z0 sensor thickness in mm as read at connect time, or None if unread.""" + return self._z0_thickness_mm + def set_target_rpm(self, rpm: int) -> int: """Update the cached spindle target RPM only — does not touch the device. @@ -521,10 +535,9 @@ def set_target_rpm(self, rpm: int) -> int: return rpm def spindle_on_rpm(self, rpm: int) -> None: - """Set spindle target RPM (SET 0x3901, 1 × big-endian uint32). + """Set spindle target RPM (SET 0x3006, 1 × big-endian uint32). - RE: FUN_0041b230 @ 0x41b230 — send_trigger_u32_array(0x3901, &rpm, 1) - then wait_ping_bit21_clear. RPM is clamped to [4500, 15000]. + RPM is clamped to [4500, 15000] (SPINDLE_RPM_MIN/MAX). Updates the cached _spindle_target_rpm immediately so rapid key presses accumulate correctly even before the USB round-trip completes. """ @@ -769,7 +782,7 @@ def _wait_ping_bit21(self, timeout: float = 3.0) -> bool: """Poll GET 0x0001 until bit 21 (_PING_BIT21) clears — firmware command ack. RE: wait_ping_bit21_clear (FUN_0041b930) in VP_MDX40A.exe, 3 s timeout. - Used after SET 0x3901 (spindle RPM), SET 0x2425 (reset spindle time), + Used after SET 0x3901 (Z0 sensor thickness), SET 0x2425 (reset spindle time), SET 0x3107 (axis config), SET 0x347b-0x3482 (axis params), SET 0x2012 (limits). """ deadline = time.monotonic() + timeout @@ -1271,13 +1284,12 @@ def _mdx_handshake(self) -> None: self.get_XYZ_axis_scaling() self.get_rotary_axis_angle_correction() - # Read configured spindle target RPM (GET 0x3900, Pattern B) - rpm = self.get_spindle_rpm() - if rpm is not None and SPINDLE_RPM_MIN <= rpm <= SPINDLE_RPM_MAX: - self._spindle_target_rpm = rpm - log.info("Spindle target RPM read from device: %d", rpm) + # Read configured Z0 sensor thickness (GET 0x3900, Pattern B). + self._z0_thickness_mm = self.get_z0_sensor_thickness_mm() + if self._z0_thickness_mm is not None: + log.info("Z0 sensor thickness: %.3f mm", self._z0_thickness_mm) else: - log.info("Spindle RPM read failed or out of range (%s); defaulting to %d", rpm, SPINDLE_RPM_MIN) + log.info("Z0 sensor thickness read failed") def _read_state(self) -> Optional[MachineState]: """Read the 0x0100 state block (XYZA coords + flags) and cache on self._state. diff --git a/rollingmill/tui.py b/rollingmill/tui.py index 17ddedf..18ef539 100644 --- a/rollingmill/tui.py +++ b/rollingmill/tui.py @@ -567,7 +567,7 @@ def _draw_state(self, win: curses.window, height: int, cols: int) -> None: # Row: spindle speed + on/off # Layout: spindle OFF live 9000 tgt 9000 ×100% = 9000 RPM feed 100% runtime Xh XXm # RE: actual_rpm = MulDiv(target_rpm, pct, 100) @ update_state_and_coords - # <> keys set target RPM (SET 0x3901); +- keys set both override %s together + # <> keys set the host-side target RPM (sent with SET 0x3006); +- keys set both override %s together row += 1 if row < height: spindle_on = bool(s.flags & FLAG_SPINDLE) diff --git a/tests/test_z0_thickness.py b/tests/test_z0_thickness.py new file mode 100644 index 0000000..7dea043 --- /dev/null +++ b/tests/test_z0_thickness.py @@ -0,0 +1,44 @@ +"""Z0 sensor thickness: GET 0x3900 (SET 0x3901 is the Setup dialog's write). + +Decoded from VP_MDX40A.exe: the Setup dialog's "Thickness of Z0 Sensor:" edit box +(dialog 4050, control 0x0FD9) is loaded from GET 0x3900 and saved with SET 0x3901, +both as a big-endian uint32 in 1/1000 mm. +""" +import struct + +import pytest +import usb.core + +from rollingmill import decode +from rollingmill.machine import MDX40A +from rollingmill.usb import MdxMockUSB + + +def test_get_z0_sensor_thickness_reads_micrometres_as_mm(): + mock = MdxMockUSB(responses={0x3900: struct.pack('>I', 15000)}) + assert MDX40A(mock).get_z0_sensor_thickness_mm() == pytest.approx(15.0) + + +def test_thickness_is_cached_at_connect(): + mock = MdxMockUSB(responses={0x3900: struct.pack('>I', 15100)}) + assert MDX40A(mock).z0_sensor_thickness_mm == pytest.approx(15.1) + + +def test_thickness_read_failure_is_none(): + class _Failing(MdxMockUSB): + def vend_get(self, wValue, length): + if wValue == 0x0001: + raise usb.core.USBError("boom") + return super().vend_get(wValue, length) + + m = MDX40A(_Failing()) + assert m.get_z0_sensor_thickness_mm() is None + assert m.z0_sensor_thickness_mm is None + + +def test_decode_labels_read_and_write_as_thickness(): + read = decode.decode_line("t", '<', 0x0003, struct.pack('>I', 15000), last_trigger=0x3900) + assert "Z0_SENSOR_THICKNESS" in read and "15.000 mm" in read + write = decode.decode_line("t", '>', 0x3901, struct.pack('>I', 14500)) + assert "Z0_SENSOR_THICKNESS_WRITE" in write and "14.500 mm" in write + assert "RPM" not in read + write From 874d6c63c94d7a20e8cc9edc97418867bf79d0eb Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sun, 8 Nov 2026 12:08:31 +0000 Subject: [PATCH 3/9] tui: show configured Z0 sensor thickness beside the spindle runtime Co-Authored-By: Claude Sonnet 5.5 --- docs/z-probing.md | 4 +++- rollingmill/tui.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/z-probing.md b/docs/z-probing.md index f0b7b55..acc583b 100644 --- a/docs/z-probing.md +++ b/docs/z-probing.md @@ -14,7 +14,9 @@ Quit during an active cycle requests stop; quit again after idle to exit. Only mode 1 (User/RML-1) has live-capture evidence. Other WCS selections are rejected. This feature uses the firmware's existing sensor thickness/settings; -it does not write a guessed thickness. There is no NC streaming during probing; +it does not write a guessed thickness. The configured value (GET 0x3900, the +Setup dialog's "Thickness of Z0 Sensor", 1/1000 mm) is read at connect and shown in the +TUI state pane to the right of the spindle runtime; check it matches your sensor before probing. There is no NC streaming during probing; launch without `--file`. The fresh state must be idle, with cover closed and no error or spindle-on flag. diff --git a/rollingmill/tui.py b/rollingmill/tui.py index 18ef539..37023cc 100644 --- a/rollingmill/tui.py +++ b/rollingmill/tui.py @@ -591,7 +591,11 @@ def _draw_state(self, win: curses.window, height: int, cols: int) -> None: secs = self._m.spindle_secs if secs is not None: h, m = secs // 3600, (secs % 3600) // 60 - self._put(win, row, 73, f'runtime {h}h {m:02d}m', CP(_CP_LABEL)) + runtime = f'runtime {h}h {m:02d}m' + self._put(win, row, 73, runtime, CP(_CP_LABEL)) + zt = self._m.z0_sensor_thickness_mm + zt_str = f'{zt:.3f}' if zt is not None else '?' + self._put(win, row, 73 + len(runtime) + 2, f'Z0 sensor {zt_str} mm', CP(_CP_LABEL)) # Row: rotary axis centreline origin (only shown when rotary is installed) rotary_byte = self._m.rotary_extension_byte From 9d0f8057721b3057700cb063ec113e5a718f7585 Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sun, 8 Nov 2026 12:36:24 +0000 Subject: [PATCH 4/9] Add Z0 sensor thickness setter and edit it from the probe dialog machine: set_z0_sensor_thickness_mm() writes SET 0x3901 (big-endian uint32, 1/1000 mm) inside the 0x1109 operation bracket, waits for the ping bit 21 ack like the Setup dialog's OK handler, then reads the value back; it is limited to VPanel's 14-16 mm and returns True only if the read-back matches. tui: the 'b' confirmation becomes a Z0 Sensor Probe dialog styled like the others (title, key summary at the bottom). It shows the stored thickness (no re-read), lets it be edited, H sets it, Enter starts the probe cycle (refused while an edited value is unset) and Esc cancels. Y no longer confirms. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- docs/z-probing.md | 34 ++++++++--- rollingmill/machine.py | 48 ++++++++++++++- rollingmill/tui.py | 122 +++++++++++++++++++++++++++++-------- tests/test_probing.py | 2 +- tests/test_z0_thickness.py | 100 ++++++++++++++++++++++++++++++ 6 files changed, 274 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index d9700fd..f1721da 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ The interactive TUI exposes the following keybindings: | c | coordinate systems (activate / move-to / overwrite) | | m | Move-To picker (presets + User Specify numeric entry) | | t | tool diameter offsets | -| b | Z0 sensor probing (User/RML-1; Y confirms, Esc stops) | +| b | Z0 sensor probe dialog (User/RML-1; H sets the sensor thickness, Enter starts, Esc stops) | | q | quit | **Cut panel** (only when `--file` is given) diff --git a/docs/z-probing.md b/docs/z-probing.md index acc583b..1c0068b 100644 --- a/docs/z-probing.md +++ b/docs/z-probing.md @@ -2,10 +2,10 @@ Select **User (RML-1)** with `c`, close the coordinate dialog, then press **b**. Place the clean connected sensor on the workpiece, position the tool directly -above it, turn off the spindle, and close the cover. **Y** explicitly confirms -and starts the descent/retract cycle; **Esc** dismisses the confirmation or -requests an immediate stop during motion. The TUI keeps polling and displaying -Z/feed. Other machine commands are blocked until a fresh idle reply arrives. +above it, turn off the spindle, and close the cover. The **Z0 Sensor Probe** +dialog shows the sensor thickness stored in the machine: **Enter** starts the +descent/retract cycle and **Esc** dismisses the dialog, or requests an +immediate stop during motion. The TUI keeps polling and displaying Z/feed. Other machine commands are blocked until a fresh idle reply arrives. The firmware updates the User Z origin; the application then reads slot 1 again and refreshes its coordinate display. A cancellation, device error, lost status, @@ -14,12 +14,32 @@ Quit during an active cycle requests stop; quit again after idle to exit. Only mode 1 (User/RML-1) has live-capture evidence. Other WCS selections are rejected. This feature uses the firmware's existing sensor thickness/settings; -it does not write a guessed thickness. The configured value (GET 0x3900, the -Setup dialog's "Thickness of Z0 Sensor", 1/1000 mm) is read at connect and shown in the -TUI state pane to the right of the spindle runtime; check it matches your sensor before probing. There is no NC streaming during probing; +it does not write a guessed thickness. The cycle uses the machine's stored sensor +thickness (GET 0x3900 / SET 0x3901, the Setup dialog's "Thickness of Z0 Sensor", +1/1000 mm), which is read at connect, shown in the dialog and in the TUI state pane to +the right of the spindle runtime. Measure your sensor and check it matches: a wrong +value shifts the Z origin the cycle sets by the same amount. To change it, type the +new value in the dialog (14.000–16.000 mm, as in VPanel's Setup dialog) and press +**H**; the value is written, read back and verified, and **Enter** is refused while an +edited value has not been set. There is no NC streaming during probing; launch without `--file`. The fresh state must be idle, with cover closed and no error or spindle-on flag. +## Setting probe thickness + +The cycle subtracts the thickness of the conductive pad, which is stored in the machine: +`SET 0x3902` carries no thickness. The two commands below are the Setup dialog's +"Thickness of Z0 Sensor" box (see [usb-protocol.md](usb-protocol.md)). + +| wValue | Payload | Notes | +|---|---|---| +| `0x3900` | reply `>I`, 1/1000 mm | Read (Pattern B): `SET 0x3900`, poll ping, `GET 0x0003`. `15000` = 15 mm | +| `0x3901` | `>I`, 1/1000 mm | Write, inside the `SET 0x1109` `00` … `FF` bracket; then poll ping bit 21 (`0x00200000`) until it clears (3 s timeout) | + +To set 15.030 mm: `SET 0x1109 00`, `SET 0x3901 00 00 3A B6`, wait for the ack, `SET 0x1109 FF`, then read `0x3900` +back to check it (`00 00 3A B6`). VPanel limits the value to 14.000–16.000 mm; the firmware's own limits are not +known. Writing a changed value and reading it back was confirmed on a real MDX-40A. + ## Protocol evidence A passive VPanel/USB-driver capture from a physical MDX-40A showed: diff --git a/rollingmill/machine.py b/rollingmill/machine.py index 44894c1..0e17299 100644 --- a/rollingmill/machine.py +++ b/rollingmill/machine.py @@ -32,6 +32,11 @@ SPINDLE_RPM_MIN = 4500 SPINDLE_RPM_MAX = 15000 +# Limits VPanel's Setup dialog applies to the Z0 sensor thickness (mm). The firmware's +# own limits are unknown, so the setter holds to the same range. +Z0_THICKNESS_MIN_MM = 14.0 +Z0_THICKNESS_MAX_MM = 16.0 + # WCS slot display labels (index = slot number passed to set_active_wcs / # get_wcs_origin / write_wcs_origin / partial_update_wcs_origin). # Slot 0 is the machine coordinate system; 1 = RML-1 user origin; 2 = EXOFS; @@ -518,9 +523,50 @@ def get_z0_sensor_thickness_mm(self) -> Optional[float]: @property def z0_sensor_thickness_mm(self) -> Optional[float]: - """Z0 sensor thickness in mm as read at connect time, or None if unread.""" + """Z0 sensor thickness in mm as last read or set, or None if unread.""" return self._z0_thickness_mm + def set_z0_sensor_thickness_mm(self, thickness_mm: float) -> bool: + """Write the Z0 sensor thickness to the firmware, then read it back. + + SET 0x3901, one big-endian uint32 in 1/1000 mm, inside the 0x1109 operation + bracket and followed by the ping bit 21 ack wait, as the Setup dialog's OK + handler does (write_ncode_settings → MdxAction_x3901_p1). The 0x3902 Z0 + sensor cycle uses this stored value, so a wrong value shifts the Z origin + it sets by the same amount. + + Raises ValueError outside Z0_THICKNESS_MIN_MM..Z0_THICKNESS_MAX_MM (the + Setup dialog's range). Returns True only if the write was acked and the + value read back matches; the cached `z0_sensor_thickness_mm` is refreshed + from the read-back either way. + """ + if not Z0_THICKNESS_MIN_MM <= thickness_mm <= Z0_THICKNESS_MAX_MM: + raise ValueError( + f"Z0 sensor thickness must be {Z0_THICKNESS_MIN_MM:.3f}–" + f"{Z0_THICKNESS_MAX_MM:.3f} mm, got {thickness_mm:.3f}" + ) + raw = round(thickness_mm * 1000) + try: + self._link.vend_set(0x1109, b'\x00') + try: + self._link.vend_set(0x3901, struct.pack('>I', raw)) + log.info("Z0 sensor thickness → %.3f mm (raw %d)", raw / 1000.0, raw) + acked = self._wait_ping_bit21() + finally: + self._link.vend_set(0x1109, b'\xff') + except usb.core.USBError as e: + log.warning("set_z0_sensor_thickness_mm failed: %s", e) + return False + readback = self.get_z0_sensor_thickness_mm() + if readback is not None: + self._z0_thickness_mm = readback + if not acked: + return False + if readback is None or abs(readback * 1000 - raw) > 0.5: + log.warning("Z0 sensor thickness read-back %s does not match %.3f mm", readback, raw / 1000.0) + return False + return True + def set_target_rpm(self, rpm: int) -> int: """Update the cached spindle target RPM only — does not touch the device. diff --git a/rollingmill/tui.py b/rollingmill/tui.py index 37023cc..f59141c 100644 --- a/rollingmill/tui.py +++ b/rollingmill/tui.py @@ -20,7 +20,7 @@ c open coordinate systems dialog (activate / move-to / overwrite) m open Move-To picker (presets + User Specify numeric entry) t open tool diameter offsets dialog - b Z0 sensor probing (Y confirms; Esc stops) + b Z0 sensor probe dialog (H sets thickness, Enter starts; Esc stops) q quit Cut panel (visible when --file is given): @@ -142,6 +142,9 @@ def __init__(self, machine: _machine.MDX40A, log_buf: _LogBuffer, self._tool_edit_buf = '' self._probe = ZProbe(machine) self._probe_confirm = False + self._probe_buf = '' # Z0 thickness edit buffer (mm) + self._probe_dirty = False # buffer edited but not yet sent with H + self._probe_notice = '' # last result shown in the dialog # ── Entry point ─────────────────────────────────────────────────────────── @@ -222,15 +225,7 @@ def _handle_key(self, key: int) -> None: self._probe.cancel() return # No jog, spindle, WCS edits or NC streaming during probing. if self._probe_confirm: - if key in (27, ord('n'), ord('N'), ord('q'), ord('Q')): - self._probe_confirm = False - elif key in (ord('y'), ord('Y')): - self._probe_confirm = False - try: - self._probe.start() - self.annotate("KEY b Z0 probe confirmed") - except (ValueError, usb.core.USBError) as exc: - logging.getLogger(__name__).warning("Z-probe: %s", exc) + self._probe_dialog_key(key) return # Modal dialogs swallow all keys while open if self._wcs_open: @@ -329,10 +324,63 @@ def _handle_key(self, key: int) -> None: if self._cut_job or self._jog_armed or self._drill_active: logging.getLogger(__name__).warning("Close the NC job and stop jogging/drilling before Z-probing") else: - self._probe_confirm = True + self._probe_open_dialog() elif key in (ord('p'), ord('P')): self._m.fetch_axis_snapshot() + # ── Z0 sensor probe dialog ('b' key) ────────────────────────────────────── + + def _probe_open_dialog(self) -> None: + thickness = self._m.z0_sensor_thickness_mm + self._probe_buf = f'{thickness:.3f}' if thickness is not None else '' + self._probe_dirty = False + self._probe_notice = '' + self._probe_confirm = True + + def _probe_dialog_key(self, key: int) -> None: + if key in (27, ord('q'), ord('Q')): + self._probe_confirm = False + elif key in (10, 13): # Enter — start the probe cycle + if self._probe_dirty: + self._probe_notice = 'Thickness edited: press H to set it first' + return + self._probe_confirm = False + try: + self._probe.start() + self.annotate("KEY b Z0 probe confirmed") + except (ValueError, usb.core.USBError) as exc: + logging.getLogger(__name__).warning("Z-probe: %s", exc) + elif key in (ord('h'), ord('H')): # H — write the thickness + self._probe_set_thickness() + elif key in (127, curses.KEY_BACKSPACE, 8): + self._probe_buf = self._probe_buf[:-1] + self._probe_dirty = True + self._probe_notice = '' + elif 32 <= key < 128 and chr(key) in '0123456789.': + if not self._probe_dirty: + self._probe_buf = '' # first keystroke replaces the value + if len(self._probe_buf) < 8: + self._probe_buf += chr(key) + self._probe_dirty = True + self._probe_notice = '' + + def _probe_set_thickness(self) -> None: + try: + value = float(self._probe_buf) + ok = self._m.set_z0_sensor_thickness_mm(value) + except ValueError as exc: + self._probe_notice = str(exc) if self._probe_buf else 'Enter a thickness in mm first' + return + self.annotate(f"KEY h Z0 sensor thickness {value:.3f} mm ok={ok}") + stored = self._m.z0_sensor_thickness_mm + if stored is not None: + self._probe_buf = f'{stored:.3f}' + if ok: + self._probe_dirty = False + self._probe_notice = f'Set: Z0 sensor thickness is {stored:.3f} mm' + else: + self._probe_notice = 'Set failed: machine did not accept or confirm the value' + def annotate(self, msg: str) -> None: """Write a `# ` marker to the trace, if a tracer was attached. @@ -489,23 +537,49 @@ def _draw(self, stdscr: curses.window) -> None: if self._probe_confirm: self._draw_probe_dialog(stdscr, rows, cols) - def _draw_probe_dialog(self, stdscr, rows, cols): - lines = [ - "Set User (RML-1) Z origin using Z0 sensor", - "Connect and clean the sensor; place it on the workpiece.", - "Position the tool directly above it; close the cover.", - "Firmware uses its existing sensor thickness/settings.", - "Y starts descent and retract; Esc cancels this dialog.", - ] - if rows < 10 or cols < 30: + def _draw_probe_dialog(self, stdscr: curses.window, rows: int, cols: int) -> None: + CP = curses.color_pair + BOLD = curses.A_BOLD + + dh = 12 + dw = min(66, cols - 2) + if rows < dh + 2 or dw < 40: self._put(stdscr, 0, 0, "Z-probe: enlarge terminal; Esc cancels") stdscr.refresh() return - width = min(cols - 2, 70) - win = curses.newwin(8, width, (rows - 8) // 2, (cols - width) // 2) + try: + win = curses.newwin(dh, dw, (rows - dh) // 2, (cols - dw) // 2) + except curses.error: + return + + win.erase() win.box() - for i, line in enumerate(lines, 1): - self._put(win, i, 2, line[:width - 4]) + self._put(win, 0, 2, ' Z0 Sensor Probe', CP(_CP_HEADER) | BOLD) + + lines = [ + 'Set the User (RML-1) Z origin using the Z0 sensor.', + 'Clean and connect the sensor and place it on the workpiece.', + 'Position the tool directly above it and close the cover.', + ] + for i, line in enumerate(lines): + self._put(win, 1 + i, 2, line[:dw - 4], CP(_CP_LABEL)) + + stored = self._m.z0_sensor_thickness_mm + stored_str = f'{stored:.3f} mm' if stored is not None else '?' + field = (self._probe_buf + '_') if self._probe_dirty else (self._probe_buf or '?') + attr = (CP(_CP_ACTIVE) | BOLD) if self._probe_dirty else (CP(_CP_VALUE) | BOLD) + self._put(win, 5, 2, 'Thickness of Z0 sensor:', CP(_CP_LABEL)) + self._put(win, 5, 26, f' {field:<9s}', attr) + self._put(win, 5, 37, 'mm', CP(_CP_LABEL)) + self._put(win, 6, 2, f'in machine: {stored_str} (limits {_machine.Z0_THICKNESS_MIN_MM:.0f}' + f'–{_machine.Z0_THICKNESS_MAX_MM:.0f} mm)'[:dw - 4], CP(_CP_DIM)) + if self._probe_notice: + self._put(win, 8, 2, self._probe_notice[:dw - 4], CP(_CP_LOG_WARN) | BOLD) + + keys = ' Enter start probe H set thickness 0-9 . edit Esc cancel' + self._put(win, dh - 3, 1, '─' * (dw - 2), CP(_CP_LABEL)) + self._put(win, dh - 2, 1, keys[:dw - 2], CP(_CP_KEYS)) + win.refresh() def _draw_state(self, win: curses.window, height: int, cols: int) -> None: diff --git a/tests/test_probing.py b/tests/test_probing.py index 37b77fa..2e8a99e 100644 --- a/tests/test_probing.py +++ b/tests/test_probing.py @@ -138,7 +138,7 @@ def test_tui_confirmation_and_motion_interlocks(): tui._handle_key(27) assert not tui._probe_confirm tui._handle_key(ord('b')) - tui._handle_key(ord('y')) + tui._handle_key(10) assert tui._probe.active n = len(link.sent) for key in ('z', 's', 'c', 'r', 'b'): diff --git a/tests/test_z0_thickness.py b/tests/test_z0_thickness.py index 7dea043..02a011c 100644 --- a/tests/test_z0_thickness.py +++ b/tests/test_z0_thickness.py @@ -42,3 +42,103 @@ def test_decode_labels_read_and_write_as_thickness(): write = decode.decode_line("t", '>', 0x3901, struct.pack('>I', 14500)) assert "Z0_SENSOR_THICKNESS_WRITE" in write and "14.500 mm" in write assert "RPM" not in read + write + + +# ── setter and the 'b' dialog ──────────────────────────────────────────────── + +class _Recording(MdxMockUSB): + """Mock whose GET 0x3900 follows SET 0x3901, like the firmware.""" + + def __init__(self, thickness_um=14140, accept=True): + super().__init__(responses={0x3900: struct.pack('>I', thickness_um)}) + self.sent = [] + self.accept = accept + + def vend_set(self, wValue, data=b""): + self.sent.append((wValue, data)) + if wValue == 0x3901 and self.accept: + self._responses[0x3900] = data + super().vend_set(wValue, data) + + +def test_set_writes_bracketed_uint32_and_verifies_readback(): + link = _Recording() + m = MDX40A(link) + link.sent.clear() + assert m.set_z0_sensor_thickness_mm(15.03) is True + assert link.sent == [(0x1109, b'\x00'), (0x3901, struct.pack('>I', 15030)), (0x1109, b'\xff'), + (0x3900, b'')] + assert m.z0_sensor_thickness_mm == pytest.approx(15.03) + + +@pytest.mark.parametrize("bad", [13.999, 16.001, 0.0, -1.0, 150.0]) +def test_set_rejects_values_outside_vpanel_range_without_writing(bad): + link = _Recording() + m = MDX40A(link) + link.sent.clear() + with pytest.raises(ValueError): + m.set_z0_sensor_thickness_mm(bad) + assert link.sent == [] + + +def test_set_reports_failure_when_readback_differs(): + link = _Recording(accept=False) + m = MDX40A(link) + assert m.set_z0_sensor_thickness_mm(15.03) is False + assert m.z0_sensor_thickness_mm == pytest.approx(14.14) # cache shows what the machine holds + + +def _type(tui, text): + for ch in text: + tui._handle_key(ord(ch)) + + +def test_dialog_shows_stored_value_and_h_sets_it(): + from rollingmill.tui import TUI, _LogBuffer + link = _Recording() + m = MDX40A(link) + tui = TUI(m, _LogBuffer()) + tui._handle_key(ord('b')) + assert tui._probe_confirm and tui._probe_buf == '14.140' and not tui._probe_dirty + link.sent.clear() + _type(tui, '15.03') + assert tui._probe_buf == '15.03' and tui._probe_dirty + assert link.sent == [] # typing alone writes nothing + tui._handle_key(ord('h')) + assert (0x3901, struct.pack('>I', 15030)) in link.sent + assert not tui._probe_dirty and tui._probe_buf == '15.030' + assert tui._probe_confirm # dialog stays open after H + + +def test_enter_is_refused_until_edited_value_is_set(): + from rollingmill.tui import TUI, _LogBuffer + link = _Recording() + tui = TUI(MDX40A(link), _LogBuffer()) + tui._handle_key(ord('b')) + _type(tui, '15.03') + link.sent.clear() + tui._handle_key(10) + assert tui._probe_confirm and not tui._probe.active + assert "H" in tui._probe_notice and link.sent == [] + + +def test_out_of_range_h_keeps_dialog_and_writes_nothing(): + from rollingmill.tui import TUI, _LogBuffer + link = _Recording() + tui = TUI(MDX40A(link), _LogBuffer()) + tui._handle_key(ord('b')) + _type(tui, '20') + link.sent.clear() + tui._handle_key(ord('h')) + assert link.sent == [] and tui._probe_dirty and "14.000" in tui._probe_notice + + +def test_esc_closes_without_writing(): + from rollingmill.tui import TUI, _LogBuffer + link = _Recording() + tui = TUI(MDX40A(link), _LogBuffer()) + tui._handle_key(ord('b')) + _type(tui, '15') + link.sent.clear() + tui._handle_key(27) + assert not tui._probe_confirm and link.sent == [] From ba9ca9764cd2868aa611d062ae1214ce73c2577a Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sun, 8 Nov 2026 13:30:47 +0000 Subject: [PATCH 5/9] Z-probe: set the Z origin of EXOFS and G54-G59 as well as User VPanel's MDx3902_GiantDispatch encodes the target coordinate system as the first word of SET 0x3902: 1 = User (RML-1), 2 = EXOFS, 3-8 = G54-G59, which is the WCS slot index rollingmill already uses (wcs_code in SET 0x3501, WCS_SLOT_NAMES, get_wcs_origin). Only slot 1 has a live capture. - probing: probe the active slot (1-8), send the slot as the first word and refresh that slot's origin afterwards; MCS and slots 9+ are rejected - tui: the dialog shows the target, refuses Enter for an unsupported target and warns when the command set is RML-1 (VPanel only offers G54-G59 and EXOFS in NC Code or automatic mode) - docs: record the encoder evidence in z-probing.md Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- docs/usb-protocol.md | 1 + docs/z-probing.md | 46 ++++++++++++++++++++++++++------------ rollingmill/probing.py | 36 ++++++++++++++++++++--------- rollingmill/tui.py | 33 ++++++++++++++++++++------- tests/test_probing.py | 43 ++++++++++++++++++++++++++++++++--- tests/test_z0_thickness.py | 25 ++++++++++++++++++++- 7 files changed, 148 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index f1721da..b7ab28c 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ The interactive TUI exposes the following keybindings: | c | coordinate systems (activate / move-to / overwrite) | | m | Move-To picker (presets + User Specify numeric entry) | | t | tool diameter offsets | -| b | Z0 sensor probe dialog (User/RML-1; H sets the sensor thickness, Enter starts, Esc stops) | +| b | Z0 sensor probe dialog (sets the Z origin of User, EXOFS or G54–G59 using probe) | | q | quit | **Cut panel** (only when `--file` is given) diff --git a/docs/usb-protocol.md b/docs/usb-protocol.md index 96dd6e6..077004f 100644 --- a/docs/usb-protocol.md +++ b/docs/usb-protocol.md @@ -146,6 +146,7 @@ Payload layouts and dispatch detail live in [move-commands.md](move-commands.md) | wValue | Payload | Notes | |--------|---------|-------| | `0x3901` | `>I` — thickness uint32, 1/1000 mm | Write Z0 sensor thickness (Setup dialog OK; VPanel limits it to 14000–16000); poll ping bit 21 clear after. | +| `0x3902` | `>HHH` — slot, -1, -1 | Probe for Z0 sensor, then update Z-axis origin in coordinate system slot. | See [z-probing.md](z-probing.md) for the `SET 0x3902` operation that uses the z-origin sensor. diff --git a/docs/z-probing.md b/docs/z-probing.md index 1c0068b..dabda04 100644 --- a/docs/z-probing.md +++ b/docs/z-probing.md @@ -1,29 +1,31 @@ # Z0 sensor probing -Select **User (RML-1)** with `c`, close the coordinate dialog, then press **b**. +Select the coordinate system whose Z origin you want to set with `c` (**User (RML-1)**, +**EXOFS** or **G54–G59**), activate it, then close the coordinate dialog. +Then press **b** to open the Z-origin dialog. Place the clean connected sensor on the workpiece, position the tool directly above it, turn off the spindle, and close the cover. The **Z0 Sensor Probe** -dialog shows the sensor thickness stored in the machine: **Enter** starts the +dialog shows the target coordinate system and the sensor thickness stored in the machine: **Enter** starts the descent/retract cycle and **Esc** dismisses the dialog, or requests an immediate stop during motion. The TUI keeps polling and displaying Z/feed. Other machine commands are blocked until a fresh idle reply arrives. -The firmware updates the User Z origin; the application then reads slot 1 again +The firmware updates the target's Z origin; the application then reads that slot again and refreshes its coordinate display. A cancellation, device error, lost status, unobserved motion or timeout is reported as an unverified result, never success. Quit during an active cycle requests stop; quit again after idle to exit. -Only mode 1 (User/RML-1) has live-capture evidence. Other WCS selections are -rejected. This feature uses the firmware's existing sensor thickness/settings; -it does not write a guessed thickness. The cycle uses the machine's stored sensor +You cannot start the Z-origin probing for machine coordinates (MCS - slot 0) since that +offset is fixed at zero. VPanel only offers EXOFS and G54–G59 when the command set is NC Code or +automatic, so the dialog warns when the command set is RML-1; whether the firmware +accepts those modes then is not known. The cycle uses the machine's stored sensor thickness (GET 0x3900 / SET 0x3901, the Setup dialog's "Thickness of Z0 Sensor", -1/1000 mm), which is read at connect, shown in the dialog and in the TUI state pane to -the right of the spindle runtime. Measure your sensor and check it matches: a wrong -value shifts the Z origin the cycle sets by the same amount. To change it, type the -new value in the dialog (14.000–16.000 mm, as in VPanel's Setup dialog) and press +1/1000 mm), which is read at connect, shown in the dialog and in the TUI state pane. + +Measure your sensor and check it matches: a wrong value shifts the Z origin the cycle +sets by the same amount. To change it, type the new value in the dialog +(14.000–16.000 mm, as in VPanel's Setup dialog) and press **H**; the value is written, read back and verified, and **Enter** is refused while an -edited value has not been set. There is no NC streaming during probing; -launch without `--file`. The fresh state must be idle, with cover closed and no -error or spindle-on flag. +edited value has not been set. ## Setting probe thickness @@ -40,6 +42,22 @@ To set 15.030 mm: `SET 0x1109 00`, `SET 0x3901 00 00 3A B6`, wait for the ack, ` back to check it (`00 00 3A B6`). VPanel limits the value to 14.000–16.000 mm; the firmware's own limits are not known. Writing a changed value and reading it back was confirmed on a real MDX-40A. +## Target coordinate system + +The first word of `SET 0x3902` is the WCS slot index (the same numbering as +`WCS_SLOT_NAMES` and the `wcs_code` of `SET 0x3501`); the other two words are `FFFF`. +From VPanel's `MDx3902_GiantDispatch` (`0x403b10`, operation 6, called from +`set_origin_using_sensor_button_gd6`), whose `cs` is the "Set Origin Point" dropdown's +item ID (`dialog_poulate_coordinatesys_dropdown`, `0x413e80`): + +| Slot (`cs`) | Coordinate system | First word | VPanel encoder | +|---|---|---|---| +| 0 | MCS | not allowed | — | +| 1 | User (RML-1) | `0001` | `0x41b270` | +| 2 | EXOFS | `0002` | `0x41b2b0` | +| 3–8 | G54–G59 | `0003`–`0008` | `0x41b2f0(cs + 0x33)`: `(G − 54) + 3` | +| 10–310 | G54.1 P1–P300 | `cs − 1` | `0x41b340(cs − 9)`: `(n − 1) + 9`; not implemented here | + ## Protocol evidence A passive VPanel/USB-driver capture from a physical MDX-40A showed: @@ -48,7 +66,7 @@ A passive VPanel/USB-driver capture from a physical MDX-40A showed: 2. Stop spindle: SET 0x3006 uint32 big-endian zero. 3. Twice: Pattern B query 0x2001, two-byte reply 00 14. Bit 2 of byte 1 satisfies the connected check; bit 3 is the contact condition. -4. SET **0x3902**, payload **00 01 FF FF FF FF** (`>HHH`, mode 1, -1, -1). +4. SET **0x3902**, payload **00 01 FF FF FF FF** (`>HHH`, slot, -1, -1). 5. Direct GET 0x0100 32-byte status polls during descent/retraction. During this phase the TUI does not send its normal idle heartbeat/secondary reads. 6. Motion flags clear, then re-read the User origin with Pattern B 0x030B. diff --git a/rollingmill/probing.py b/rollingmill/probing.py index 1d6b18f..9cbfccb 100644 --- a/rollingmill/probing.py +++ b/rollingmill/probing.py @@ -1,7 +1,9 @@ """Cooperative Z0 sensor cycle captured on a real MDX-40A. -Only the User/RML-1 origin (mode 1) is supported. No sensor thickness or -uncaptured coordinate-mode settings are written. +The cycle sets the Z origin of the active coordinate system: User/RML-1 (slot 1, +the captured cycle), EXOFS (slot 2) or G54-G59 (slots 3-8). The first word of +SET 0x3902 is the slot index, as VPanel's MDx3902_GiantDispatch encodes it (see +docs/z-probing.md). No sensor thickness or uncaptured settings are written. """ import logging import struct @@ -9,11 +11,16 @@ import usb.core -from .machine import FLAG_CMD_MOVE, FLAG_DOOR, FLAG_ERROR, FLAG_MOVING, FLAG_SPINDLE, _decode_state +from .machine import (FLAG_CMD_MOVE, FLAG_DOOR, FLAG_ERROR, FLAG_MOVING, FLAG_SPINDLE, + _decode_state, wcs_label) log = logging.getLogger(__name__) MOTION = FLAG_CMD_MOVE | FLAG_MOVING | 0x00002000 +# WCS slots the probe can target: 1 = User (RML-1), 2 = EXOFS, 3-8 = G54-G59. Only slot 1 +# has a live capture. VPanel only offers slots 2-8 when the command set is NC Code or automatic. +PROBE_SLOTS = range(1, 9) + class ZProbe: """Call update with each *fresh* poll result; UI remains responsive.""" @@ -23,6 +30,7 @@ def __init__(self, machine, clock=time.monotonic): self.clock = clock self.active = False self.message = "" + self.slot = 0 # WCS slot being probed, set by start() self._bracket = False self._seen_motion = False self._cancelled = False @@ -32,8 +40,12 @@ def __init__(self, machine, clock=time.monotonic): def start(self): if self.active: raise ValueError("A Z-probe cycle is already active") - if self.machine.active_wcs != 1: - raise ValueError("Select User (RML-1) in the coordinate dialog first") + slot = self.machine.active_wcs + if slot not in PROBE_SLOTS: + raise ValueError( + "Select User (RML-1), EXOFS or G54-G59 in the coordinate dialog first " + f"(active: {wcs_label(slot)})" + ) state = self.machine.poll() if state is None or not state.idle or self.machine.is_busy: raise ValueError("Fresh idle machine status is required") @@ -56,11 +68,12 @@ def start(self): raise ValueError("Z0 sensor is not connected") if sensor[1] & 0x08: raise ValueError("Z0 sensor is already in contact with the tool") + self.slot = slot self._started = self._last_fresh = self.clock() self._seen_motion = self._cancelled = False self.active = True - self.message = "Z-probe starting; Esc stops motion" - link.vend_set(0x3902, struct.pack(">HHH", 1, 0xFFFF, 0xFFFF)) + self.message = f"Z-probe of {wcs_label(slot)} starting; Esc stops motion" + link.vend_set(0x3902, struct.pack(">HHH", slot, 0xFFFF, 0xFFFF)) except (ValueError, usb.core.USBError): if self.active: # Motion write outcome is unknown. Keep UI locked until fresh idle. @@ -132,11 +145,12 @@ def update(self, state): if self._cancelled: self.message = "Z-probe stopped; origin result unverified" else: - origin = self.machine.get_wcs_origin(1) + origin = self.machine.get_wcs_origin(self.slot) if origin is None: - raise ValueError("Cycle ended but User origin could not be refreshed") - self.machine._wcs_offset = origin - self.message = f"Z-probe cycle ended; User Z origin {origin[2]:.3f} mm" + raise ValueError(f"Cycle ended but {wcs_label(self.slot)} origin could not be refreshed") + if self.machine.active_wcs == self.slot: + self.machine._wcs_offset = origin + self.message = f"Z-probe cycle ended; {wcs_label(self.slot)} Z origin {origin[2]:.3f} mm" log.info(self.message) except (ValueError, usb.core.USBError) as exc: self.message = f"Z-probe result unverified: {exc}" diff --git a/rollingmill/tui.py b/rollingmill/tui.py index f59141c..993c026 100644 --- a/rollingmill/tui.py +++ b/rollingmill/tui.py @@ -50,7 +50,7 @@ from .check import lint_file from .trace import Tracer from .cutjob import CutJob -from .probing import ZProbe +from .probing import PROBE_SLOTS, ZProbe # ── Jog parameters ──────────────────────────────────────────────────────────── @@ -145,6 +145,7 @@ def __init__(self, machine: _machine.MDX40A, log_buf: _LogBuffer, self._probe_buf = '' # Z0 thickness edit buffer (mm) self._probe_dirty = False # buffer edited but not yet sent with H self._probe_notice = '' # last result shown in the dialog + self._probe_cmdset = None # command set read when the dialog opened (0 RML-1, 1 NC, 2 auto) # ── Entry point ─────────────────────────────────────────────────────────── @@ -335,12 +336,16 @@ def _probe_open_dialog(self) -> None: self._probe_buf = f'{thickness:.3f}' if thickness is not None else '' self._probe_dirty = False self._probe_notice = '' + self._probe_cmdset = self._m.query_command_set() self._probe_confirm = True def _probe_dialog_key(self, key: int) -> None: if key in (27, ord('q'), ord('Q')): self._probe_confirm = False elif key in (10, 13): # Enter — start the probe cycle + if self._m.active_wcs not in PROBE_SLOTS: + self._probe_notice = 'Select User, EXOFS or G54-G59 with c first' + return if self._probe_dirty: self._probe_notice = 'Thickness edited: press H to set it first' return @@ -541,7 +546,7 @@ def _draw_probe_dialog(self, stdscr: curses.window, rows: int, cols: int) -> Non CP = curses.color_pair BOLD = curses.A_BOLD - dh = 12 + dh = 13 dw = min(66, cols - 2) if rows < dh + 2 or dw < 40: self._put(stdscr, 0, 0, "Z-probe: enlarge terminal; Esc cancels") @@ -557,24 +562,36 @@ def _draw_probe_dialog(self, stdscr: curses.window, rows: int, cols: int) -> Non self._put(win, 0, 2, ' Z0 Sensor Probe', CP(_CP_HEADER) | BOLD) lines = [ - 'Set the User (RML-1) Z origin using the Z0 sensor.', + 'Set the Z origin of the target below using the Z0 sensor.', 'Clean and connect the sensor and place it on the workpiece.', 'Position the tool directly above it and close the cover.', ] for i, line in enumerate(lines): self._put(win, 1 + i, 2, line[:dw - 4], CP(_CP_LABEL)) + slot = self._m.active_wcs + target = _machine.wcs_label(slot) + if slot in PROBE_SLOTS: + self._put(win, 4, 2, 'Target:', CP(_CP_LABEL)) + self._put(win, 4, 10, target, CP(_CP_VALUE) | BOLD) + if slot >= 2 and self._probe_cmdset == _machine.MDX40A.CMDSET_RML1: + self._put(win, 5, 2, 'Command set is RML-1; VPanel only offers this in NC/auto mode'[:dw - 4], + CP(_CP_LOG_WARN)) + else: + self._put(win, 4, 2, f'Target: {target} - select User, EXOFS or G54-G59 with c'[:dw - 4], + CP(_CP_LOG_WARN) | BOLD) + stored = self._m.z0_sensor_thickness_mm stored_str = f'{stored:.3f} mm' if stored is not None else '?' field = (self._probe_buf + '_') if self._probe_dirty else (self._probe_buf or '?') attr = (CP(_CP_ACTIVE) | BOLD) if self._probe_dirty else (CP(_CP_VALUE) | BOLD) - self._put(win, 5, 2, 'Thickness of Z0 sensor:', CP(_CP_LABEL)) - self._put(win, 5, 26, f' {field:<9s}', attr) - self._put(win, 5, 37, 'mm', CP(_CP_LABEL)) - self._put(win, 6, 2, f'in machine: {stored_str} (limits {_machine.Z0_THICKNESS_MIN_MM:.0f}' + self._put(win, 6, 2, 'Thickness of Z0 sensor:', CP(_CP_LABEL)) + self._put(win, 6, 26, f' {field:<9s}', attr) + self._put(win, 6, 37, 'mm', CP(_CP_LABEL)) + self._put(win, 7, 2, f'in machine: {stored_str} (limits {_machine.Z0_THICKNESS_MIN_MM:.0f}' f'–{_machine.Z0_THICKNESS_MAX_MM:.0f} mm)'[:dw - 4], CP(_CP_DIM)) if self._probe_notice: - self._put(win, 8, 2, self._probe_notice[:dw - 4], CP(_CP_LOG_WARN) | BOLD) + self._put(win, 9, 2, self._probe_notice[:dw - 4], CP(_CP_LOG_WARN) | BOLD) keys = ' Enter start probe H set thickness 0-9 . edit Esc cancel' self._put(win, dh - 3, 1, '─' * (dw - 2), CP(_CP_LABEL)) diff --git a/tests/test_probing.py b/tests/test_probing.py index 2e8a99e..9e99c14 100644 --- a/tests/test_probing.py +++ b/tests/test_probing.py @@ -92,15 +92,52 @@ def test_reject_unsafe_state(flag): assert not any(c == 0x3902 for c, _ in link.sent) -def test_unsupported_wcs_no_writes(): +@pytest.mark.parametrize("slot", [0, 9, 10]) +def test_unsupported_wcs_no_writes(slot): + # MCS has no origin to set; slots 9-10 have no sensor-cycle code in VPanel. link, m, probe, _ = fixture() - m.set_active_wcs(3) + m.set_active_wcs(slot) link.sent.clear() - with pytest.raises(ValueError, match="RML-1"): + with pytest.raises(ValueError, match="Select User"): probe.start() assert link.sent == [] +@pytest.mark.parametrize("slot", range(1, 9)) +def test_wire_packet_first_word_is_the_wcs_slot(slot): + # VPanel's MDx3902_GiantDispatch: 1 = User, 2 = EXOFS, 3-8 = G54-G59 (cs + 0x33 → (G-54)+3). + link, m, probe, _ = fixture() + m.set_active_wcs(slot) + link.sent.clear() + probe.start() + assert (0x3902, struct.pack(">HHH", slot, 0xFFFF, 0xFFFF)) in link.sent + assert probe.slot == slot + + +def test_g54_cycle_refreshes_that_slot_not_user(): + link, m, probe, clock = fixture() + link._responses[0x3203] = struct.pack(">4i", 1000, 2000, -3000, 0) # G54 origin after the cycle + m.set_active_wcs(3) + link.sent.clear() + probe.start() + assert "WCS1 (G54)" in probe.message + probe.update(_decode_state(IDLE)) + samples = json.loads((Path(__file__).parent / "fixtures/mdx40a_zprobe_status.json").read_text()) + moving = False + for data in samples: + state = _decode_state(bytes(data)) + moving |= bool(state.flags & 0x04000000) + if moving: + clock[0] += 0.2 + probe.update(state) + if not probe.active: + break + assert not probe.active and "WCS1 (G54)" in probe.message and "-3.000" in probe.message + assert m.wcs_offset[2] == pytest.approx(-3.0) + assert (0x030b, b"") not in link.sent[-2:] # slot 1's origin is not what was refreshed + assert link.sent[-1] == (0x3203, b"") + + def test_no_motion_timeout_is_not_success(): _, _, probe, clock = fixture() probe.start() diff --git a/tests/test_z0_thickness.py b/tests/test_z0_thickness.py index 02a011c..018f3c2 100644 --- a/tests/test_z0_thickness.py +++ b/tests/test_z0_thickness.py @@ -113,7 +113,9 @@ def test_dialog_shows_stored_value_and_h_sets_it(): def test_enter_is_refused_until_edited_value_is_set(): from rollingmill.tui import TUI, _LogBuffer link = _Recording() - tui = TUI(MDX40A(link), _LogBuffer()) + m = MDX40A(link) + m.set_active_wcs(1) + tui = TUI(m, _LogBuffer()) tui._handle_key(ord('b')) _type(tui, '15.03') link.sent.clear() @@ -122,6 +124,27 @@ def test_enter_is_refused_until_edited_value_is_set(): assert "H" in tui._probe_notice and link.sent == [] +def test_enter_is_refused_for_a_workspace_without_a_sensor_cycle(): + from rollingmill.tui import TUI, _LogBuffer + link = _Recording() + tui = TUI(MDX40A(link), _LogBuffer()) # MCS is active + tui._handle_key(ord('b')) + link.sent.clear() + tui._handle_key(10) + assert tui._probe_confirm and not tui._probe.active + assert "Select User" in tui._probe_notice and link.sent == [] + + +@pytest.mark.parametrize("config,expected", [(b"\x00\x00", 0), (b"\x00\x01", 1), (b"\x00\x02", 2)]) +def test_dialog_reads_command_set_when_opened(config, expected): + from rollingmill.tui import TUI, _LogBuffer + link = _Recording() + link._responses[0x3106] = config + b"\x00" * 4 + tui = TUI(MDX40A(link), _LogBuffer()) + tui._handle_key(ord('b')) + assert tui._probe_cmdset == expected + + def test_out_of_range_h_keeps_dialog_and_writes_nothing(): from rollingmill.tui import TUI, _LogBuffer link = _Recording() From d810ad3107bf79d602c31d0d9bb58b078463a759 Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sun, 8 Nov 2026 13:34:15 +0000 Subject: [PATCH 6/9] tui: b in MCS (or any workspace without a sensor cycle) only logs a warning Co-Authored-By: Claude Sonnet 5.5 --- rollingmill/tui.py | 22 +++++++++------------- tests/test_z0_thickness.py | 31 ++++++++++++++++++++++--------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/rollingmill/tui.py b/rollingmill/tui.py index 993c026..03c5ecd 100644 --- a/rollingmill/tui.py +++ b/rollingmill/tui.py @@ -324,6 +324,10 @@ def _handle_key(self, key: int) -> None: elif key in (ord('b'), ord('B')): if self._cut_job or self._jog_armed or self._drill_active: logging.getLogger(__name__).warning("Close the NC job and stop jogging/drilling before Z-probing") + elif self._m.active_wcs not in PROBE_SLOTS: + logging.getLogger(__name__).warning( + "Z-probe: select User, EXOFS or G54-G59 coords with c first (active: %s)", + _machine.wcs_label(self._m.active_wcs)) else: self._probe_open_dialog() elif key in (ord('p'), ord('P')): @@ -343,9 +347,6 @@ def _probe_dialog_key(self, key: int) -> None: if key in (27, ord('q'), ord('Q')): self._probe_confirm = False elif key in (10, 13): # Enter — start the probe cycle - if self._m.active_wcs not in PROBE_SLOTS: - self._probe_notice = 'Select User, EXOFS or G54-G59 with c first' - return if self._probe_dirty: self._probe_notice = 'Thickness edited: press H to set it first' return @@ -570,16 +571,11 @@ def _draw_probe_dialog(self, stdscr: curses.window, rows: int, cols: int) -> Non self._put(win, 1 + i, 2, line[:dw - 4], CP(_CP_LABEL)) slot = self._m.active_wcs - target = _machine.wcs_label(slot) - if slot in PROBE_SLOTS: - self._put(win, 4, 2, 'Target:', CP(_CP_LABEL)) - self._put(win, 4, 10, target, CP(_CP_VALUE) | BOLD) - if slot >= 2 and self._probe_cmdset == _machine.MDX40A.CMDSET_RML1: - self._put(win, 5, 2, 'Command set is RML-1; VPanel only offers this in NC/auto mode'[:dw - 4], - CP(_CP_LOG_WARN)) - else: - self._put(win, 4, 2, f'Target: {target} - select User, EXOFS or G54-G59 with c'[:dw - 4], - CP(_CP_LOG_WARN) | BOLD) + self._put(win, 4, 2, 'Target:', CP(_CP_LABEL)) + self._put(win, 4, 10, _machine.wcs_label(slot), CP(_CP_VALUE) | BOLD) + if slot >= 2 and self._probe_cmdset == _machine.MDX40A.CMDSET_RML1: + self._put(win, 5, 2, 'Command set is RML-1; VPanel only offers this in NC/auto mode'[:dw - 4], + CP(_CP_LOG_WARN)) stored = self._m.z0_sensor_thickness_mm stored_str = f'{stored:.3f} mm' if stored is not None else '?' diff --git a/tests/test_z0_thickness.py b/tests/test_z0_thickness.py index 018f3c2..8729858 100644 --- a/tests/test_z0_thickness.py +++ b/tests/test_z0_thickness.py @@ -97,6 +97,7 @@ def test_dialog_shows_stored_value_and_h_sets_it(): from rollingmill.tui import TUI, _LogBuffer link = _Recording() m = MDX40A(link) + m.set_active_wcs(1) tui = TUI(m, _LogBuffer()) tui._handle_key(ord('b')) assert tui._probe_confirm and tui._probe_buf == '14.140' and not tui._probe_dirty @@ -124,15 +125,21 @@ def test_enter_is_refused_until_edited_value_is_set(): assert "H" in tui._probe_notice and link.sent == [] -def test_enter_is_refused_for_a_workspace_without_a_sensor_cycle(): +@pytest.mark.parametrize("slot", [0, 9, 10]) +def test_b_in_an_unprobeable_workspace_logs_a_warning_instead_of_opening(slot, caplog): + import logging + from rollingmill.tui import TUI, _LogBuffer link = _Recording() - tui = TUI(MDX40A(link), _LogBuffer()) # MCS is active - tui._handle_key(ord('b')) + m = MDX40A(link) + m.set_active_wcs(slot) + tui = TUI(m, _LogBuffer()) link.sent.clear() - tui._handle_key(10) - assert tui._probe_confirm and not tui._probe.active - assert "Select User" in tui._probe_notice and link.sent == [] + with caplog.at_level(logging.WARNING): + tui._handle_key(ord('b')) + assert not tui._probe_confirm and not tui._probe.active + assert any("select User, EXOFS or G54-G59" in r.getMessage() for r in caplog.records) + assert link.sent == [] @pytest.mark.parametrize("config,expected", [(b"\x00\x00", 0), (b"\x00\x01", 1), (b"\x00\x02", 2)]) @@ -140,7 +147,9 @@ def test_dialog_reads_command_set_when_opened(config, expected): from rollingmill.tui import TUI, _LogBuffer link = _Recording() link._responses[0x3106] = config + b"\x00" * 4 - tui = TUI(MDX40A(link), _LogBuffer()) + m = MDX40A(link) + m.set_active_wcs(1) + tui = TUI(m, _LogBuffer()) tui._handle_key(ord('b')) assert tui._probe_cmdset == expected @@ -148,7 +157,9 @@ def test_dialog_reads_command_set_when_opened(config, expected): def test_out_of_range_h_keeps_dialog_and_writes_nothing(): from rollingmill.tui import TUI, _LogBuffer link = _Recording() - tui = TUI(MDX40A(link), _LogBuffer()) + m = MDX40A(link) + m.set_active_wcs(1) + tui = TUI(m, _LogBuffer()) tui._handle_key(ord('b')) _type(tui, '20') link.sent.clear() @@ -159,7 +170,9 @@ def test_out_of_range_h_keeps_dialog_and_writes_nothing(): def test_esc_closes_without_writing(): from rollingmill.tui import TUI, _LogBuffer link = _Recording() - tui = TUI(MDX40A(link), _LogBuffer()) + m = MDX40A(link) + m.set_active_wcs(1) + tui = TUI(m, _LogBuffer()) tui._handle_key(ord('b')) _type(tui, '15') link.sent.clear() From 1d5dd90f26246c0b0630a2714422c7128556ec29 Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sun, 8 Nov 2026 17:21:44 +0000 Subject: [PATCH 7/9] tui: log probe warnings as rollingmill.tui, not __main__ Under `python -m rollingmill.tui`, getLogger(__name__) is "__main__", so the probe warnings showed with the wrong context and missed the rollingmill logger's level setting. Co-Authored-By: Claude Sonnet 5.5 --- rollingmill/tui.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/rollingmill/tui.py b/rollingmill/tui.py index 03c5ecd..86af617 100644 --- a/rollingmill/tui.py +++ b/rollingmill/tui.py @@ -52,6 +52,8 @@ from .cutjob import CutJob from .probing import PROBE_SLOTS, ZProbe +log = logging.getLogger('rollingmill.tui') + # ── Jog parameters ──────────────────────────────────────────────────────────── STEPS_LINEAR = [0.01, 0.1, 1.0, 10.0, 50.0] # XYZ jog distances (mm) @@ -323,9 +325,9 @@ def _handle_key(self, key: int) -> None: self._tool_open_dialog() elif key in (ord('b'), ord('B')): if self._cut_job or self._jog_armed or self._drill_active: - logging.getLogger(__name__).warning("Close the NC job and stop jogging/drilling before Z-probing") + log.warning("Close the NC job and stop jogging/drilling before Z-probing") elif self._m.active_wcs not in PROBE_SLOTS: - logging.getLogger(__name__).warning( + log.warning( "Z-probe: select User, EXOFS or G54-G59 coords with c first (active: %s)", _machine.wcs_label(self._m.active_wcs)) else: @@ -355,7 +357,7 @@ def _probe_dialog_key(self, key: int) -> None: self._probe.start() self.annotate("KEY b Z0 probe confirmed") except (ValueError, usb.core.USBError) as exc: - logging.getLogger(__name__).warning("Z-probe: %s", exc) + log.warning("Z-probe: %s", exc) elif key in (ord('h'), ord('H')): # H — write the thickness self._probe_set_thickness() elif key in (127, curses.KEY_BACKSPACE, 8): @@ -432,7 +434,7 @@ def _start_jog(self, axis: str, sign: int) -> None: try: self._m.send_jog(axis, dist, speed=speed) except Exception as exc: - logging.getLogger('tui').error("Jog %s %+.3f mm failed: %s", axis, dist, exc) + log.error("Jog %s %+.3f mm failed: %s", axis, dist, exc) return self._moving = axis self._jog_armed = True @@ -1026,7 +1028,7 @@ def _move_handle_key(self, key: int) -> None: try: action() except Exception as exc: - logging.getLogger('tui').error("Move target %r failed: %s", label, exc) + log.error("Move target %r failed: %s", label, exc) def _draw_move_dialog(self, stdscr: curses.window, rows: int, cols: int) -> None: CP = curses.color_pair From 87f07e62ff63679af7e9417c00d7abf21093342b Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sun, 8 Nov 2026 17:21:44 +0000 Subject: [PATCH 8/9] docs: describe Z0 sensor probing and simplify the probe and USB docs Co-Authored-By: Claude Sonnet 5.5 --- docs/z-probing.md | 48 ++++++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/docs/z-probing.md b/docs/z-probing.md index dabda04..083e359 100644 --- a/docs/z-probing.md +++ b/docs/z-probing.md @@ -1,5 +1,15 @@ # Z0 sensor probing +Locate the material/tool contact surface on the Z-axis. + +A conductive disk is positioned on the workpiece below the tool, which is wired back to the machine. The operation slowly lowers the Z-axis until the cutting bit touches the pad completing a circuit, which stops motion and causes a retreat. The measured Z-height is then adjusted down by the stored height of the conductive pad (14-16mm). The calculated surface height is stored to the device as the selected coordinate system's Z axis origin. + +The conductive disk is connected to the rear "Z-origin" sensor port, which is a 3.5mm Jack socket at the rear of the machine. The 3.5mm jack 'tip' *alone* must be connected to the pad, the supplied cable uses a banana plug to make contact with the disk. + +The height of the disk can be precisely measured (with calipers) and saved into the firmware - VPanel Setup dialog limits the thickness to 14.000–16.000 mm; the firmware's own limits are not known. + +## User notes + Select the coordinate system whose Z origin you want to set with `c` (**User (RML-1)**, **EXOFS** or **G54–G59**), activate it, then close the coordinate dialog. Then press **b** to open the Z-origin dialog. @@ -30,8 +40,6 @@ edited value has not been set. ## Setting probe thickness The cycle subtracts the thickness of the conductive pad, which is stored in the machine: -`SET 0x3902` carries no thickness. The two commands below are the Setup dialog's -"Thickness of Z0 Sensor" box (see [usb-protocol.md](usb-protocol.md)). | wValue | Payload | Notes | |---|---|---| @@ -42,25 +50,7 @@ To set 15.030 mm: `SET 0x1109 00`, `SET 0x3901 00 00 3A B6`, wait for the ack, ` back to check it (`00 00 3A B6`). VPanel limits the value to 14.000–16.000 mm; the firmware's own limits are not known. Writing a changed value and reading it back was confirmed on a real MDX-40A. -## Target coordinate system - -The first word of `SET 0x3902` is the WCS slot index (the same numbering as -`WCS_SLOT_NAMES` and the `wcs_code` of `SET 0x3501`); the other two words are `FFFF`. -From VPanel's `MDx3902_GiantDispatch` (`0x403b10`, operation 6, called from -`set_origin_using_sensor_button_gd6`), whose `cs` is the "Set Origin Point" dropdown's -item ID (`dialog_poulate_coordinatesys_dropdown`, `0x413e80`): - -| Slot (`cs`) | Coordinate system | First word | VPanel encoder | -|---|---|---|---| -| 0 | MCS | not allowed | — | -| 1 | User (RML-1) | `0001` | `0x41b270` | -| 2 | EXOFS | `0002` | `0x41b2b0` | -| 3–8 | G54–G59 | `0003`–`0008` | `0x41b2f0(cs + 0x33)`: `(G − 54) + 3` | -| 10–310 | G54.1 P1–P300 | `cs − 1` | `0x41b340(cs − 9)`: `(n − 1) + 9`; not implemented here | - -## Protocol evidence - -A passive VPanel/USB-driver capture from a physical MDX-40A showed: +## Z-probe cycle 1. SET 0x03F5 payload 02, operation begin SET 0x1109 payload 00. 2. Stop spindle: SET 0x3006 uint32 big-endian zero. @@ -77,3 +67,19 @@ The captured cycle descended at 60 mm/min, retracted at 1800 mm/min and ended in about 16.9 seconds. Those speeds are firmware-controlled, not hard-coded motion commands. A lowest sampled Z of -79.880 mm preceded reversal; exact contact instant was between samples. No text success response is sent. + +### Target coordinate system (slot) + +The first word of `SET 0x3902` is the WCS slot index (the same numbering as +`WCS_SLOT_NAMES` and the `wcs_code` of `SET 0x3501`). +From VPanel's `MDx3902_GiantDispatch` (`0x403b10`, operation 6, called from +`set_origin_using_sensor_button_gd6`), whose `cs` is the "Set Origin Point" dropdown's +item ID (`dialog_poulate_coordinatesys_dropdown`, `0x413e80`): + +| Slot (`cs`) | Coordinate system | First word | VPanel encoder | +|---|---|---|---| +| 0 | MCS | not allowed | — | +| 1 | User (RML-1) | `0001` | `0x41b270` | +| 2 | EXOFS | `0002` | `0x41b2b0` | +| 3–8 | G54–G59 | `0003`–`0008` | `0x41b2f0(cs + 0x33)`: `(G − 54) + 3` | +| 10–310 | G54.1 P1–P300 | `cs − 1` | `0x41b340(cs − 9)`: `(n − 1) + 9`; not implemented here | From d357e5183b8effa883f66240747ea9b4272cd92c Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Mon, 9 Nov 2026 09:56:27 +0000 Subject: [PATCH 9/9] tui: make the Z0 probe dialog match the coordinates box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key summary uses the ↵ symbol and "Esc close" like the coordinates box, the three descriptive lines are grey instead of cyan, and there is a blank row before "Target". Co-Authored-By: Claude Sonnet 5.5 --- rollingmill/tui.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/rollingmill/tui.py b/rollingmill/tui.py index 86af617..797e496 100644 --- a/rollingmill/tui.py +++ b/rollingmill/tui.py @@ -549,7 +549,7 @@ def _draw_probe_dialog(self, stdscr: curses.window, rows: int, cols: int) -> Non CP = curses.color_pair BOLD = curses.A_BOLD - dh = 13 + dh = 14 dw = min(66, cols - 2) if rows < dh + 2 or dw < 40: self._put(stdscr, 0, 0, "Z-probe: enlarge terminal; Esc cancels") @@ -570,28 +570,28 @@ def _draw_probe_dialog(self, stdscr: curses.window, rows: int, cols: int) -> Non 'Position the tool directly above it and close the cover.', ] for i, line in enumerate(lines): - self._put(win, 1 + i, 2, line[:dw - 4], CP(_CP_LABEL)) + self._put(win, 1 + i, 2, line[:dw - 4], CP(_CP_DIM)) slot = self._m.active_wcs - self._put(win, 4, 2, 'Target:', CP(_CP_LABEL)) - self._put(win, 4, 10, _machine.wcs_label(slot), CP(_CP_VALUE) | BOLD) + self._put(win, 5, 2, 'Target:', CP(_CP_LABEL)) + self._put(win, 5, 10, _machine.wcs_label(slot), CP(_CP_VALUE) | BOLD) if slot >= 2 and self._probe_cmdset == _machine.MDX40A.CMDSET_RML1: - self._put(win, 5, 2, 'Command set is RML-1; VPanel only offers this in NC/auto mode'[:dw - 4], + self._put(win, 6, 2, 'Command set is RML-1; VPanel only offers this in NC/auto mode'[:dw - 4], CP(_CP_LOG_WARN)) stored = self._m.z0_sensor_thickness_mm stored_str = f'{stored:.3f} mm' if stored is not None else '?' field = (self._probe_buf + '_') if self._probe_dirty else (self._probe_buf or '?') attr = (CP(_CP_ACTIVE) | BOLD) if self._probe_dirty else (CP(_CP_VALUE) | BOLD) - self._put(win, 6, 2, 'Thickness of Z0 sensor:', CP(_CP_LABEL)) - self._put(win, 6, 26, f' {field:<9s}', attr) - self._put(win, 6, 37, 'mm', CP(_CP_LABEL)) - self._put(win, 7, 2, f'in machine: {stored_str} (limits {_machine.Z0_THICKNESS_MIN_MM:.0f}' + self._put(win, 7, 2, 'Thickness of Z0 sensor:', CP(_CP_LABEL)) + self._put(win, 7, 26, f' {field:<9s}', attr) + self._put(win, 7, 37, 'mm', CP(_CP_LABEL)) + self._put(win, 8, 2, f'in machine: {stored_str} (limits {_machine.Z0_THICKNESS_MIN_MM:.0f}' f'–{_machine.Z0_THICKNESS_MAX_MM:.0f} mm)'[:dw - 4], CP(_CP_DIM)) if self._probe_notice: - self._put(win, 9, 2, self._probe_notice[:dw - 4], CP(_CP_LOG_WARN) | BOLD) + self._put(win, 10, 2, self._probe_notice[:dw - 4], CP(_CP_LOG_WARN) | BOLD) - keys = ' Enter start probe H set thickness 0-9 . edit Esc cancel' + keys = ' ↵ start probe H set thickness 0-9 . edit Esc close' self._put(win, dh - 3, 1, '─' * (dw - 2), CP(_CP_LABEL)) self._put(win, dh - 2, 1, keys[:dw - 2], CP(_CP_KEYS))