Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,10 @@ The interactive TUI exposes the following keybindings:
|-----|--------|
| <kbd>←</kbd> / <kbd>→</kbd> | X axis −/+ |
| <kbd>↑</kbd> / <kbd>↓</kbd> | Y axis −/+ |
| <kbd>a</kbd> / <kbd>z</kbd> | Z axis +/− (`a` raises, `z` lowers) |
| <kbd>a</kbd> / <kbd>z</kbd> | Z axis +/− (`a(up)` raises, `z(down)` lowers) |
| <kbd>[</kbd> / <kbd>]</kbd> | A axis −/+ |
| <kbd>Esc</kbd> | cancel an in-flight jog |
| <kbd>f</kbd> | cycle jog speed: vslow / slow / medium / fast |
| <kbd>1</kbd>…<kbd>5</kbd> | 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° |
| <kbd>f</kbd> | cycle pulse/feed: 0.01 mm / 6 mm/min, 0.1 / 60, 1 / 600, 5 / 3000 |

**Spindle**

Expand All @@ -66,9 +65,11 @@ The interactive TUI exposes the following keybindings:

| Key | Action |
|-----|--------|
| <kbd>j</kbd> | hold / fixed-step jog; standard terminals stop 200 ms after repeats cease |
| <kbd>c</kbd> | coordinate systems (activate / move-to / overwrite) |
| <kbd>m</kbd> | Move-To picker (presets + User Specify numeric entry) |
| <kbd>t</kbd> | tool diameter offsets |
| <kbd>b</kbd> | Z0 sensor probing (User/RML-1; Y confirms, Esc stops) |
| <kbd>q</kbd> | quit |

**Cut panel** (only when `--file` is given)
Expand All @@ -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.

Expand Down
48 changes: 48 additions & 0 deletions docs/jog-speeds.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
48 changes: 48 additions & 0 deletions docs/move-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
46 changes: 46 additions & 0 deletions docs/z-probing.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 3 additions & 1 deletion rollingmill/decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
153 changes: 153 additions & 0 deletions rollingmill/keyrelease.py
Original file line number Diff line number Diff line change
@@ -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[<u\x1b[?1004l')
sys.stdout.flush()
self._started = False

def close(self):
self.suspend()
Loading