diff --git a/README.md b/README.md
index 98486d2..e381ee8 100644
--- a/README.md
+++ b/README.md
@@ -47,11 +47,10 @@ The interactive TUI exposes the following keybindings:
|-----|--------|
| ← / → | X axis −/+ |
| ↑ / ↓ | Y axis −/+ |
-| a / z | Z axis +/− (`a` raises, `z` lowers) |
+| a / z | Z axis +/− (`a(up)` raises, `z(down)` lowers) |
| [ / ] | A axis −/+ |
| Esc | cancel an in-flight jog |
-| f | cycle jog speed: vslow / slow / medium / fast |
-| 1…5 | step size: XYZ 0.01 / 0.1 / 1.0 / 10.0 / 50.0 mm — A 0.01 / 0.1 / 1.0 / 10.0 / 90.0° |
+| f | cycle pulse/feed: 0.01 mm / 6 mm/min, 0.1 / 60, 1 / 600, 5 / 3000 |
**Spindle**
@@ -66,9 +65,11 @@ The interactive TUI exposes the following keybindings:
| Key | Action |
|-----|--------|
+| j | hold / fixed-step jog; standard terminals stop 200 ms after repeats cease |
| 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)
@@ -87,7 +88,7 @@ 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.
+* Z0 sensor probing is available with `b` for User (RML-1), based on a captured real MDX-40A cycle. Sensor thickness editing and probing other coordinate systems remain unsupported. See [Z-probing](docs/z-probing.md).
* 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/jog-speeds.md b/docs/jog-speeds.md
index 28b578a..928b02e 100644
--- a/docs/jog-speeds.md
+++ b/docs/jog-speeds.md
@@ -26,3 +26,51 @@ Low Speed is its own target, so it runs at constant 240/120 mm/min with no ramp.
High Speed ramps: XYZ 240 → 1800 mm/min, A 120 → 3000 mm/min over 3 seconds (30 ticks × 100 ms).
**A axis:** step modes are identical to XYZ (same deltas, same 0xFFFF speed). In continuous mode the ramp-start speed is halved (120 vs 240 mm/min) and the High Speed target delta is larger (8.000° vs 5.000 mm per tick). There is no VPanel-level gating on A-axis jog commands; the firmware simply ignores `nA` when no rotary table is installed.
+
+## Rollingmill hold-to-jog
+
+Hold-to-jog is the default. A tap in an ordinary Linux terminal sends a single
+selected pulse at its paired constant feed. Holding the direction
+key starts continuous jogging after keyboard auto-repeat begins. Press `j`
+to switch to fixed-step mode, which uses the same selected pulse/feed pair.
+
+Ordinary terminals use a **200 ms repeat timeout**. The initial keyboard-repeat
+delay is accommodated separately (up to one second between the first press
+and first repeat). Subsequent repeats renew the timeout without restarting
+motion. Once repeats stop, motion stop is requested. A repeat rate
+slower than five presses per second can interrupt motion; use the normal
+keyboard-repeat setting. Two quick taps are indistinguishable from a hold
+and can briefly start continuous motion, bounded by the same timeout.
+
+Actual key-release events are used automatically when a terminal supports
+the Kitty keyboard protocol; this is optional. No Windows APIs, privileged
+Linux input access or special terminal installation is required for the
+repeat fallback. Esc always requests stop. Remaining repeat events after
+Esc cannot restart motion until there has been a 200 ms quiet interval.
+Queued input events carry receipt timestamps, so old buffered repeats cannot
+renew a motion timeout when subsequently handled.
+
+Jog commands refresh every 100 ms without waiting for motion to end. The application applies no speed ramp. Door/error
+flags, stale or failed status reads also request stop. The cooperative loop
+checks input every 25 ms between USB operations. Release detection therefore
+takes up to 200 ms plus loop/USB latency in an ordinary terminal. Physical
+smoothness has not yet been validated on the mill.
+
+## Current pulse selector
+
+`f` cycles a single pulse/feed selection, defaulting to 5 mm / 3000 mm/min:
+
+| Pulse (mm) | Feed (mm/min) |
+|------------|---------------|
+| 0.01 | 6 |
+| 0.1 | 60 |
+| 1 | 600 |
+| 5 | 3000 |
+
+Each pair represents 100 ms of travel. A single press sends the whole selected
+pulse; a hold repeatedly sends that same pulse at that same feed, with no
+application acceleration ramp. The previous independent speed and numeric
+step selectors were removed. Rotary A uses the same numeric pairs in degrees
+and degrees/minute. Firmware motor acceleration remains firmware-controlled.
+The reverse-engineered VPanel table above is historical protocol evidence;
+these application presets are the requested proportional pulse/feed mapping.
diff --git a/docs/move-commands.md b/docs/move-commands.md
index 821154f..df7fe1f 100644
--- a/docs/move-commands.md
+++ b/docs/move-commands.md
@@ -86,3 +86,51 @@ the main panel.
| `0x1109` | Operation bracket — payload `0x00` = begin, `0xFF` = end; must wrap all jog, move-to, and NC job sequences. |
| `0x3808` | Move Y to centre of rotary A-axis. Payload `>HH` — mode (`2`), speed (`0xFFFF` = firmware max). Rotary-only entry of the Move dropdown. |
| `0x0500` | Move to View Position. Payload `>H` — speed (`0xFFFF` = firmware max). Parks the machine in the front-of-bed view pose for workpiece load/unload. |
+
+## Rollingmill selected-coordinate origin moves
+
+The TUI's origin presets now resolve the selected slot's stored origin, read
+the current position, and send `0x04f7` in machine coordinates. Unselected axes
+retain their current position. This matches the selected coordinate display
+and avoids `0x3501`'s command-set-mode restrictions. MCS uses zero origin; a
+failed origin/position read cancels the move. View Position and rotation-center
+presets remain machine-defined locations. Numeric entry already adds the
+selected display offset to the entered coordinates.
+
+## Dialog and coordinate conversion update
+
+The Move-To preview/view preset is explicitly labeled MCS. Origin presets and
+User Specified numeric targets share `move_to_position`, which reads the
+selected coordinate origin and adds it to entered values. Blank numeric
+fields preserve the current machine-axis position. If a coordinate origin
+cannot be read, selection/movement fails visibly instead of substituting zero.
+The machine-defined rotation-center shortcut was removed from this picker;
+its machine API remains available separately.
+
+Cooperative overlays now use `noutrefresh` and one final `doupdate` per frame,
+so the background and dialog are presented together. Blocking numeric entry
+retains its independent refresh. PTY checks found no repeated dialog repaint
+output for Move-To, Coordinate Systems and Tool Diameter Offsets while idle.
+
+## Absolute move lifecycle correction
+
+The live session trace `20261004-211007.558.txt` recorded origin targets being
+sent successfully, immediately followed by `0x03f2` and `0x1109=FF`; sampled
+positions then remained unchanged. Static inspection of VPanel confirms that
+it begins interactive operations with `0x03f5=02` and `0x1109=00`, waits for
+motion completion after `0x04f7`, then unwinds with `0x03f2`, `0x03f5=FF` and
+`0x1109=FF`. Its absolute-move callers use numeric speeds 120/3000.
+
+Rollingmill now keeps an absolute move pending across poll ticks, uses direct
+status reads instead of idle heartbeat writes, and defers cleanup until the
+move ends. Completion requires the sampled target position (within 0.002 mm
+per axis); ended motion at a different position is unverified. Unacknowledged
+commands, cancellation and missing status produce a visible failure/status
+message. The preset 0xFFFF speed sentinel maps to 3000 mm/min for absolute
+moves. Competing TUI operations are blocked while the move is pending; Esc
+requests stop and polling continues until idle.
+
+This correction has mocked lifecycle and packet tests; it has not yet been
+confirmed by a new physical movement. The trace and static code establish the
+protocol mismatch, but hardware validation is still needed to confirm the
+cause of the observed no-motion symptom.
diff --git a/docs/z-probing.md b/docs/z-probing.md
new file mode 100644
index 0000000..4335d34
--- /dev/null
+++ b/docs/z-probing.md
@@ -0,0 +1,46 @@
+# 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.
+
+`MdxUSB.vend_set` already prepends the vendor header. Its initiation data stage
+is **01 39 02 00 00 01 FF FF FF FF**; do not add a second header in the feature.
+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.
+
+Tests replay the real status samples in `tests/fixtures/mdx40a_zprobe_status.json`,
+verify the exact USB initiation bytes, and exercise sensor, state, timeout,
+disconnect, cancellation and TUI interlocks. The new application feature has
+not itself been motion-tested on hardware yet.
diff --git a/pyproject.toml b/pyproject.toml
index 290cfdf..f891deb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,7 +34,7 @@ dependencies = [
"pyusb>=1.2",
"pyhershey>=0.1",
"erbsland-maze>=1.4.8",
- "setuptools", # pyhershey imports pkg_resources; drop when upstream moves to importlib.metadata
+ "setuptools<81", # pyhershey imports pkg_resources; drop when upstream moves to importlib.metadata
]
[project.optional-dependencies]
diff --git a/rollingmill/decode.py b/rollingmill/decode.py
index 449083f..9409ca1 100644
--- a/rollingmill/decode.py
+++ b/rollingmill/decode.py
@@ -56,6 +56,8 @@
# Add entries here as new commands are discovered and RE'd.
KNOWN_WVALUES: dict = {
# ── Handshake / ping ─────────────────────────────────────────────────────
+ 0x2001: 'z0_sensor_status',
+ 0x3902: 'z0_probe',
0x0001: 'ping',
0x0002: 'machine_type',
0x0003: 'trigger_response',
@@ -114,7 +116,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/keyrelease.py b/rollingmill/keyrelease.py
new file mode 100644
index 0000000..5256811
--- /dev/null
+++ b/rollingmill/keyrelease.py
@@ -0,0 +1,153 @@
+"""Portable terminal key events using the Kitty keyboard protocol."""
+import curses
+import re
+import sys
+import time
+from collections import deque
+
+
+class HeldKeys:
+ # Kitty functional-key codepoints (not Unicode characters).
+ SPECIAL = {57350: curses.KEY_LEFT, 57351: curses.KEY_RIGHT,
+ 57352: curses.KEY_UP, 57353: curses.KEY_DOWN,
+ 57348: curses.KEY_IC, 57349: curses.KEY_DC,
+ 57354: curses.KEY_PPAGE, 57355: curses.KEY_NPAGE,
+ 57356: curses.KEY_HOME, 57357: curses.KEY_END,
+ 127: curses.KEY_BACKSPACE}
+ JOG = {curses.KEY_RIGHT: ('X', 1), curses.KEY_LEFT: ('X', -1),
+ curses.KEY_UP: ('Y', 1), curses.KEY_DOWN: ('Y', -1),
+ ord('a'): ('Z', 1), ord('A'): ('Z', 1),
+ ord('z'): ('Z', -1), ord('Z'): ('Z', -1),
+ ord(']'): ('A', 1), ord('['): ('A', -1)}
+
+ def __init__(self):
+ self.supported = False
+ self._held = set()
+ self._buffer = ''
+ self._pending = deque()
+ self._escape_at = 0.0
+ self._started = False
+ self.event_time = None
+ self.event_type = 1
+
+ def _queue(self, key, event=1):
+ self._pending.append((key, time.monotonic(), event))
+
+ def _pop(self):
+ key, self.event_time, self.event_type = self._pending.popleft()
+ return key
+
+ @property
+ def window(self):
+ # A compatibility token for TUI's current input session.
+ return 1 if self.supported else None
+
+ def start(self):
+ if not self._started:
+ # Disambiguate escape codes + report press/repeat/release; ask for
+ # a response rather than trusting TERM or guessing key-up timing.
+ sys.stdout.write('\x1b[>11u\x1b[?u\x1b[?1004h')
+ sys.stdout.flush()
+ self._started = True
+
+ def feed(self, data):
+ self._buffer += data
+ while self._buffer:
+ if not self._buffer.startswith('\x1b'):
+ self._queue(ord(self._buffer[0]))
+ self._buffer = self._buffer[1:]
+ continue
+ if self._buffer == '\x1b' or self._buffer == '\x1b[':
+ break
+ match = re.match(r'\x1b\[([0-9;:?<>]*)([A-Za-z~])', self._buffer)
+ if not match:
+ if len(self._buffer) > 64 or not self._buffer.startswith('\x1b['):
+ self._queue(27)
+ self._buffer = self._buffer[1:]
+ continue
+ break
+ args, final = match.groups()
+ self._buffer = self._buffer[match.end():]
+ if final == 'u' and args.startswith('?'):
+ self.supported = (int(args[1:] or '0') & 11) == 11
+ elif final == 'u' and not args.startswith(('>', '<')):
+ fields = args.split(';')
+ code = int(fields[0].split(':')[0])
+ key = self.SPECIAL.get(code, code)
+ event = 1
+ if len(fields) > 1 and ':' in fields[1]:
+ event = int(fields[1].split(':')[1])
+ jog = self.JOG.get(key)
+ if event == 3:
+ if jog:
+ self._held.discard(jog)
+ elif event in (1, 2):
+ if jog:
+ self._held.add(jog)
+ self._queue(key, event)
+ elif final == 'O': # Focus out, CSI O (focus in = CSI I).
+ self._held.clear()
+ elif final in 'ABCDHF' or final == '~':
+ key = {'A': curses.KEY_UP, 'B': curses.KEY_DOWN,
+ 'C': curses.KEY_RIGHT, 'D': curses.KEY_LEFT,
+ 'H': curses.KEY_HOME, 'F': curses.KEY_END}.get(final)
+ if final == '~':
+ key = {'2': curses.KEY_IC, '3': curses.KEY_DC,
+ '5': curses.KEY_PPAGE, '6': curses.KEY_NPAGE}.get(args.split(';')[0])
+ fields = args.split(';')
+ event = int(fields[1].split(':')[1]) if len(fields) > 1 and ':' in fields[1] else 1
+ jog = self.JOG.get(key)
+ if event == 3:
+ if jog:
+ self._held.discard(jog)
+ elif key is not None:
+ if jog and self.supported:
+ self._held.add(jog)
+ self._queue(key, event)
+ elif final == 'Z':
+ self._queue(curses.KEY_BTAB)
+ if self._buffer and not self._escape_at:
+ self._escape_at = time.monotonic()
+ elif not self._buffer:
+ self._escape_at = 0.0
+
+ def read(self, screen):
+ if self._pending:
+ return self._pop()
+ key = screen.getch()
+ if 0 <= key < 256:
+ self.feed(chr(key))
+ # Drain the rest of an encoded event without a timeout per byte.
+ screen.timeout(0)
+ try:
+ while True:
+ key = screen.getch()
+ if key == -1:
+ break
+ if 0 <= key < 256:
+ self.feed(chr(key))
+ else:
+ self._queue(key)
+ finally:
+ screen.timeout(25)
+ elif key != -1:
+ self._queue(key)
+ if self._buffer == '\x1b' and time.monotonic() - self._escape_at >= 0.025:
+ self._buffer = ''
+ self._escape_at = 0.0
+ self._queue(27)
+ return self._pop() if self._pending else -1
+
+ def held(self, axis, sign, window):
+ return bool(self.supported and (axis, sign) in self._held
+ and (axis, -sign) not in self._held)
+
+ def suspend(self):
+ self._held.clear()
+ if self._started:
+ sys.stdout.write('\x1b[ Optional[MachineState]:
polling_update_state_and_push_UI → state-change dispatcher path,
which auto-pops the error dialog on the same transition.
"""
+ if self.move_active:
+ return self._poll_absolute_move()
s = self._read_state()
prev_ping = self._last_ping_word
self._ping_status()
@@ -455,6 +459,9 @@ def pattern_b_read(
def stop_motion(self) -> None:
"""Send immediate motion stop (SET wValue=0x03f3)."""
+ if self.move_active:
+ self._absolute_move["cancelled"] = True
+ self.move_message = "Move stop requested; waiting for idle"
try:
self._link.vend_set(0x03f3)
log.info("Motion stop sent (SET 0x03f3)")
@@ -850,16 +857,12 @@ def set_active_wcs(self, slot: int) -> None:
"""
if not 0 <= slot <= 10:
raise ValueError(f"WCS slot must be 0–10, got {slot}")
- try:
- log.info("Active WCS → %s", wcs_label(slot))
- self._active_wcs = slot
- if slot == 0:
- self._wcs_offset = (0.0, 0.0, 0.0, 0.0)
- else:
- origin = self.get_wcs_origin(slot)
- self._wcs_offset = origin if origin else (0.0, 0.0, 0.0, 0.0)
- except usb.core.USBError as e:
- log.warning("set_active_wcs(%s) failed: %s", wcs_label(slot), e)
+ origin = (0.0, 0.0, 0.0, 0.0) if slot == 0 else self.get_wcs_origin(slot)
+ if origin is None:
+ raise ValueError(f"Cannot read {wcs_label(slot)}; previous coordinate system retained")
+ self._active_wcs = slot
+ self._wcs_offset = origin
+ log.info("Active WCS → %s", wcs_label(slot))
def write_wcs_origin(
self, slot: int,
@@ -1144,23 +1147,92 @@ def move_to_machine_pos(
RE: FUN_00419ef0 @ 0x00419ef0 — absolute position move (flags=0xFFFF),
wrapped in operation bracket SET 0x1109 (0x00 begin / 0xff end).
- Fire-and-forget: returns once the four control transfers are sent;
+ Fire-and-forget: returns after the operation begin and target are sent;
the caller observes motion completion via subsequent poll() cycles.
"""
- payload = struct.pack(
- '>HH4i', speed, 0xFFFF,
- round(x_mm * 1000), round(y_mm * 1000),
- round(z_mm * 1000), round(a_deg * 1000),
- )
+ if self.move_active:
+ raise ValueError("An absolute move is already active")
+ # 0xffff is a preset sentinel, not the numeric speed used by VPanel's
+ # absolute-move callers (120 or 3000). Use the confirmed maximum.
+ actual_speed = 3000 if speed == JOG_SPEED_MAX else speed
+ if not 1 <= actual_speed <= 3000:
+ raise ValueError("Absolute move speed must be 1–3000 mm/min")
+ payload = struct.pack('>HH4i', actual_speed, 0xffff,
+ round(x_mm * 1000), round(y_mm * 1000),
+ round(z_mm * 1000), round(a_deg * 1000))
+ now = time.monotonic()
+ self._absolute_move = {"target": (x_mm, y_mm, z_mm, a_deg),
+ "started": now, "fresh": now,
+ "seen": False, "cancelled": False}
try:
+ self._link.vend_set(0x03f5, b'\x02')
self._link.vend_set(0x1109, b'\x00')
self._link.vend_set(0x04f7, payload)
- self._link.vend_set(0x3f2)
- self._link.vend_set(0x1109, b'\xff')
- log.info("Move to machine (%.3f, %.3f, %.3f, %.3f°) speed=%d",
- x_mm, y_mm, z_mm, a_deg, speed)
- except usb.core.USBError as e:
- log.warning("move_to_machine_pos failed: %s", e)
+ self.move_message = "Move-To sent; waiting for motion"
+ log.info("Move to machine %s speed=%d", self._absolute_move["target"], actual_speed)
+ except usb.core.USBError:
+ self.stop_motion()
+ self.move_message = "Move write failed; result unverified, waiting for idle"
+ raise
+
+ @property
+ def move_active(self) -> bool:
+ return self._absolute_move is not None
+
+ def _finish_absolute_move(self) -> None:
+ # VPanel's operation unwinder runs only after its motion wait.
+ for cmd, data in ((0x03f2, b''), (0x03f5, b'\xff'), (0x1109, b'\xff')):
+ self._link.vend_set(cmd, data)
+ self._absolute_move = None
+
+ def _poll_absolute_move(self):
+ operation = self._absolute_move
+ now = time.monotonic()
+ try:
+ raw = self._link.vend_get(0x0100, 32)
+ state = _decode_state(raw) if len(raw) == 32 else None
+ except usb.core.USBError:
+ state = None
+ if state is None:
+ if now - operation["fresh"] > 3 and not operation["cancelled"]:
+ self.stop_motion()
+ self.move_message = "Move status lost; stop requested, waiting for idle"
+ return None
+ self._state = state
+ operation["fresh"] = now
+ moving = bool(state.flags & (FLAG_MOVING | FLAG_CMD_MOVE))
+ operation["seen"] |= moving
+ current = (state.x_mm, state.y_mm, state.z_mm, state.a_deg)
+ reached = all(abs(a - b) <= 0.002 for a, b in zip(current, operation["target"]))
+ if (state.flags & (FLAG_DOOR | FLAG_ERROR) or now - operation["started"] > 120) and not operation["cancelled"]:
+ self.stop_motion()
+ self.move_message = "Move interrupted; stop requested, waiting for idle"
+ if not moving and state.idle:
+ if operation["cancelled"]:
+ self.move_message = "Move stopped; target not verified"
+ elif reached:
+ self.move_message = "Move-To complete; target position confirmed"
+ elif operation["seen"]:
+ self.move_message = "Move ended before target; result unverified"
+ elif now - operation["started"] > 2:
+ self.stop_motion()
+ self.move_message = "Move did not start; stop requested, result unverified"
+ else:
+ return state # Idle before acknowledgement is not completion.
+ try:
+ self._finish_absolute_move()
+ except usb.core.USBError:
+ self.move_message = "Move cleanup failed; waiting for reconnection"
+ log.info(self.move_message)
+ return state
+
+ def close_move(self):
+ if self.move_active:
+ self.stop_motion()
+ try:
+ self._finish_absolute_move()
+ except usb.core.USBError:
+ log.warning("Absolute-move cleanup failed on exit")
# ── Move-to-origin presets (firmware-resolved) ────────────────────────────
@@ -1191,9 +1263,9 @@ def move_to_origin(
wcs: Optional[int] = None,
speed: int = JOG_SPEED_MAX,
) -> None:
- """Move the selected axes to their stored origin in the given WCS (SET 0x3501).
+ """Move selected axes to the selected origin using an absolute machine move.
- Payload `>HHH` — (wcs_code, axis_mask, speed). `wcs` defaults to the
+ Uses move_to_position and absolute machine coordinates. `wcs` defaults to the
currently-active WCS. `axis_mask` is a bitfield: 1=X, 2=Y, 4=Z, 8=A
(and combinations: 3=XY). VPanel only ever sends {1, 2, 3, 4, 8} from
the Move dropdown — anything else is rejected.
@@ -1205,11 +1277,30 @@ def move_to_origin(
raise ValueError(
f"axis_mask must be one of {self._VALID_MOVE_AXIS_MASKS}, got {axis_mask}"
)
- wcs_code = self._active_wcs if wcs is None else wcs
- self._send_bracketed(
- 0x3501, struct.pack('>HHH', wcs_code, axis_mask, speed),
- f"Move to origin: wcs={wcs_label(wcs_code)} mask=0x{axis_mask:x} speed={speed}",
- )
+ target = [0.0 if axis_mask & (1 << i) else None for i in range(4)]
+ self.move_to_position(*target, wcs=wcs, speed=speed)
+
+ def move_to_position(self, x=None, y=None, z=None, a=None,
+ wcs: Optional[int] = None, speed: int = 1800) -> None:
+ """Move in selected coordinates; omitted axes preserve machine position."""
+ slot = self._active_wcs if wcs is None else wcs
+ if not 0 <= slot <= 10:
+ raise ValueError("WCS slot must be 0–10")
+ origin = (0.0, 0.0, 0.0, 0.0) if slot == 0 else self.get_wcs_origin(slot)
+ if origin is None:
+ raise ValueError("Cannot read selected coordinate origin; move cancelled")
+ state = self.poll()
+ if state is None:
+ raise ValueError("Cannot read current position; move cancelled")
+ if not state.idle or state.flags & (FLAG_MOVING | FLAG_CMD_MOVE | FLAG_DOOR | FLAG_ERROR):
+ raise ValueError("Machine must be idle with cover closed before Move-To")
+ current = (state.x_mm, state.y_mm, state.z_mm, state.a_deg)
+ target = [current[i] if value is None else value + origin[i]
+ for i, value in enumerate((x, y, z, a))]
+ if slot == self._active_wcs:
+ self._wcs_offset = origin
+ log.info("Move in %s: target=%s; machine target=%s", wcs_label(slot), (x, y, z, a), target)
+ self.move_to_machine_pos(*target, speed=speed)
def move_to_rotation_center_y(self, speed: int = JOG_SPEED_MAX) -> None:
"""Move Y onto the rotary A-axis centreline (SET 0x3808, mode=2).
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 34e8796..08e48e9 100644
--- a/rollingmill/tui.py
+++ b/rollingmill/tui.py
@@ -11,8 +11,8 @@
a / z Z axis +/− (a raises, z lowers)
[/] A axis −/+
Esc cancel in-flight jog
- f cycle jog speed: vslow / slow / medium / fast (default slow)
- 1/2/3/4/5 XYZ step: 0.01/0.1/1.0/10.0/50.0 mm A step: 0.01/0.1/1.0/10.0/90.0°
+ j toggle step / continuous jog (direction starts; Esc stops)
+ f cycle pulse size/feed: 0.01/6, 0.1/60, 1/600, 5/3000 mm/mm-min
s spindle on / off (toggle)
d A-axis rotary drilling on / off (toggle; Drill Workpiece dialog)
< / > spindle target RPM −500 / +500
@@ -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):
@@ -37,28 +38,23 @@
import time
from typing import Optional, Tuple
+import usb.core
+
from . import machine as _machine
from . import trace as _trace
from .machine import (FLAG_VIEW_LED, FLAG_DOOR, FLAG_SPINDLE, FLAG_CMD_MOVE,
FLAG_TOOLBTN, FLAG_NC_READY, FLAG_MOVING, FLAG_BUSY,
- FLAG_ERROR, FLAG_STATE, FLAG_STATE_SHIFT, JOG_SPEED_MAX,
+ FLAG_ERROR, FLAG_STATE, FLAG_STATE_SHIFT,
STATE_MAP, WCS_SLOT_NAMES)
from .trace import Tracer
from .cutjob import CutJob
+from .probing import ZProbe
+from .keyrelease import HeldKeys
# ── Jog parameters ────────────────────────────────────────────────────────────
-STEPS_LINEAR = [0.01, 0.1, 1.0, 10.0, 50.0] # XYZ jog distances (mm)
-STEPS_ROTARY = [0.01, 0.1, 1.0, 10.0, 90.0] # A jog distances (degrees)
-
-# Jog speed presets cycled by the `f` key (mm/min).
-# slow = VPanel continuous-jog ramp start (240); fast = firmware max (0xFFFF).
-SPEED_PRESETS = [
- ('vslow', 120),
- ('slow', 240),
- ('medium', 480),
- ('fast', JOG_SPEED_MAX),
-]
+# Each pulse takes 100 ms at its paired feed. Rotary uses degrees/degree-min.
+JOG_PULSES = [(0.01, 6), (0.1, 60), (1.0, 600), (5.0, 3000)]
# ── Colour pair IDs ───────────────────────────────────────────────────────────
@@ -110,11 +106,21 @@ def __init__(self, machine: _machine.MDX40A, log_buf: _LogBuffer,
self._m = machine
self._log = log_buf
self._tracer = tracer
- self._step_i = 2 # index into STEPS_LINEAR / STEPS_ROTARY (default 1.0mm / 1.0°)
- self._speed_i = 1 # index into SPEED_PRESETS; `f` cycles (default slow)
+ self._pulse_i = 3 # default 5 mm at 3000 mm/min
self._moving : Optional[str] = None # axis currently jogging (None → idle)
self._jog_armed = False # True between send_jog() and post-motion settle
self._jog_sent_at = 0.0 # monotonic timestamp of last send_jog()
+ self._held_keys = HeldKeys()
+ self._jog_window = None
+ self._continuous = True
+ self._repeat_direction = None
+ self._repeat_last = None
+ self._repeat_deadline = 0.0
+ self._repeat_blocked = False
+ self._continuous_axis = None
+ self._continuous_sign = 0
+ self._continuous_next = 0.0
+ self._jog_last_fresh = 0.0
self._quit = False
self._last_poll = 0.0 # monotonic timestamp of last machine.poll()
# WCS overlay dialog state
@@ -134,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 ───────────────────────────────────────────────────────────
@@ -141,16 +149,18 @@ def run(self, stdscr: curses.window) -> None:
self._init_colors()
curses.curs_set(0)
stdscr.nodelay(True)
- # getch() returns every 100 ms — our cooperative tick
- stdscr.timeout(100)
+ # getch() returns every 25 ms — our cooperative tick
+ stdscr.timeout(25)
# ncurses defaults ESCDELAY to ~1000 ms to see if Esc is the start of an
# arrow/function-key sequence. Drop it to 25 ms so Esc is delivered on
# the next tick.
curses.set_escdelay(25)
+ self._held_keys.start()
+
while not self._quit:
try:
- key = stdscr.getch()
+ key = self._held_keys.read(stdscr)
except curses.error:
key = -1
@@ -161,17 +171,28 @@ def run(self, stdscr: curses.window) -> None:
if self._coord_entry_pending:
self._coord_entry_pending = False
- self._coord_entry_dialog(stdscr)
+ self._held_keys.suspend()
+ try:
+ self._coord_entry_dialog(stdscr)
+ finally:
+ self._held_keys.start()
stdscr.clear()
+ self._service_continuous_jog()
+
# 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)
+ if fresh is not None:
+ self._jog_last_fresh = time.monotonic()
+ elif self._continuous_axis is not None:
+ self._stop_continuous_jog()
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)
@@ -208,6 +229,33 @@ def P(idx, fg, bg=-1):
# ── Input ─────────────────────────────────────────────────────────────────
def _handle_key(self, key: int) -> None:
+ if self._continuous_axis is not None:
+ if key in (27, ord("q"), ord("Q"), ord("j"), ord("J")):
+ self._stop_continuous_jog()
+ return
+ if key not in (curses.KEY_LEFT, curses.KEY_RIGHT, curses.KEY_UP, curses.KEY_DOWN, ord("a"), ord("A"), ord("z"), ord("Z"), ord("["), ord("]")):
+ return
+
+ if self._m.move_active:
+ if key in (27, ord('q'), ord('Q')):
+ self._m.stop_motion()
+ return # Keep polling and block competing commands until idle.
+
+ 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)
@@ -220,6 +268,8 @@ def _handle_key(self, key: int) -> None:
return
if key == 27: # Esc — cancel an in-flight jog
+ self._repeat_blocked = True
+ self._repeat_last = time.monotonic()
if self._jog_armed:
self._m.stop_motion()
self._jog_armed = False
@@ -275,14 +325,18 @@ def _handle_key(self, key: int) -> None:
self._start_jog(axis, sign)
return
- if key in (ord('f'), ord('F')):
- self._speed_i = (self._speed_i + 1) % len(SPEED_PRESETS)
- self.annotate(f"KEY f speed={SPEED_PRESETS[self._speed_i][0]}")
- elif key in (ord('1'), ord('2'), ord('3'), ord('4'), ord('5')):
- self._step_i = key - ord('1')
- lin = STEPS_LINEAR[self._step_i]
- rot = STEPS_ROTARY[self._step_i]
- self.annotate(f"KEY {chr(key)} step={lin}mm/{rot}°")
+ if key in (ord("j"), ord("J")):
+ self._continuous = not self._continuous
+ self._repeat_direction = None
+ self._repeat_last = None
+ self._repeat_blocked = False
+ if self._continuous:
+ self._held_keys.start()
+ self.annotate("Jog mode: " + ("CONTINUOUS — hold direction; release stops" if self._continuous else "STEP"))
+ elif key in (ord('f'), ord('F')):
+ self._pulse_i = (self._pulse_i + 1) % len(JOG_PULSES)
+ pulse, feed = JOG_PULSES[self._pulse_i]
+ self.annotate(f"KEY f pulse={pulse:g}mm feed={feed}mm/min")
elif key in (ord('s'), ord('S')):
self._toggle_spindle()
elif key == ord('<'):
@@ -301,6 +355,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()
@@ -334,12 +393,98 @@ def _nc_key_hint(job: CutJob) -> str:
parts.append('q quit')
return ' '.join(parts)
+ def _stop_continuous_jog(self) -> None:
+ if self._continuous_axis is not None:
+ self._m.stop_motion()
+ self._m._set_operation_mode(False)
+ self._continuous_axis = None
+ self._repeat_blocked = True
+ self._repeat_direction = None
+ self._repeat_last = time.monotonic()
+ self._moving = None
+ self._jog_armed = False
+
+ def _service_continuous_jog(self) -> None:
+ if self._continuous_axis is None:
+ return
+ now = time.monotonic()
+ held = (self._held_keys.held(self._continuous_axis, self._continuous_sign, self._jog_window)
+ if self._held_keys.supported else now < self._repeat_deadline)
+ if not held:
+ self._stop_continuous_jog()
+ return
+ state = self._m.state
+ if now - self._jog_last_fresh > 0.75 or state.flags & (FLAG_DOOR | FLAG_ERROR):
+ self._stop_continuous_jog()
+ return
+ if now < self._continuous_next:
+ return
+ axis = self._continuous_axis
+ delta, speed = JOG_PULSES[self._pulse_i]
+ try:
+ self._m.send_jog(axis, self._continuous_sign * delta, speed=speed)
+ except Exception:
+ self._stop_continuous_jog()
+ logging.getLogger('tui').exception("Continuous jog failed")
+ return
+ self._continuous_next = now + 0.1
+
def _start_jog(self, axis: str, sign: int) -> None:
+ now = time.monotonic()
+ repeat_ready = False
+ fallback = self._continuous and not self._held_keys.supported
+ if fallback:
+ stamp = self._held_keys.event_time
+ if not isinstance(stamp, (int, float)):
+ stamp = now # Direct callers/tests do not have a queued event.
+ if now - stamp >= 0.2:
+ return # Buffered presses must not renew a motion lease.
+ gap = float('inf') if self._repeat_last is None else stamp - self._repeat_last
+ if self._repeat_blocked and gap <= 0.2:
+ self._repeat_last = stamp
+ return # Esc/stop cannot be undone by the remaining repeats.
+ if self._repeat_blocked:
+ self._repeat_direction = None
+ self._repeat_blocked = False
+ direction = (axis, sign)
+ repeat_ready = direction == self._repeat_direction and 0 <= gap <= 1.0
+ self._repeat_direction = direction
+ self._repeat_last = stamp
+ self._repeat_deadline = stamp + 0.2
+ if self._continuous and not fallback:
+ repeat_ready = self._held_keys.event_type == 2
+ if self._continuous and repeat_ready:
+ if self._continuous_axis is not None:
+ if axis == self._continuous_axis and sign == self._continuous_sign:
+ return # Refresh the lease, without restarting motion.
+ self._stop_continuous_jog()
+ return
+ if self._jog_armed or self._cut_job or self._drill_active:
+ return
+ window = self._held_keys.window
+ if not fallback and (window is None or not self._held_keys.held(axis, sign, window)):
+ return
+ self._jog_window = window
+ fresh = self._m.poll()
+ if fresh is None or not fresh.idle or fresh.flags & (FLAG_MOVING | FLAG_CMD_MOVE | FLAG_DOOR | FLAG_ERROR):
+ return
+ self._m._set_operation_mode(True)
+ self._continuous_axis = axis
+ self._continuous_sign = sign
+ self._continuous_next = 0.0
+ self._jog_last_fresh = time.monotonic()
+ self._moving = axis
+ self._jog_armed = True
+ suffix = "repeat timeout 200 ms" if fallback else "key release"
+ self.annotate(f"CONTINUOUS {axis} {sign:+d} — {suffix}; Esc stops")
+ self._service_continuous_jog()
+ return
+ if fallback and (self._cut_job or self._drill_active):
+ return
if self._jog_armed:
return # previous jog still settling — ignore (no overlap)
- step = STEPS_ROTARY[self._step_i] if axis == 'A' else STEPS_LINEAR[self._step_i]
- dist = sign * step
- speed = SPEED_PRESETS[self._speed_i][1]
+ step, speed = JOG_PULSES[self._pulse_i]
+ dist = sign * step
unit = '°' if axis == 'A' else 'mm'
self.annotate(
@@ -376,7 +521,7 @@ def _update_jog_indicator(self) -> None:
raise the bits);
- neither MOVING nor CMD_MOVE set on the cached state.
"""
- if not self._jog_armed:
+ if self._continuous_axis is not None or not self._jog_armed:
return
if time.monotonic() - self._jog_sent_at < 0.2:
return
@@ -440,9 +585,12 @@ def _draw(self, stdscr: curses.window) -> None:
log_h = rows - log_row
self._draw_log(stdscr, log_row, log_h, cols)
+ operation_message = self._probe.message if self._probe.active else (self._m.move_message or self._probe.message)
+ if operation_message:
+ self._put(stdscr, max(0, log_row - 1), 0, operation_message)
try:
- stdscr.refresh()
+ stdscr.noutrefresh()
except curses.error:
pass
@@ -454,6 +602,32 @@ 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)
+
+ try:
+ curses.doupdate()
+ except curses.error:
+ pass
+
+ 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.noutrefresh()
+ 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.noutrefresh()
def _draw_state(self, win: curses.window, height: int, cols: int) -> None:
s = self._m.state
@@ -461,15 +635,18 @@ def _draw_state(self, win: curses.window, height: int, cols: int) -> None:
BOLD = curses.A_BOLD
# Row 0 — title bar
- speed_lbl = SPEED_PRESETS[self._speed_i][0]
- lin_lbl = STEPS_LINEAR[self._step_i]
- rot_lbl = STEPS_ROTARY[self._step_i]
+ pulse, feed = JOG_PULSES[self._pulse_i]
wcs_lbl = WCS_SLOT_NAMES[self._m.active_wcs]
rotary_lbl = {0: "No Extension", 1: "Rotary Axis", 2: "Rotary Vice"}.get(
self._m.rotary_extension_byte, "?")
- title = f" Roland MDX-40A │ {speed_lbl} │ XYZ {lin_lbl}mm A {rot_lbl}° │ {wcs_lbl} │ {rotary_lbl} "
+ title = f" Roland MDX-40A │ {wcs_lbl} │ {rotary_lbl} "
self._put(win, 0, 0, title.ljust(cols), CP(_CP_HEADER) | BOLD)
+ if height >= 2:
+ mode = "Hold" if self._continuous else "Step"
+ pulse_line = f" [f] Pulse: {pulse:g} mm | Feed: {feed} mm/min | {mode} | A: {pulse:g}°"
+ self._put(win, 1, 0, pulse_line[:cols], CP(_CP_VALUE) | BOLD)
+
if height < 3:
return
@@ -557,8 +734,8 @@ def _draw_state(self, win: curses.window, height: int, cols: int) -> None:
ref_row = height - 2
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',
+ f' jog: ←→ X ↑↓ Y a(up)/z(down) Z [] A j mode={"CONTINUOUS (Esc stops)" if self._continuous else "STEP"} f pulse (0.01/0.1/1/5mm)',
+ ' 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
@@ -626,7 +803,10 @@ def _wcs_handle_key(self, key: int) -> None:
elif key == curses.KEY_DOWN:
self._wcs_sel = min(len(WCS_SLOT_NAMES)-1, self._wcs_sel + 1)
elif key in (10, 13): # Enter — activate
- self._m.set_active_wcs(self._wcs_sel)
+ try:
+ self._m.set_active_wcs(self._wcs_sel)
+ except ValueError as exc:
+ logging.getLogger("tui").warning("Coordinate selection: %s", exc)
elif key in (ord('m'), ord('M')): # Move to stored origin
if self._wcs_sel == 0:
return # MCS origin is always (0,0,0,0) — no-op / already there
@@ -675,7 +855,7 @@ def _draw_wcs_dialog(self, stdscr: curses.window, rows: int, cols: int) -> None:
win.addstr(0, 2, title[:dw - 4], CP(_CP_HEADER) | BOLD)
if dh < 5:
- win.refresh()
+ win.noutrefresh()
return
# Column headers
@@ -719,7 +899,7 @@ def _draw_wcs_dialog(self, stdscr: curses.window, rows: int, cols: int) -> None:
win.addstr(ref_row, 1, '─' * (dw - 2), CP(_CP_LABEL))
win.addstr(ref_row + 1, 1, keys[:dw - 2], CP(_CP_KEYS))
- win.refresh()
+ win.noutrefresh()
# ── Tool diameter offsets dialog ──────────────────────────────────────────
@@ -792,7 +972,7 @@ def _draw_tool_dialog(self, stdscr: curses.window, rows: int, cols: int) -> None
win.addstr(0, 2, ' Tool Diameter Offsets'[:dw - 4], CP(_CP_HEADER) | BOLD)
if dh < 5:
- win.refresh()
+ win.noutrefresh()
return
hdr = f" {'Slot':4s} {'Value (mm)':>12s}"
@@ -830,7 +1010,7 @@ def _draw_tool_dialog(self, stdscr: curses.window, rows: int, cols: int) -> None
win.addstr(ref_row, 1, '─' * (dw - 2), CP(_CP_LABEL))
win.addstr(ref_row + 1, 1, keys[:dw - 2], CP(_CP_KEYS))
- win.refresh()
+ win.noutrefresh()
# ── Move-To picker ('m' key) ──────────────────────────────────────────────
@@ -844,7 +1024,7 @@ def _move_targets(self) -> list:
"""
m = self._m
targets = [
- ('View Position', lambda: m.move_to_view_position()),
+ ('Preview / View Position (MCS)', lambda: m.move_to_view_position()),
('X Origin', lambda: m.move_to_origin(0x1)),
('Y Origin', lambda: m.move_to_origin(0x2)),
('Z Origin', lambda: m.move_to_origin(0x4)),
@@ -853,8 +1033,6 @@ def _move_targets(self) -> list:
rotary = m.rotary_extension_byte
if rotary is not None and rotary >= 1:
targets.append(('A Origin', lambda: m.move_to_origin(0x8)))
- if rotary is not None and rotary >= 2:
- targets.append(('Rotation center Y', lambda: m.move_to_rotation_center_y()))
targets.append(('User Specified…', None)) # None → open numeric entry dialog
return targets
@@ -907,7 +1085,7 @@ def _draw_move_dialog(self, stdscr: curses.window, rows: int, cols: int) -> None
win.erase()
win.box()
- wcs_lbl = 'MCS' if self._m.active_wcs == 0 else f'WCS{self._m.active_wcs}'
+ wcs_lbl = _machine.wcs_label(self._m.active_wcs)
title = f' Move To (using {wcs_lbl})'
win.addstr(0, 2, title[:dw - 4], CP(_CP_HEADER) | BOLD)
win.addstr(1, 1, '─' * (dw - 2), CP(_CP_LABEL))
@@ -928,7 +1106,7 @@ def _draw_move_dialog(self, stdscr: curses.window, rows: int, cols: int) -> None
win.addstr(ref_row, 1, '─' * (dw - 2), CP(_CP_LABEL))
win.addstr(ref_row + 1, 1, keys[:dw - 2], CP(_CP_KEYS))
- win.refresh()
+ win.noutrefresh()
# ── Coordinate entry dialog (blocking, 'c' key) ───────────────────────────
@@ -949,7 +1127,7 @@ def _coord_entry_dialog(self, stdscr: curses.window) -> None:
CP = curses.color_pair
BOLD = curses.A_BOLD
- wcs_lbl = 'MCS' if self._m.active_wcs == 0 else f'WCS{self._m.active_wcs}'
+ wcs_lbl = _machine.wcs_label(self._m.active_wcs)
win.erase()
win.box()
@@ -1034,21 +1212,15 @@ def redraw():
for b, default in zip(bufs, defaults):
b = b.strip()
if not b:
- final_display.append(default)
+ final_display.append(None)
continue
try:
final_display.append(float(b))
except ValueError:
final_display.append(default)
- # Convert WCS-relative display coords back to machine coords
- ox, oy, oz, oa = self._m.wcs_offset
- mx = final_display[0] + ox
- my = final_display[1] + oy
- mz = final_display[2] + oz
- ma = final_display[3] + oa
-
- self._m.move_to_machine_pos(mx, my, mz, ma)
+ # Resolve through the same selected-CS path as origin presets.
+ self._m.move_to_position(*final_display)
def _draw_nc_panel(self, win: curses.window, start: int, height: int, cols: int) -> None:
if not self._cut_job or height < 2:
@@ -1177,7 +1349,20 @@ def main(argv=None) -> None:
tui = TUI(m, log_buf, t)
if args.file:
tui.load_nc_file(args.file)
- curses.wrapper(tui.run)
+ try:
+ def run_with_cleanup(screen):
+ try:
+ tui.run(screen)
+ finally:
+ tui._stop_continuous_jog()
+ tui._probe.close()
+ m.close_move()
+ tui._held_keys.close() # Restore before curses leaves alternate screen.
+ curses.wrapper(run_with_cleanup)
+ finally:
+ tui._stop_continuous_jog()
+ tui._probe.close()
+ tui._held_keys.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_motion_controls.py b/tests/test_motion_controls.py
new file mode 100644
index 0000000..92267f3
--- /dev/null
+++ b/tests/test_motion_controls.py
@@ -0,0 +1,417 @@
+import curses
+import struct
+from unittest.mock import Mock
+
+import pytest
+from test_probing import IDLE, Link
+
+from rollingmill.machine import FLAG_DOOR, MDX40A, _decode_state
+from rollingmill.tui import TUI, _LogBuffer
+
+
+@pytest.mark.parametrize("slot", range(9))
+@pytest.mark.parametrize("mask", [1, 2, 3, 4, 8])
+def test_move_origin_uses_selected_coordinates_and_preserves_other_axes(slot, mask):
+ link = Link()
+ machine = MDX40A(link)
+ machine._active_wcs = slot
+ machine.get_wcs_origin = Mock(return_value=(11.0, 22.0, 33.0, 44.0))
+ machine.poll = Mock(return_value=_decode_state(IDLE))
+ machine.move_to_origin(mask, speed=1800)
+ current = _decode_state(IDLE)
+ origin = (0, 0, 0, 0) if slot == 0 else (11000, 22000, 33000, 44000)
+ position = [round(v * 1000) for v in (current.x_mm, current.y_mm, current.z_mm, current.a_deg)]
+ expected = [origin[i] if mask & (1 << i) else position[i] for i in range(4)]
+ assert (0x04f7, struct.pack('>HH4i', 1800, 0xffff, *expected)) in link.sent
+ assert not any(cmd == 0x3501 for cmd, _ in link.sent)
+ if slot:
+ machine.get_wcs_origin.assert_called_once_with(slot)
+
+
+def test_origin_read_failure_sends_no_motion():
+ link = Link()
+ machine = MDX40A(link)
+ machine._active_wcs = 4
+ link.sent.clear()
+ machine.get_wcs_origin = Mock(return_value=None)
+ with pytest.raises(ValueError):
+ machine.move_to_origin(3)
+ assert not link.sent
+
+
+def test_continuous_refresh_during_motion_repeat_and_stop(monkeypatch):
+ clock = [10.0]
+ monkeypatch.setattr('rollingmill.tui.time.monotonic', lambda: clock[0])
+ link = Link()
+ machine = MDX40A(link)
+ machine.poll = Mock(return_value=_decode_state(IDLE))
+ machine._state = _decode_state(IDLE)
+ tui = TUI(machine, _LogBuffer())
+ tui._held_keys = Mock(window=123, event_type=2)
+ tui._held_keys.held.return_value = True
+ tui._handle_key(curses.KEY_RIGHT)
+ assert tui._continuous_axis == 'X'
+ initial = sum(c == 0x04f5 for c, _ in link.sent)
+ tui._handle_key(curses.KEY_RIGHT)
+ assert sum(c == 0x04f5 for c, _ in link.sent) == initial
+ machine._state.flags |= 0x04400000
+ clock[0] += 0.11
+ tui._service_continuous_jog()
+ assert sum(c == 0x04f5 for c, _ in link.sent) == initial + 1
+ tui._handle_key(ord('m'))
+ assert not tui._move_open
+ tui._handle_key(27)
+ assert tui._continuous_axis is None
+ assert (0x03f3, b'') in link.sent
+ assert link.sent[-1] == (0x1109, b'\xff')
+
+
+@pytest.mark.parametrize('reason', ['stale', 'door'])
+def test_continuous_stops_on_status_loss_or_open_cover(monkeypatch, reason):
+ clock = [10.0]
+ monkeypatch.setattr('rollingmill.tui.time.monotonic', lambda: clock[0])
+ link = Link()
+ machine = MDX40A(link)
+ machine.poll = Mock(return_value=_decode_state(IDLE))
+ machine._state = _decode_state(IDLE)
+ tui = TUI(machine, _LogBuffer())
+ tui._held_keys = Mock(window=123, event_type=2)
+ tui._held_keys.held.return_value = True
+ tui._continuous = True
+ tui._start_jog('Z', -1)
+ if reason == 'stale':
+ clock[0] += 1
+ else:
+ machine._state.flags |= FLAG_DOOR
+ tui._service_continuous_jog()
+ assert tui._continuous_axis is None
+ assert (0x03f3, b'') in link.sent
+
+
+def test_release_stops_motion(monkeypatch):
+ link = Link()
+ machine = MDX40A(link)
+ machine.poll = Mock(return_value=_decode_state(IDLE))
+ machine._state = _decode_state(IDLE)
+ tui = TUI(machine, _LogBuffer())
+ tui._held_keys = Mock(window=123, event_type=2)
+ tui._held_keys.held.return_value = True
+ tui._continuous = True
+ tui._start_jog('X', 1)
+ tui._held_keys.held.return_value = False
+ tui._service_continuous_jog()
+ assert tui._continuous_axis is None
+ assert (0x03f3, b'') in link.sent
+
+
+def test_terminal_release_focus_and_opposing_keys():
+ from rollingmill.keyrelease import HeldKeys
+ keys = HeldKeys()
+ keys.feed("\x1b[?11u\x1b[57351;1:1u")
+ assert keys.supported and keys.held('X', 1, 1)
+ keys.feed("\x1b[57351;1:2u")
+ assert keys.held('X', 1, 1)
+ keys.feed("\x1b[57351;1:3u")
+ assert not keys.held('X', 1, 1)
+ keys.feed("\x1b[97;1:1u\x1b[122;1:1u")
+ assert not keys.held('Z', 1, 1)
+ keys.feed("\x1b[122;1:3u")
+ assert keys.held('Z', 1, 1)
+ keys.feed("\x1b[O")
+ assert not keys.held('Z', 1, 1)
+
+
+def test_legacy_terminal_does_not_enable_continuous_motion():
+ from rollingmill.keyrelease import HeldKeys
+ keys = HeldKeys()
+ keys.feed("\x1b[Caz")
+ assert not keys.supported
+ assert not keys.held('X', 1, None)
+
+
+def test_arrow_release_uses_csi_direction_encoding():
+ from rollingmill.keyrelease import HeldKeys
+ keys = HeldKeys()
+ keys.feed("\x1b[?11u\x1b[1;1:1C")
+ assert keys.held('X', 1, 1)
+ keys.feed("\x1b[1;1:2C")
+ assert keys.held('X', 1, 1)
+ keys.feed("\x1b[1;1:3C")
+ assert not keys.held('X', 1, 1)
+
+
+def legacy_tui(monkeypatch):
+ from rollingmill.keyrelease import HeldKeys
+ clock = [10.0]
+ monkeypatch.setattr('rollingmill.tui.time.monotonic', lambda: clock[0])
+ link = Link()
+ machine = MDX40A(link)
+ machine.poll = Mock(return_value=_decode_state(IDLE))
+ machine._state = _decode_state(IDLE)
+ tui = TUI(machine, _LogBuffer())
+ tui._held_keys = HeldKeys()
+ tui._pulse_i = 1
+ link.sent.clear()
+ return tui, link, clock
+
+
+def key_at(tui, clock, when, key=curses.KEY_RIGHT):
+ clock[0] = when
+ tui._held_keys.event_time = when
+ tui._handle_key(key)
+
+
+def test_ordinary_terminal_tap_is_one_bounded_step(monkeypatch):
+ tui, link, clock = legacy_tui(monkeypatch)
+ key_at(tui, clock, 10.0)
+ assert not tui._continuous_axis
+ assert link.sent == [(0x04f5, struct.pack('>HH4i', 60, 0, 100, 0, 0, 0))]
+ clock[0] = 10.3
+ tui._update_jog_indicator()
+ tui._service_continuous_jog()
+ assert len(link.sent) == 1
+
+
+def test_repeat_delay_refresh_and_200ms_release_timeout(monkeypatch):
+ tui, link, clock = legacy_tui(monkeypatch)
+ key_at(tui, clock, 10.0)
+ clock[0] = 10.3
+ tui._update_jog_indicator()
+ # Standard keyboard initial-repeat delay, not a 200 ms initial deadline.
+ key_at(tui, clock, 10.6)
+ assert tui._continuous_axis == 'X'
+ count = sum(c == 0x04f5 for c, _ in link.sent)
+ key_at(tui, clock, 10.65)
+ clock[0] = 10.71
+ tui._service_continuous_jog()
+ assert sum(c == 0x04f5 for c, _ in link.sent) == count + 1
+ clock[0] = 10.84
+ tui._service_continuous_jog()
+ assert tui._continuous_axis == 'X'
+ clock[0] = 10.86
+ tui._service_continuous_jog()
+ assert tui._continuous_axis is None
+ assert link.sent[-2:] == [(0x03f3, b''), (0x1109, b'\xff')]
+
+
+def test_escape_cannot_be_undone_by_remaining_repeats(monkeypatch):
+ tui, link, clock = legacy_tui(monkeypatch)
+ key_at(tui, clock, 10.0)
+ clock[0] = 10.3
+ tui._update_jog_indicator()
+ key_at(tui, clock, 10.6)
+ key_at(tui, clock, 10.65, 27)
+ count = sum(c == 0x04f5 for c, _ in link.sent)
+ for when in (10.7, 10.75, 10.8, 10.85):
+ key_at(tui, clock, when)
+ assert tui._continuous_axis is None
+ assert sum(c == 0x04f5 for c, _ in link.sent) == count
+ key_at(tui, clock, 11.2)
+ assert sum(c == 0x04f5 for c, _ in link.sent) == count + 1
+ assert tui._continuous_axis is None # A new press is a bounded tap first.
+
+
+def test_old_buffered_repeat_cannot_renew_motion(monkeypatch):
+ tui, link, clock = legacy_tui(monkeypatch)
+ key_at(tui, clock, 10.0)
+ clock[0] = 10.3
+ tui._update_jog_indicator()
+ key_at(tui, clock, 10.6)
+ clock[0] = 10.85
+ tui._held_keys.event_time = 10.64
+ tui._handle_key(curses.KEY_RIGHT)
+ tui._service_continuous_jog()
+ assert tui._continuous_axis is None
+
+
+def test_slow_repeat_stops_instead_of_latching_motion(monkeypatch):
+ tui, link, clock = legacy_tui(monkeypatch)
+ key_at(tui, clock, 10.0)
+ clock[0] = 10.3
+ tui._update_jog_indicator()
+ key_at(tui, clock, 10.6)
+ clock[0] = 10.81
+ tui._service_continuous_jog()
+ assert tui._continuous_axis is None
+
+
+def test_step_mode_still_uses_selected_distance(monkeypatch):
+ tui, link, clock = legacy_tui(monkeypatch)
+ tui._continuous = False
+ tui._pulse_i = 1
+ key_at(tui, clock, 10.0)
+ assert link.sent == [(0x04f5, struct.pack('>HH4i', 60, 0, 100, 0, 0, 0))]
+
+
+@pytest.mark.parametrize('index,pulse,feed', [(0, 0.01, 6), (1, 0.1, 60), (2, 1.0, 600), (3, 5.0, 3000)])
+@pytest.mark.parametrize('axis,sign', [('X', 1), ('Z', -1), ('A', 1)])
+def test_selected_pulse_exact_payload_for_tap_and_constant_hold(monkeypatch, index, pulse, feed, axis, sign):
+ tui, link, clock = legacy_tui(monkeypatch)
+ tui._pulse_i = index
+ tui._start_jog(axis, sign)
+ vector = [0, 0, 0, 0]
+ vector['XYZA'.index(axis)] = sign * round(pulse * 1000)
+ packet = (0x04f5, struct.pack('>HH4i', feed, 0, *vector))
+ assert link.sent == [packet]
+ clock[0] = 10.3
+ tui._update_jog_indicator()
+ clock[0] = 10.6
+ tui._start_jog(axis, sign)
+ assert tui._continuous_axis == axis
+ # All pulses, including the first held pulse, have exactly the same feed.
+ for tick in range(1, 50):
+ clock[0] = 10.6 + tick * 0.11
+ tui._jog_last_fresh = clock[0]
+ tui._start_jog(axis, sign) # Renew standard-terminal input lease.
+ tui._service_continuous_jog()
+ pulses = [sent for sent in link.sent if sent[0] == 0x04f5]
+ assert len(pulses) == 51
+ assert all(sent == packet for sent in pulses)
+
+
+def test_f_cycles_only_the_four_pulse_feed_pairs(monkeypatch):
+ from rollingmill.tui import JOG_PULSES
+ tui, _, _ = legacy_tui(monkeypatch)
+ tui._pulse_i = 0
+ values = []
+ for _ in range(4):
+ values.append(JOG_PULSES[tui._pulse_i])
+ tui._handle_key(ord('f'))
+ assert values == [(0.01, 6), (0.1, 60), (1.0, 600), (5.0, 3000)]
+ assert tui._pulse_i == 0
+ tui._handle_key(ord('5'))
+ assert tui._pulse_i == 0 # Previous independent step selector removed.
+
+
+def test_protocol_tap_also_uses_selected_pulse(monkeypatch):
+ tui, link, clock = legacy_tui(monkeypatch)
+ tui._pulse_i = 3
+ tui._held_keys.feed("\x1b[?11u\x1b[97;1:1u")
+ tui._held_keys._pop()
+ tui._handle_key(ord('a'))
+ assert tui._continuous_axis is None
+ assert link.sent == [(0x04f5, struct.pack('>HH4i', 3000, 0, 0, 0, 5000, 0))]
+ clock[0] = 10.3
+ tui._update_jog_indicator()
+ tui._held_keys.feed("\x1b[97;1:2u")
+ tui._held_keys._pop()
+ tui._handle_key(ord('a'))
+ assert tui._continuous_axis == 'Z'
+ tui._held_keys.feed("\x1b[97;1:3u")
+ tui._service_continuous_jog()
+ assert tui._continuous_axis is None
+
+
+@pytest.mark.parametrize('slot', range(9))
+def test_numeric_position_uses_selected_frame_and_blank_axes_are_unchanged(slot):
+ link = Link()
+ machine = MDX40A(link)
+ machine.get_wcs_origin = Mock(return_value=(11.0, 22.0, 33.0, 44.0))
+ machine.poll = Mock(return_value=_decode_state(IDLE))
+ machine.set_active_wcs(slot)
+ link.sent.clear()
+ machine.move_to_position(x=5.0, z=-2.0, speed=1800)
+ current = _decode_state(IDLE)
+ offset = (0, 0, 0, 0) if slot == 0 else (11000, 22000, 33000, 44000)
+ expected = (5000 + offset[0], round(current.y_mm * 1000),
+ -2000 + offset[2], round(current.a_deg * 1000))
+ assert (0x04f7, struct.pack('>HH4i', 1800, 0xffff, *expected)) in link.sent
+
+
+def test_failed_coordinate_activation_does_not_substitute_machine_origin():
+ machine = MDX40A(Link())
+ machine._active_wcs = 1
+ machine._wcs_offset = (10, 20, 30, 40)
+ machine.get_wcs_origin = Mock(return_value=None)
+ with pytest.raises(ValueError, match='previous coordinate system retained'):
+ machine.set_active_wcs(3)
+ assert machine.active_wcs == 1
+ assert machine.wcs_offset == (10, 20, 30, 40)
+
+
+def test_coordinate_menu_activation_then_move_menu_uses_same_origin():
+ link = Link()
+ machine = MDX40A(link)
+ machine.get_wcs_origin = Mock(side_effect=lambda slot: (slot * 10.0, slot * 20.0, -slot * 5.0, 0.0))
+ machine.poll = Mock(return_value=_decode_state(IDLE))
+ tui = TUI(machine, _LogBuffer())
+ tui._wcs_sel = 4
+ tui._wcs_handle_key(10)
+ assert machine.active_wcs == 4
+ link.sent.clear()
+ targets = dict(tui._move_targets())
+ targets['XY Origin']()
+ state = _decode_state(IDLE)
+ assert (0x04f7, struct.pack('>HH4i', 3000, 0xffff, 40000, 80000,
+ round(state.z_mm * 1000), round(state.a_deg * 1000))) in link.sent
+ machine._absolute_move = None
+ link.sent.clear()
+ targets['Preview / View Position (MCS)']()
+ assert link.sent == [(0x1109, b'\0'), (0x0500, b'\xff\xff'), (0x1109, b'\xff')]
+
+
+def test_absolute_move_keeps_operation_open_until_target_reached(monkeypatch):
+ clock = [10.0]
+ monkeypatch.setattr('rollingmill.machine.time.monotonic', lambda: clock[0])
+ link = Link()
+ machine = MDX40A(link)
+ link.sent.clear()
+ before = _decode_state(IDLE)
+ target = (before.x_mm + 1.0, before.y_mm, before.z_mm, before.a_deg)
+ machine.move_to_machine_pos(*target, speed=0xffff)
+ assert [c for c, _ in link.sent] == [0x03f5, 0x1109, 0x04f7]
+ assert link.sent[0] == (0x03f5, b'\x02')
+ assert struct.unpack('>H', link.sent[-1][1][:2])[0] == 3000
+ machine.poll() # Fresh idle before motion acknowledgement.
+ assert machine.move_active
+ assert [c for c, _ in link.sent] == [0x03f5, 0x1109, 0x04f7]
+ moving = bytearray(IDLE)
+ moving[:4] = (before.flags | 0x04400000).to_bytes(4, 'big')
+ link._responses[0x0100] = bytes(moving)
+ clock[0] += 0.2
+ machine.poll()
+ assert machine.move_active
+ # No idle heartbeat writes or premature 0x03f2/operation exit during motion.
+ assert len(link.sent) == 3
+ done = bytearray(IDLE)
+ done[4:20] = struct.pack('>4i', *(round(v * 1000) for v in target))
+ link._responses[0x0100] = bytes(done)
+ machine.poll()
+ assert not machine.move_active
+ assert link.sent[-3:] == [(0x03f2, b''), (0x03f5, b'\xff'), (0x1109, b'\xff')]
+ assert 'target position confirmed' in machine.move_message
+
+
+def test_absolute_move_that_never_starts_reports_failure(monkeypatch):
+ clock = [10.0]
+ monkeypatch.setattr('rollingmill.machine.time.monotonic', lambda: clock[0])
+ link = Link()
+ machine = MDX40A(link)
+ machine.move_to_machine_pos(0, 0, 0, 0)
+ clock[0] = 12.1
+ machine.poll()
+ assert not machine.move_active
+ assert 'did not start' in machine.move_message
+ assert (0x03f3, b'') in link.sent
+
+
+def test_absolute_move_blocks_keys_and_cancel_waits_for_idle():
+ link = Link()
+ machine = MDX40A(link)
+ machine.move_to_machine_pos(0, 0, 0, 0)
+ moving = bytearray(IDLE)
+ moving[:4] = (int.from_bytes(moving[:4], 'big') | 0x04400000).to_bytes(4, 'big')
+ link._responses[0x0100] = bytes(moving)
+ tui = TUI(machine, _LogBuffer())
+ count = len(link.sent)
+ for key in ('z', 's', 'b', 'c', 'm'):
+ tui._handle_key(ord(key))
+ assert len(link.sent) == count
+ tui._handle_key(27)
+ assert (0x03f3, b'') in link.sent
+ machine.poll()
+ assert machine.move_active
+ link._responses[0x0100] = IDLE
+ machine.poll()
+ assert not machine.move_active
+ assert 'stopped' in machine.move_message
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