From b7ef8146ba625888cd2b4e67c97da31eeb50b74d Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sun, 8 Nov 2026 20:04:08 +0000 Subject: [PATCH 1/4] machine: read the command set at connect; add NC Code preferences via one config write The 6-byte GET 0x3106 / SET 0x3107 struct holds the command set (byte 1) and, from the VPanel "NC Code setting" page (FUN_00404ff0 / FUN_00405040), the decimal-point method (byte 2), tool-diameter offset type (byte 3) and optional block (byte 4); bytes 0 and 5 are only round-tripped. MachineConfig decodes it, write_config() changes any of the fields with one read-modify-write (validated, acked and read back), and the config is cached at connect. set_command_set() now delegates to it. Co-Authored-By: Claude Sonnet 5.5 --- docs/sending-nc-code.md | 2 +- rollingmill/machine.py | 171 ++++++++++++++++++++++++++++------- tests/test_machine_config.py | 122 +++++++++++++++++++++++++ 3 files changed, 261 insertions(+), 34 deletions(-) create mode 100644 tests/test_machine_config.py diff --git a/docs/sending-nc-code.md b/docs/sending-nc-code.md index 40a9569..e30aff6 100644 --- a/docs/sending-nc-code.md +++ b/docs/sending-nc-code.md @@ -148,7 +148,7 @@ plus the enable-check `0 < value < 3` at `FUN_00402af0`): Called from `write_ncode_settings` @ `0x00402a20` when the Setup-tab NC/RML selection changes. -Mirrored in `mdx40a/machine.py` as the constants `MDX40A.CMDSET_RML1`, +Mirrored in `rollingmill/machine.py` as the module constants `CMDSET_RML1`, `CMDSET_NC`, `CMDSET_AUTO`. ### Symptom of the wrong mode diff --git a/rollingmill/machine.py b/rollingmill/machine.py index 0e17299..00b8a75 100644 --- a/rollingmill/machine.py +++ b/rollingmill/machine.py @@ -37,6 +37,75 @@ Z0_THICKNESS_MIN_MM = 14.0 Z0_THICKNESS_MAX_MM = 16.0 +# ── Machine config struct (GET 0x3106 / SET 0x3107, 6 bytes) ───────────────── +# RE (VPanel Setup dialog): byte 1 is the command set (radio group 0xFD3-0xFD5 of the +# "Modeling Machine" page); bytes 2-4 are the three radio groups of the "NC Code setting" +# page (dialog 4020, groups 0xFB5 / 0xFB8 / 0xFBD): FUN_00404ff0 loads page fields from +# bytes 3, 2, 4 and FUN_00405040 stores them back. Each byte is the MFC radio index. +# Bytes 0 and 5 are never edited by VPanel, only round-tripped. +CMDSET_RML1 = 0 # "RML-1" +CMDSET_NC = 1 # "NC Code" +CMDSET_AUTO = 2 # "Selected automatically (RML-1/NC Code)" — sniffs the first byte + +OFFSET_TYPE_A = 0 +OFFSET_TYPE_B = 1 + +DECIMAL_CONVENTIONAL = 0 # no decimal point = 1/1000 mm; with one = mm +DECIMAL_CALC_ALL = 1 # "Calculator Method [All]": always mm +DECIMAL_CALC_F = 2 # "Calculator Method [F]" +DECIMAL_CALC_IJKRXYZA = 3 # "Calculator Method [IJKRXYZA]" + +OPTIONAL_BLOCK_ENABLED = 0 +OPTIONAL_BLOCK_DISABLED = 1 + +CMDSET_LABELS = {CMDSET_RML1: "RML-1", CMDSET_NC: "NC Code", + CMDSET_AUTO: "Selected automatically (RML-1/NC Code)"} +OFFSET_TYPE_LABELS = {OFFSET_TYPE_A: "A", OFFSET_TYPE_B: "B"} +DECIMAL_LABELS = {DECIMAL_CONVENTIONAL: "Conventional Method", + DECIMAL_CALC_ALL: "Calculator Method [All]", + DECIMAL_CALC_F: "Calculator Method [F]", + DECIMAL_CALC_IJKRXYZA: "Calculator Method [IJKRXYZA]"} +OPTIONAL_BLOCK_LABELS = {OPTIONAL_BLOCK_ENABLED: "Enabled", OPTIONAL_BLOCK_DISABLED: "Disabled"} + + +@dataclass(frozen=True) +class MachineConfig: + """The 6-byte machine-config struct. `raw` keeps the bytes VPanel never edits.""" + raw: bytes + + @property + def command_set(self) -> int: + return self.raw[1] + + @property + def decimal_method(self) -> int: + return self.raw[2] + + @property + def offset_type(self) -> int: + return self.raw[3] + + @property + def optional_block(self) -> int: + return self.raw[4] + + def with_changes(self, command_set: Optional[int] = None, offset_type: Optional[int] = None, + decimal_method: Optional[int] = None, + optional_block: Optional[int] = None) -> "MachineConfig": + """Return a copy with the given fields replaced; raises ValueError on bad values.""" + for name, value, labels in (("command_set", command_set, CMDSET_LABELS), + ("offset_type", offset_type, OFFSET_TYPE_LABELS), + ("decimal_method", decimal_method, DECIMAL_LABELS), + ("optional_block", optional_block, OPTIONAL_BLOCK_LABELS)): + if value is not None and value not in labels: + raise ValueError(f"{name} must be one of {sorted(labels)}; got {value}") + raw = bytearray(self.raw) + for index, value in ((1, command_set), (2, decimal_method), (3, offset_type), (4, optional_block)): + if value is not None: + raw[index] = value + return MachineConfig(bytes(raw)) + + # WCS slot display labels (index = slot number passed to set_active_wcs / # get_wcs_origin / write_wcs_origin / partial_update_wcs_origin). # Slot 0 is the machine coordinate system; 1 = RML-1 user origin; 2 = EXOFS; @@ -220,6 +289,7 @@ def __init__(self, link: MdxLink): self._spindle_live_speed: Optional[int] = None # latest GET 0x3003 uint32 — current spindle/feed speed self._spindle_target_rpm: int = SPINDLE_RPM_MIN # host-side target RPM; pushed to the mill with SET 0x3006 self._z0_thickness_mm: Optional[float] = None # configured Z0 sensor thickness (GET 0x3900), read at connect + self._config: Optional[MachineConfig] = None # machine-config struct (GET 0x3106), read at connect self._spindle_secs: Optional[int] = None self._active_wcs: int = 0 # 0=MCS, 1-10=WCS1-WCS10 self._wcs_offset: tuple = (0.0, 0.0, 0.0, 0.0) # machine coords of active WCS origin (mm/deg) @@ -1113,8 +1183,7 @@ def _query_axis_config(self) -> Optional[bytes]: """Read the raw 6-byte machine-config struct (Pattern B, GET 0x3106). RE: query_0x3106_6bytes @ 0x0041a350 → dev_trigger_read(0x3106, buf, 6). - Byte[1] is the nc_rml_flags command-set selector — see query_command_set. - Internal helper; pair with SET 0x3107 (send_axis_config) to write back. + Internal helper; see MachineConfig for the byte layout. """ try: data = self.pattern_b_read(0x3106, 6) @@ -1127,29 +1196,72 @@ def _query_axis_config(self) -> Optional[bytes]: log.warning("_query_axis_config failed: %s", e) return None - # Command-set selector values (byte[1] of GET 0x3106 / SET 0x3107). - # RE: Setup dialog radio buttons (control IDs 0xFD3/0xFD4/0xFD5), bound via - # MFC DDX_Radio so the radio index (0/1/2 from top) maps to nc_rml_flags. - # The enable-check at FUN_00402af0 confirms valid range is 0..2. - CMDSET_RML1 = 0 # "RML-1" - CMDSET_NC = 1 # "NC Code" - CMDSET_AUTO = 2 # "Selected automatically (RML-1/NC Code)" — sniffs first byte + def read_config(self) -> Optional[MachineConfig]: + """Read the machine-config struct from the device and refresh the cache.""" + raw = self._query_axis_config() + if raw is None: + return None + self._config = MachineConfig(raw) + return self._config + + @property + def config(self) -> Optional[MachineConfig]: + """Machine-config struct as last read or written, or None if unread.""" + return self._config + + @property + def command_set(self) -> Optional[int]: + """Command set (CMDSET_*) as last read or written, or None if unread.""" + return None if self._config is None else self._config.command_set def query_command_set(self) -> Optional[int]: """Query firmware command-set mode — byte[1] of GET 0x3106. Returns one of CMDSET_RML1 (0), CMDSET_NC (1), CMDSET_AUTO (2), - or None on USB error. See set_command_set() for the value meanings. + or None on USB error. Refreshes the cached config. """ - cfg = self._query_axis_config() - return None if cfg is None else cfg[1] + cfg = self.read_config() + return None if cfg is None else cfg.command_set - def set_command_set(self, mode: int) -> bool: - """Switch firmware command-set mode (read-modify-write of 6-byte config). + def write_config(self, command_set: Optional[int] = None, offset_type: Optional[int] = None, + decimal_method: Optional[int] = None, + optional_block: Optional[int] = None) -> bool: + """Change command set and/or NC Code preferences with one read-modify-write. - RE: write_ncode_settings @ 0x00402a20 — reads the 6-byte config via - query_0x3106, updates byte[1] (nc_rml_flags), and writes back via - send_axis_config_0x3107, which polls ping bit 21 clear after. + RE: write_ncode_settings @ 0x00402a20 — VPanel reads the 6-byte struct, replaces + the edited bytes and writes the whole struct back with send_axis_config_0x3107 + (SET 0x3107), then polls ping bit 21 clear. It only writes if something changed. + The settings persist in the firmware across power cycles. + + Raises ValueError for an invalid value, before anything is written. Returns True + if nothing needed changing, or if the write was acked and the read-back matches. + """ + current = self.read_config() + if current is None: + log.warning("write_config: could not read current config") + return False + wanted = current.with_changes(command_set, offset_type, decimal_method, optional_block) + if wanted == current: + log.info("write_config: already %s, no write needed", current.raw.hex()) + return True + try: + self._link.vend_set(0x3107, wanted.raw) + log.info("write_config: SET 0x3107 %s → %s", current.raw.hex(), wanted.raw.hex()) + except usb.core.USBError as e: + log.warning("write_config: SET 0x3107 failed: %s", e) + return False + acked = self._wait_ping_bit21() + readback = self.read_config() + if not acked: + return False + if readback != wanted: + log.warning("write_config: read-back %s does not match %s", + None if readback is None else readback.raw.hex(), wanted.raw.hex()) + return False + return True + + def set_command_set(self, mode: int) -> bool: + """Switch firmware command-set mode (read-modify-write of the 6-byte config). mode: 0 (CMDSET_RML1) — RML-1 only; G-code blocks are silently dropped. @@ -1158,24 +1270,11 @@ def set_command_set(self, mode: int) -> bool: (`%` or `(` → NC, otherwise RML-1). Returns True on firmware ack (or no-op if already in requested mode). + See write_config() to change the NC Code preferences too. """ - if mode not in (0, 1, 2): + if mode not in CMDSET_LABELS: raise ValueError(f"mode must be 0/1/2 (RML/NC/AUTO); got {mode}") - cfg = self._query_axis_config() - if cfg is None: - log.warning("set_command_set: could not read current config") - return False - if cfg[1] == mode: - log.info("set_command_set: already in mode %d, no write needed", mode) - return True - new_cfg = bytes([cfg[0], mode]) + cfg[2:6] - try: - self._link.vend_set(0x3107, new_cfg) - log.info("set_command_set: SET 0x3107 %s → %s", cfg.hex(), new_cfg.hex()) - except usb.core.USBError as e: - log.warning("set_command_set: SET 0x3107 failed: %s", e) - return False - return self._wait_ping_bit21() + return self.write_config(command_set=mode) def get_rotary_axis_centreline(self) -> Optional[tuple]: """Read stored rotary A-axis centreline from firmware (Pattern B, GET 0x3801). @@ -1330,6 +1429,12 @@ def _mdx_handshake(self) -> None: self.get_XYZ_axis_scaling() self.get_rotary_axis_angle_correction() + # Read the machine-config struct (GET 0x3106): command set + NC Code preferences. + if self.read_config() is not None: + log.info("Command set: %s", CMDSET_LABELS.get(self.command_set, f"unknown ({self.command_set})")) + else: + log.info("Command set read failed") + # Read configured Z0 sensor thickness (GET 0x3900, Pattern B). self._z0_thickness_mm = self.get_z0_sensor_thickness_mm() if self._z0_thickness_mm is not None: diff --git a/tests/test_machine_config.py b/tests/test_machine_config.py new file mode 100644 index 0000000..f905b0d --- /dev/null +++ b/tests/test_machine_config.py @@ -0,0 +1,122 @@ +"""Machine-config struct (GET 0x3106 / SET 0x3107): command set + NC Code preferences. + +Decoded from VP_MDX40A.exe: byte 1 is the command set, bytes 2-4 are the radio groups of +the "NC Code setting" page (decimal method, tool-diameter offset type, optional block), +bytes 0 and 5 are only round-tripped. See machine.MachineConfig. +""" +import pytest +import usb.core + +from rollingmill import machine as M +from rollingmill.machine import MDX40A, MachineConfig +from rollingmill.usb import MdxMockUSB + + +class _Config(MdxMockUSB): + """Mock whose GET 0x3106 follows SET 0x3107, like the firmware (unless it refuses writes).""" + + def __init__(self, raw=bytes([0, 1, 0, 0, 0, 5]), accept=True, fail_write=False): + super().__init__(responses={0x3106: raw}) + self.sent = [] + self.accept = accept + self.fail_write = fail_write + + def vend_set(self, wValue, data=b""): + self.sent.append((wValue, data)) + if wValue == 0x3107: + if self.fail_write: + raise usb.core.USBError("boom") + if self.accept: + self._responses[0x3106] = data + super().vend_set(wValue, data) + + def writes(self): + return [d for w, d in self.sent if w == 0x3107] + + +def test_connect_reads_and_decodes_the_struct(): + m = MDX40A(_Config(bytes([9, 2, 3, 1, 1, 7]))) + cfg = m.config + assert cfg.raw == bytes([9, 2, 3, 1, 1, 7]) + assert (cfg.command_set, cfg.decimal_method, cfg.offset_type, cfg.optional_block) == (2, 3, 1, 1) + assert m.command_set == M.CMDSET_AUTO + + +def test_failed_read_at_connect_leaves_the_cache_empty(): + class _Failing(_Config): + def vend_get(self, wValue, length): + if wValue == 0x0001: + raise usb.core.USBError("boom") + return super().vend_get(wValue, length) + + m = MDX40A(_Failing()) + assert m.config is None and m.command_set is None + assert m.query_command_set() is None + + +def test_write_config_sends_the_whole_struct_changing_only_the_edited_bytes(): + link = _Config(bytes([0, 1, 0, 0, 0, 5])) + m = MDX40A(link) + assert m.write_config(offset_type=M.OFFSET_TYPE_B, decimal_method=M.DECIMAL_CALC_F, + optional_block=M.OPTIONAL_BLOCK_DISABLED) is True + # byte 2 decimal, byte 3 offset type, byte 4 optional block; bytes 0, 1 and 5 untouched + assert link.writes() == [bytes([0, 1, 2, 1, 1, 5])] + assert m.config.raw == bytes([0, 1, 2, 1, 1, 5]) + + +def test_write_config_is_a_single_write_for_several_fields(): + link = _Config() + m = MDX40A(link) + m.write_config(command_set=M.CMDSET_NC, offset_type=M.OFFSET_TYPE_B) + assert len(link.writes()) == 1 + + +def test_unchanged_config_is_not_written(): + link = _Config(bytes([0, 2, 1, 1, 0, 5])) + m = MDX40A(link) + assert m.write_config(command_set=2, decimal_method=1, offset_type=1, optional_block=0) is True + assert m.write_config() is True + assert link.writes() == [] + + +def test_set_command_set_only_changes_byte_1(): + link = _Config(bytes([0, 0, 3, 1, 1, 5])) + m = MDX40A(link) + assert m.set_command_set(M.CMDSET_NC) is True + assert link.writes() == [bytes([0, 1, 3, 1, 1, 5])] + assert m.command_set == M.CMDSET_NC + + +@pytest.mark.parametrize("kwargs", [dict(command_set=3), dict(command_set=-1), dict(decimal_method=4), + dict(offset_type=2), dict(optional_block=2)]) +def test_invalid_values_raise_before_any_write(kwargs): + link = _Config() + m = MDX40A(link) + with pytest.raises(ValueError): + m.write_config(**kwargs) + assert link.writes() == [] + + +def test_set_command_set_rejects_an_invalid_mode(): + link = _Config() + with pytest.raises(ValueError): + MDX40A(link).set_command_set(3) + assert link.writes() == [] + + +def test_a_write_the_machine_does_not_keep_is_reported_as_failed(): + link = _Config(bytes([0, 1, 0, 0, 0, 5]), accept=False) + m = MDX40A(link) + assert m.write_config(offset_type=M.OFFSET_TYPE_B) is False + assert m.config.offset_type == M.OFFSET_TYPE_A # the cache shows what the machine holds + + +def test_usb_failure_on_write_is_reported_as_failed(): + m = MDX40A(_Config(fail_write=True)) + assert m.write_config(command_set=M.CMDSET_AUTO) is False + + +def test_with_changes_leaves_other_bytes_alone(): + cfg = MachineConfig(bytes([7, 1, 2, 1, 0, 9])) + assert cfg.with_changes(optional_block=1).raw == bytes([7, 1, 2, 1, 1, 9]) + assert cfg.with_changes().raw == cfg.raw From b7bc9aafec60b8d5c6dc87d2c15c149b4f3c1402 Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sun, 8 Nov 2026 20:09:59 +0000 Subject: [PATCH 2/4] tui: show the command mode in the header and add the n command mode dialog The header shows "RML-1 mode", "NC mode" or "Auto-mode" from the config read at connect. n opens a dialog for the command set plus the three NC Code preferences (tool-diameter offset type, decimal-point method, optional block): arrows move, Space selects, Enter applies everything changed with one config write, Esc cancels. The NC Code preferences are locked unless the command set is NC Code or automatic, as in VPanel, and n is refused while a job is loaded. The Z-probe dialog now uses the cached command set instead of reading it on open, and also warns that VPanel hides User in NC Code mode. Co-Authored-By: Claude Sonnet 5.5 --- rollingmill/tui.py | 173 ++++++++++++++++++++++++++++-- tests/test_cmd_mode.py | 210 +++++++++++++++++++++++++++++++++++++ tests/test_z0_thickness.py | 14 --- 3 files changed, 376 insertions(+), 21 deletions(-) create mode 100644 tests/test_cmd_mode.py diff --git a/rollingmill/tui.py b/rollingmill/tui.py index 1eeb021..7407e43 100644 --- a/rollingmill/tui.py +++ b/rollingmill/tui.py @@ -21,6 +21,7 @@ m open Move-To picker (presets + User Specify numeric entry) t open tool diameter offsets dialog b Z0 sensor probe dialog (H sets thickness, Enter starts; Esc stops) + n command mode dialog (command set + NC Code preferences) q quit Cut panel (visible when --file is given): @@ -87,6 +88,39 @@ # ── Curses log handler ──────────────────────────────────────────────────────── +# ── Command mode dialog data ───────────────────────────────────────────────── +# (MachineConfig field, group label, [(value, option label), ...], NC Code preference?) +# NC Code preferences are only editable in the NC Code or automatic command set, as in +# VPanel (its "NC Code setting..." button is enabled for modes 1 and 2). +_CMD_GROUPS = [ + ('command_set', 'Command set', list(_machine.CMDSET_LABELS.items()), False), + ('offset_type', 'Offset type', list(_machine.OFFSET_TYPE_LABELS.items()), True), + ('decimal_method', 'Decimal point', list(_machine.DECIMAL_LABELS.items()), True), + ('optional_block', 'Optional block', list(_machine.OPTIONAL_BLOCK_LABELS.items()), True), +] +_CMD_ROWS = [(key, value, label, nc_only) + for key, _group, options, nc_only in _CMD_GROUPS for value, label in options] + + +def _cmd_mode_label(command_set: Optional[int]) -> str: + """Header text for the command set read from the machine.""" + return {_machine.CMDSET_RML1: 'RML-1 mode', _machine.CMDSET_NC: 'NC mode', + _machine.CMDSET_AUTO: 'Auto-mode'}.get(command_set, 'mode ?') + + +def _probe_mode_warning(slot: int, command_set: Optional[int]) -> Optional[str]: + """Why VPanel would not offer a Z-probe of `slot` in this command set, if it wouldn't. + + VPanel's coordinate dropdown: RML-1 offers MCS and User; NC Code offers MCS, G54-G59 and + EXOFS; automatic offers all of them. + """ + if slot >= 2 and command_set == _machine.CMDSET_RML1: + return 'Command set is RML-1; VPanel offers this only in NC/auto mode' + if slot == 1 and command_set == _machine.CMDSET_NC: + return 'Command set is NC Code; VPanel hides User in this mode' + return None + + class _LogBuffer(logging.Handler): """Captures log records into a fixed-size deque for the TUI log pane.""" @@ -147,7 +181,11 @@ def __init__(self, machine: _machine.MDX40A, log_buf: _LogBuffer, self._probe_buf = '' # Z0 thickness edit buffer (mm) self._probe_dirty = False # buffer edited but not yet sent with H self._probe_notice = '' # last result shown in the dialog - self._probe_cmdset = None # command set read when the dialog opened (0 RML-1, 1 NC, 2 auto) + self._cmd_open = False # command mode dialog + self._cmd_sel = 0 # cursor: index into _CMD_ROWS + self._cmd_snapshot = None # MachineConfig as read when the dialog opened / last applied + self._cmd_vals = {} # pending value per MachineConfig field + self._cmd_notice = '' # last result shown in the dialog # ── Entry point ─────────────────────────────────────────────────────────── @@ -240,6 +278,9 @@ def _handle_key(self, key: int) -> None: if self._move_open: self._move_handle_key(key) return + if self._cmd_open: + self._cmd_handle_key(key) + return if key == 27: # Esc — cancel an in-flight jog if self._jog_armed: @@ -332,9 +373,125 @@ def _handle_key(self, key: int) -> None: _machine.wcs_label(self._m.active_wcs)) else: self._probe_open_dialog() + elif key in (ord('n'), ord('N')): + if self._cut_job or self._jog_armed or self._drill_active: + log.warning("Close the NC job and stop jogging/drilling before changing the command mode") + else: + self._cmd_open_dialog() elif key in (ord('p'), ord('P')): self._m.fetch_axis_snapshot() + # ── Command mode dialog ('n' key) ───────────────────────────────────────── + + def _cmd_open_dialog(self) -> None: + cfg = self._m.read_config() + if cfg is None: + log.warning("Command mode: could not read the machine config") + return + self._cmd_load(cfg) + self._cmd_notice = '' + self._cmd_sel = next(i for i, (key, value, _l, _n) in enumerate(_CMD_ROWS) + if key == 'command_set' and value == cfg.command_set) \ + if cfg.command_set in _machine.CMDSET_LABELS else 0 + self._cmd_open = True + + def _cmd_load(self, cfg) -> None: + self._cmd_snapshot = cfg + self._cmd_vals = {key: getattr(cfg, key) for key, _g, _o, _n in _CMD_GROUPS} + + def _cmd_row_enabled(self, row) -> bool: + return not row[3] or self._cmd_vals['command_set'] != _machine.CMDSET_RML1 + + def _cmd_handle_key(self, key: int) -> None: + if key in (27, ord('q'), ord('Q')): + self._cmd_open = False + elif key == curses.KEY_UP: + self._cmd_move(-1) + elif key == curses.KEY_DOWN: + self._cmd_move(+1) + elif key in (32, curses.KEY_RIGHT): # Space / → — select this option + field, value, _label, _nc = _CMD_ROWS[self._cmd_sel] + self._cmd_vals[field] = value + self._cmd_notice = '' + elif key in (10, 13): # Enter — apply + self._cmd_apply() + + def _cmd_move(self, step: int) -> None: + i = self._cmd_sel + while 0 <= i + step < len(_CMD_ROWS): + i += step + if self._cmd_row_enabled(_CMD_ROWS[i]): + self._cmd_sel = i + return + + def _cmd_apply(self) -> None: + snap = self._cmd_snapshot + changes = {key: value for key, value in self._cmd_vals.items() if value != getattr(snap, key)} + if not changes: + self._cmd_notice = 'No changes to apply' + return + try: + ok = self._m.write_config(**changes) + except ValueError as exc: + self._cmd_notice = str(exc) + return + self.annotate(f"KEY n command mode {changes} ok={ok}") + if self._m.config is not None: + self._cmd_load(self._m.config) # show what the machine now holds + self._cmd_notice = 'Applied' if ok else 'Apply failed: the machine did not accept or confirm the change' + + def _draw_cmd_dialog(self, stdscr: curses.window, rows: int, cols: int) -> None: + CP = curses.color_pair + BOLD = curses.A_BOLD + + dh = 17 + dw = min(66, cols - 2) + if rows < dh + 2 or dw < 62: + self._put(stdscr, 0, 0, "Command mode: enlarge terminal; Esc cancels") + stdscr.refresh() + return + try: + win = curses.newwin(dh, dw, (rows - dh) // 2, (cols - dw) // 2) + except curses.error: + return + + win.erase() + win.box() + self._put(win, 0, 2, ' Command Mode', CP(_CP_HEADER) | BOLD) + + group_label = {key: label for key, label, _o, _n in _CMD_GROUPS} + nc_prefs_enabled = self._cmd_vals['command_set'] != _machine.CMDSET_RML1 + r, previous = 1, None + for i, row in enumerate(_CMD_ROWS): + field, value, label, _nc = row + enabled = self._cmd_row_enabled(row) + if field != previous: + pending = self._cmd_vals[field] != getattr(self._cmd_snapshot, field) + text = group_label[field] + (' *' if pending else '') + self._put(win, r, 2, text, CP(_CP_LABEL) if enabled else CP(_CP_DIM)) + previous = field + chosen = self._cmd_vals[field] == value + if i == self._cmd_sel: + attr = CP(_CP_ACTIVE) | BOLD + elif not enabled: + attr = CP(_CP_DIM) + else: + attr = CP(_CP_VALUE) | (BOLD if chosen else 0) + self._put(win, r, 18, f"{'(•)' if chosen else '( )'} {label}".ljust(dw - 19)[:dw - 19], attr) + r += 1 + + info = ('Stored in the machine; kept across power cycles.' if nc_prefs_enabled + else 'NC Code preferences need the NC Code or automatic command set.') + self._put(win, 12, 2, info[:dw - 4], CP(_CP_DIM)) + if self._cmd_notice: + self._put(win, 13, 2, self._cmd_notice[:dw - 4], CP(_CP_LOG_WARN) | BOLD) + + keys = ' ↑↓ navigate ↵ apply Space select Esc close' + self._put(win, dh - 3, 1, '─' * (dw - 2), CP(_CP_LABEL)) + self._put(win, dh - 2, 1, keys[:dw - 2], CP(_CP_KEYS)) + + win.refresh() + # ── Z0 sensor probe dialog ('b' key) ────────────────────────────────────── def _probe_open_dialog(self) -> None: @@ -342,7 +499,6 @@ def _probe_open_dialog(self) -> None: self._probe_buf = f'{thickness:.3f}' if thickness is not None else '' self._probe_dirty = False self._probe_notice = '' - self._probe_cmdset = self._m.query_command_set() self._probe_confirm = True def _probe_dialog_key(self, key: int) -> None: @@ -542,6 +698,9 @@ def _draw(self, stdscr: curses.window) -> None: if self._move_open: self._draw_move_dialog(stdscr, rows, cols) + if self._cmd_open: + self._draw_cmd_dialog(stdscr, rows, cols) + if self._probe_confirm: self._draw_probe_dialog(stdscr, rows, cols) @@ -575,9 +734,9 @@ def _draw_probe_dialog(self, stdscr: curses.window, rows: int, cols: int) -> Non slot = self._m.active_wcs self._put(win, 5, 2, 'Target:', CP(_CP_LABEL)) self._put(win, 5, 10, _machine.wcs_label(slot), CP(_CP_VALUE) | BOLD) - if slot >= 2 and self._probe_cmdset == _machine.MDX40A.CMDSET_RML1: - self._put(win, 6, 2, 'Command set is RML-1; VPanel only offers this in NC/auto mode'[:dw - 4], - CP(_CP_LOG_WARN)) + warning = _probe_mode_warning(slot, self._m.command_set) + if warning: + self._put(win, 6, 2, warning[:dw - 4], CP(_CP_LOG_WARN)) stored = self._m.z0_sensor_thickness_mm stored_str = f'{stored:.3f} mm' if stored is not None else '?' @@ -609,7 +768,7 @@ def _draw_state(self, win: curses.window, height: int, cols: int) -> None: 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 │ {speed_lbl} │ XYZ {lin_lbl}mm A {rot_lbl}° │ {wcs_lbl} │ {rotary_lbl} │ {_cmd_mode_label(self._m.command_set)} " self._put(win, 0, 0, title.ljust(cols), CP(_CP_HEADER) | BOLD) if height < 3: @@ -704,7 +863,7 @@ def _draw_state(self, win: curses.window, height: int, cols: int) -> None: if ref_row > row + 1: key_lines = [ ' jog: ←→ X ↑↓ Y a/z Z [] A f speed (vslow/slow/medium/fast) 1-5 step', - ' s spindle <> RPM d A-drill -/+ override% c coords m move-to t tools b Z-probe q quit', + ' s spindle <> RPM d A-drill -/+ override% c coords m move-to t tools b Z-probe n cmd-mode q quit', ] for i, line in enumerate(key_lines): r = ref_row + i diff --git a/tests/test_cmd_mode.py b/tests/test_cmd_mode.py new file mode 100644 index 0000000..9d712db --- /dev/null +++ b/tests/test_cmd_mode.py @@ -0,0 +1,210 @@ +"""The command mode dialog ('n'), the header label and the cached-mode probe warning.""" +import logging + +import pytest +import usb.core + +from rollingmill import machine as M +from rollingmill.machine import MDX40A +from rollingmill.tui import _CMD_ROWS, TUI, _cmd_mode_label, _LogBuffer, _probe_mode_warning +from rollingmill.usb import MdxMockUSB + +KEY_UP, KEY_DOWN, KEY_RIGHT = 259, 258, 261 # curses.KEY_* +SPACE, ENTER, ESC = 32, 10, 27 + + +class _Config(MdxMockUSB): + """Mock whose GET 0x3106 follows SET 0x3107, like the firmware (unless it refuses writes).""" + + def __init__(self, raw=bytes([0, 1, 0, 0, 0, 5]), accept=True): + super().__init__(responses={0x3106: raw}) + self.sent = [] + self.accept = accept + + def vend_set(self, wValue, data=b""): + self.sent.append((wValue, data)) + if wValue == 0x3107 and self.accept: + self._responses[0x3106] = data + super().vend_set(wValue, data) + + def writes(self): + return [d for w, d in self.sent if w == 0x3107] + + +def _tui(raw=bytes([0, 1, 0, 0, 0, 5]), **kw): + link = _Config(raw, **kw) + return TUI(MDX40A(link), _LogBuffer()), link + + +def _row(field, value): + return next(i for i, (f, v, _l, _n) in enumerate(_CMD_ROWS) if (f, v) == (field, value)) + + +def _go_to(tui, field, value): + """Move the cursor to an option row using the arrow keys, as a user would.""" + target = _row(field, value) + while tui._cmd_sel != target: + before = tui._cmd_sel + tui._handle_key(KEY_DOWN if target > tui._cmd_sel else KEY_UP) + assert tui._cmd_sel != before, f"cursor stuck at {before}, cannot reach row {target}" + + +def _select(tui, field, value): + """Move to an option row and select it with Space.""" + _go_to(tui, field, value) + tui._handle_key(SPACE) + + +# ── header label and probe warning ─────────────────────────────────────────── + +@pytest.mark.parametrize("mode,label", [(M.CMDSET_RML1, "RML-1 mode"), (M.CMDSET_NC, "NC mode"), + (M.CMDSET_AUTO, "Auto-mode"), (None, "mode ?"), (7, "mode ?")]) +def test_header_label(mode, label): + assert _cmd_mode_label(mode) == label + + +@pytest.mark.parametrize("slot,mode,warns", [ + (1, M.CMDSET_RML1, False), (1, M.CMDSET_AUTO, False), (1, M.CMDSET_NC, True), # VPanel hides User in NC + (2, M.CMDSET_RML1, True), (3, M.CMDSET_RML1, True), (8, M.CMDSET_RML1, True), # ...and G54-G59/EXOFS in RML-1 + (3, M.CMDSET_NC, False), (8, M.CMDSET_AUTO, False), (2, M.CMDSET_NC, False), + (1, None, False), (3, None, False), # unknown: no claim +]) +def test_probe_mode_warning(slot, mode, warns): + assert (_probe_mode_warning(slot, mode) is not None) is warns + + +def test_probe_dialog_uses_the_cached_command_set_without_reading_it_again(): + tui, link = _tui() + tui._m.set_active_wcs(1) + link.sent.clear() + tui._handle_key(ord('b')) + assert tui._probe_confirm + assert (0x3106, b"") not in link.sent + + +# ── opening ────────────────────────────────────────────────────────────────── + +def test_n_opens_with_the_machine_values_and_the_cursor_on_the_current_command_set(): + tui, _ = _tui(bytes([0, 2, 3, 1, 1, 5])) + tui._handle_key(ord('n')) + assert tui._cmd_open + assert tui._cmd_vals == {'command_set': 2, 'offset_type': 1, 'decimal_method': 3, 'optional_block': 1} + assert _CMD_ROWS[tui._cmd_sel][:2] == ('command_set', M.CMDSET_AUTO) + + +def test_n_is_refused_while_a_job_is_loaded(caplog): + tui, link = _tui() + tui._cut_job = object() + with caplog.at_level(logging.WARNING): + tui._handle_key(ord('n')) + assert not tui._cmd_open + assert any(r.name == "rollingmill.tui" and "command mode" in r.getMessage() for r in caplog.records) + + +def test_n_does_not_open_when_the_config_cannot_be_read(caplog): + class _Failing(_Config): + def vend_get(self, wValue, length): + if wValue == 0x0001 and self.fail: + raise usb.core.USBError("boom") + return super().vend_get(wValue, length) + + link = _Failing() + link.fail = False + tui = TUI(MDX40A(link), _LogBuffer()) + link.fail = True + with caplog.at_level(logging.WARNING): + tui._handle_key(ord('n')) + assert not tui._cmd_open + assert any("could not read" in r.getMessage() for r in caplog.records) + + +# ── navigation: NC Code preferences follow the command set ─────────────────── + +def test_nc_preferences_are_skipped_by_the_cursor_in_rml1_mode(): + tui, _ = _tui(bytes([0, 0, 0, 0, 0, 5])) # RML-1 + tui._handle_key(ord('n')) + tui._handle_key(KEY_DOWN) + tui._handle_key(KEY_DOWN) + tui._handle_key(KEY_DOWN) # past the last command-set row: nowhere to go + assert _CMD_ROWS[tui._cmd_sel][0] == 'command_set' + + +def test_choosing_nc_code_unlocks_the_preferences(): + tui, _ = _tui(bytes([0, 0, 0, 0, 0, 5])) + tui._handle_key(ord('n')) + _go_to(tui, 'command_set', M.CMDSET_NC) + tui._handle_key(SPACE) + _go_to(tui, 'decimal_method', M.DECIMAL_CALC_F) # reachable now + assert _CMD_ROWS[tui._cmd_sel][:2] == ('decimal_method', M.DECIMAL_CALC_F) + + +def test_space_and_right_arrow_select_but_do_not_write(): + tui, link = _tui(bytes([0, 1, 0, 0, 0, 5])) + tui._handle_key(ord('n')) + link.sent.clear() + _go_to(tui, 'offset_type', M.OFFSET_TYPE_B) + tui._handle_key(KEY_RIGHT) + assert tui._cmd_vals['offset_type'] == M.OFFSET_TYPE_B + _go_to(tui, 'optional_block', M.OPTIONAL_BLOCK_DISABLED) + tui._handle_key(SPACE) + assert tui._cmd_vals['optional_block'] == M.OPTIONAL_BLOCK_DISABLED + assert link.writes() == [] + + +# ── applying ───────────────────────────────────────────────────────────────── + +def test_enter_writes_the_changed_fields_in_one_set_and_reloads(): + tui, link = _tui(bytes([0, 1, 0, 0, 0, 5])) # NC Code, all defaults + tui._handle_key(ord('n')) + _select(tui, 'offset_type', M.OFFSET_TYPE_B) + _select(tui, 'decimal_method', M.DECIMAL_CALC_IJKRXYZA) + _select(tui, 'optional_block', M.OPTIONAL_BLOCK_DISABLED) + tui._handle_key(ENTER) + assert link.writes() == [bytes([0, 1, 3, 1, 1, 5])] # bytes 2, 3, 4 changed; 0, 1, 5 preserved + assert tui._cmd_notice == 'Applied' + assert tui._cmd_open # stays open, now showing the new values + assert tui._cmd_vals == {'command_set': 1, 'offset_type': 1, 'decimal_method': 3, 'optional_block': 1} + + +def test_switching_command_set_writes_only_byte_1(): + tui, link = _tui(bytes([0, 0, 2, 1, 1, 5])) + tui._handle_key(ord('n')) + _select(tui, 'command_set', M.CMDSET_AUTO) + tui._handle_key(ENTER) + assert link.writes() == [bytes([0, 2, 2, 1, 1, 5])] + assert tui._m.command_set == M.CMDSET_AUTO # header label follows + + +def test_enter_without_changes_writes_nothing(): + tui, link = _tui() + tui._handle_key(ord('n')) + link.sent.clear() + tui._handle_key(ENTER) + assert link.writes() == [] and tui._cmd_notice == 'No changes to apply' + + +def test_esc_closes_without_writing(): + tui, link = _tui() + tui._handle_key(ord('n')) + _select(tui, 'offset_type', M.OFFSET_TYPE_B) + link.sent.clear() + tui._handle_key(ESC) + assert not tui._cmd_open and link.writes() == [] + + +def test_a_write_the_machine_does_not_keep_is_reported_and_the_real_values_shown(): + tui, link = _tui(bytes([0, 1, 0, 0, 0, 5]), accept=False) + tui._handle_key(ord('n')) + _select(tui, 'offset_type', M.OFFSET_TYPE_B) + tui._handle_key(ENTER) + assert tui._cmd_notice.startswith('Apply failed') + assert tui._cmd_vals['offset_type'] == M.OFFSET_TYPE_A + + +def test_other_keys_do_not_leak_through_the_open_dialog(): + tui, link = _tui() + tui._handle_key(ord('n')) + link.sent.clear() + for key in (ord('s'), ord('m'), ord('t'), ord('c'), ord('b'), ord('z')): + tui._handle_key(key) + assert tui._cmd_open and link.sent == [] diff --git a/tests/test_z0_thickness.py b/tests/test_z0_thickness.py index 8729858..28fe09c 100644 --- a/tests/test_z0_thickness.py +++ b/tests/test_z0_thickness.py @@ -140,20 +140,6 @@ def test_b_in_an_unprobeable_workspace_logs_a_warning_instead_of_opening(slot, c assert not tui._probe_confirm and not tui._probe.active assert any("select User, EXOFS or G54-G59" in r.getMessage() for r in caplog.records) assert link.sent == [] - - -@pytest.mark.parametrize("config,expected", [(b"\x00\x00", 0), (b"\x00\x01", 1), (b"\x00\x02", 2)]) -def test_dialog_reads_command_set_when_opened(config, expected): - from rollingmill.tui import TUI, _LogBuffer - link = _Recording() - link._responses[0x3106] = config + b"\x00" * 4 - m = MDX40A(link) - m.set_active_wcs(1) - tui = TUI(m, _LogBuffer()) - tui._handle_key(ord('b')) - assert tui._probe_cmdset == expected - - def test_out_of_range_h_keeps_dialog_and_writes_nothing(): from rollingmill.tui import TUI, _LogBuffer link = _Recording() From cde8fffd3b1e7858537ad6df0fa6989fbc3b87d9 Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sun, 8 Nov 2026 20:09:59 +0000 Subject: [PATCH 3/4] docs: document the machine config struct and the n command mode key Co-Authored-By: Claude Sonnet 5.5 --- README.md | 1 + docs/sending-nc-code.md | 8 ++++++-- docs/usb-protocol.md | 23 +++++++++++++++++++++-- docs/z-probing.md | 7 ++++--- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b7ab28c..96a4d4e 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ The interactive TUI exposes the following keybindings: | c | coordinate systems (activate / move-to / overwrite) | | m | Move-To picker (presets + User Specify numeric entry) | | t | tool diameter offsets | +| n | command mode: RML-1 / NC Code / automatic, plus the NC Code preferences (tool-diameter offset type, decimal-point method, optional block); Space selects, Enter applies | | b | Z0 sensor probe dialog (sets the Z origin of User, EXOFS or G54–G59 using probe) | | q | quit | diff --git a/docs/sending-nc-code.md b/docs/sending-nc-code.md index e30aff6..9547415 100644 --- a/docs/sending-nc-code.md +++ b/docs/sending-nc-code.md @@ -174,8 +174,12 @@ if (cfg.nc_rml_flags != desired) { } ``` -Mirrored in `mdx40a/machine.py` as `MDX40A.set_command_set(mode)`. The setting -is persisted by the firmware across power cycles. +Mirrored in `rollingmill/machine.py` as `MDX40A.set_command_set(mode)`, or `MDX40A.write_config(...)` +which changes the command set and the NC Code preferences (decimal-point method, tool-diameter +offset type, optional block) in one write; the same 6 bytes carry both, see +[usb-protocol.md](usb-protocol.md#machine-config-struct-0x3106--0x3107). The setting +is persisted by the firmware across power cycles. The TUI's `n` key edits it, and the header shows +the command set read at connect. --- diff --git a/docs/usb-protocol.md b/docs/usb-protocol.md index 077004f..ad99600 100644 --- a/docs/usb-protocol.md +++ b/docs/usb-protocol.md @@ -100,7 +100,7 @@ See [z-probing.md](z-probing.md) for the `SET 0x3902` operation that uses the z- | wValue | Name | Response | Notes | |--------|------|----------|-------| -| `0x3106` | `query_0x3106_6bytes` | 6 bytes | Machine-config struct. `byte[1]` is the `nc_rml_flags` command-set selector: `0` = RML-1 only, `1` = NC Code only, `2` = Selected automatically (RML-1/NC). Read-modify-write paired with SET `0x3107`. See [sending-nc-code.md](sending-nc-code.md#command-set-selection-rml-1-vs-nc). | +| `0x3106` | `query_0x3106_6bytes` | 6 bytes | Machine-config struct: command set and NC Code preferences, see [Machine config struct](#machine-config-struct-0x3106--0x3107). Read-modify-write paired with SET `0x3107`. See [sending-nc-code.md](sending-nc-code.md#command-set-selection-rml-1-vs-nc). | | `0x346b`–`0x3472` | `query_indexed_0x346a` (index 1–8) | 4 bytes each | Read tool diameter offsets (8 slots) | | `0x3700` | `get_uint32_0x3700` | 4 bytes | Single int32 - unknown purpose read 0x3700 write 0x3701, related to rotary axis/jig detect | | `0x3801` | `get_rotary_axis_centreline_0x3801` | 12 bytes | 3×uint32 = stored rotary A-axis centreline `[X, Y, Z]` in 1/1000 mm. Only `Y, Z` define the line (X is "along" the rotation axis, so its value is informational only). Written by the jig-detect routine via `SET 0x3803`. Read by the "Current Jig" indicator in the main panel — see [rotary-jig-alignment.md](rotary-jig-alignment.md#current-jig-indicator). | @@ -150,6 +150,25 @@ Payload layouts and dispatch detail live in [move-commands.md](move-commands.md) See [z-probing.md](z-probing.md) for the `SET 0x3902` operation that uses the z-origin sensor. +### Machine config struct (`0x3106` / `0x3107`) + +The 6 bytes read by GET `0x3106` and written back whole by SET `0x3107`. Each edited byte is the +MFC radio index of a VPanel control, from the Setup dialog's "Modeling Machine" page (command set) +and its "NC Code setting..." page (dialog resource 4020; `FUN_00404ff0` loads the page from bytes +3, 2, 4 and `FUN_00405040` stores them back). VPanel writes only when something changed, and +only enables the NC Code page for the NC Code and automatic command sets. + +| Byte | Meaning | Values | +|------|---------|--------| +| 0 | status | not edited; round-tripped | +| 1 | command set (radios `0xFD3`–`0xFD5`) | `0` RML-1, `1` NC Code, `2` selected automatically | +| 2 | values with decimal point (radios `0xFB8`–`0xFBB`) | `0` Conventional Method, `1` Calculator Method [All], `2` Calculator Method [F], `3` Calculator Method [IJKRXYZA] | +| 3 | tool-diameter offset type (radios `0xFB5`, `0xFB6`) | `0` = A, `1` = B | +| 4 | optional block (radios `0xFBD`, `0xFBE`) | `0` Enabled, `1` Disabled | +| 5 | unknown | not edited; round-tripped | + +The settings persist in the firmware across power cycles. + ### Feed rate | wValue | Payload | Notes | @@ -174,7 +193,7 @@ See [z-probing.md](z-probing.md) for the `SET 0x3902` operation that uses the z- | wValue | Payload | Notes | |--------|---------|-------| -| `0x3107` | 6 bytes — modified `0x3106` struct | `send_axis_config_0x3107` @ `0x0041a370`. Writes back the 6-byte machine-config struct (read via GET `0x3106`); set `byte[1]` to `0-2` to switch command-set mode RML-1/NC/Auto. Poll ping bit 21 clear after. See [sending-nc-code.md](sending-nc-code.md#command-set-selection-rml-1-vs-nc). | +| `0x3107` | 6 bytes — modified `0x3106` struct | `send_axis_config_0x3107` @ `0x0041a370`. Writes back the 6-byte machine-config struct (read via GET `0x3106`) with the edited bytes replaced: the command set and/or the NC Code preferences, see [Machine config struct](#machine-config-struct-0x3106--0x3107). Poll ping bit 21 clear after. See [sending-nc-code.md](sending-nc-code.md#command-set-selection-rml-1-vs-nc). | | `0x347b`–`0x3482` | `>I` — uint32 (index 1–8) | Write tool diameter offsets; poll ping bit 21 clear after each | | `0x2012` | 2×uint32 (8 bytes) | Write motion limits pair; poll ping bit 21 clear after | | `0x3468` | 1 byte (bool) | Toggle "Optional Block Skip" on/off | diff --git a/docs/z-probing.md b/docs/z-probing.md index 083e359..c01f910 100644 --- a/docs/z-probing.md +++ b/docs/z-probing.md @@ -25,9 +25,10 @@ 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. You cannot start the Z-origin probing for machine coordinates (MCS - slot 0) since that -offset is fixed at zero. VPanel only offers EXOFS and G54–G59 when the command set is NC Code or -automatic, so the dialog warns when the command set is RML-1; whether the firmware -accepts those modes then is not known. The cycle uses the machine's stored sensor +offset is fixed at zero. VPanel offers User only in the RML-1 and automatic command sets, and EXOFS and +G54–G59 only in NC Code and automatic, so the dialog warns when the target is not offered +in the current command set (shown in the header, changed with `n`); whether the firmware +accepts a probe there is not known. The cycle uses the machine's stored sensor thickness (GET 0x3900 / SET 0x3901, the Setup dialog's "Thickness of Z0 Sensor", 1/1000 mm), which is read at connect, shown in the dialog and in the TUI state pane. From ae384d302b84d6928e702c6a2bbc61cf816b4808 Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sun, 8 Nov 2026 20:42:32 +0000 Subject: [PATCH 4/4] cmd-mode: label offset type as "Tool-dia offset paths" Type A/B; print the whole config at startup The dialog group is "Tool-dia offset paths" with options "Type A" and "Type B", and the option column moves right to fit it. The startup banner prints every decoded field of the 6-byte machine config plus the raw bytes, e.g. Machine config: command set NC Code, tool-dia offset paths Type A, decimal point Conventional Method, optional block Enabled [00 01 00 00 00 05] Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- docs/usb-protocol.md | 2 +- rollingmill/machine.py | 19 ++++++++++++---- rollingmill/tui.py | 10 +++++---- tests/test_machine_config.py | 42 ++++++++++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 96a4d4e..62d0e92 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ The interactive TUI exposes the following keybindings: | c | coordinate systems (activate / move-to / overwrite) | | m | Move-To picker (presets + User Specify numeric entry) | | t | tool diameter offsets | -| n | command mode: RML-1 / NC Code / automatic, plus the NC Code preferences (tool-diameter offset type, decimal-point method, optional block); Space selects, Enter applies | +| n | command mode: RML-1 / NC Code / automatic, plus the NC Code preferences (tool-diameter offset paths Type A / Type B, decimal-point method, optional block); Space selects, Enter applies | | b | Z0 sensor probe dialog (sets the Z origin of User, EXOFS or G54–G59 using probe) | | q | quit | diff --git a/docs/usb-protocol.md b/docs/usb-protocol.md index ad99600..f02e18a 100644 --- a/docs/usb-protocol.md +++ b/docs/usb-protocol.md @@ -163,7 +163,7 @@ only enables the NC Code page for the NC Code and automatic command sets. | 0 | status | not edited; round-tripped | | 1 | command set (radios `0xFD3`–`0xFD5`) | `0` RML-1, `1` NC Code, `2` selected automatically | | 2 | values with decimal point (radios `0xFB8`–`0xFBB`) | `0` Conventional Method, `1` Calculator Method [All], `2` Calculator Method [F], `3` Calculator Method [IJKRXYZA] | -| 3 | tool-diameter offset type (radios `0xFB5`, `0xFB6`) | `0` = A, `1` = B | +| 3 | tool-diameter offset type (radios `0xFB5`, `0xFB6`); shown by rollingmill as "Tool-dia offset paths" | `0` = Type A, `1` = Type B | | 4 | optional block (radios `0xFBD`, `0xFBE`) | `0` Enabled, `1` Disabled | | 5 | unknown | not edited; round-tripped | diff --git a/rollingmill/machine.py b/rollingmill/machine.py index 00b8a75..6f57c48 100644 --- a/rollingmill/machine.py +++ b/rollingmill/machine.py @@ -60,7 +60,7 @@ CMDSET_LABELS = {CMDSET_RML1: "RML-1", CMDSET_NC: "NC Code", CMDSET_AUTO: "Selected automatically (RML-1/NC Code)"} -OFFSET_TYPE_LABELS = {OFFSET_TYPE_A: "A", OFFSET_TYPE_B: "B"} +OFFSET_TYPE_LABELS = {OFFSET_TYPE_A: "Type A", OFFSET_TYPE_B: "Type B"} DECIMAL_LABELS = {DECIMAL_CONVENTIONAL: "Conventional Method", DECIMAL_CALC_ALL: "Calculator Method [All]", DECIMAL_CALC_F: "Calculator Method [F]", @@ -89,6 +89,16 @@ def offset_type(self) -> int: def optional_block(self) -> int: return self.raw[4] + def summary(self) -> str: + """One line with every decoded field and the raw bytes, for the startup banner.""" + def label(labels, value): + return labels.get(value, f"unknown ({value})") + return (f"command set {label(CMDSET_LABELS, self.command_set)}, " + f"tool-dia offset paths {label(OFFSET_TYPE_LABELS, self.offset_type)}, " + f"decimal point {label(DECIMAL_LABELS, self.decimal_method)}, " + f"optional block {label(OPTIONAL_BLOCK_LABELS, self.optional_block)} " + f"[{self.raw.hex(' ')}]") + def with_changes(self, command_set: Optional[int] = None, offset_type: Optional[int] = None, decimal_method: Optional[int] = None, optional_block: Optional[int] = None) -> "MachineConfig": @@ -1430,10 +1440,11 @@ def _mdx_handshake(self) -> None: self.get_rotary_axis_angle_correction() # Read the machine-config struct (GET 0x3106): command set + NC Code preferences. - if self.read_config() is not None: - log.info("Command set: %s", CMDSET_LABELS.get(self.command_set, f"unknown ({self.command_set})")) + cfg = self.read_config() + if cfg is not None: + log.info("Machine config: %s", cfg.summary()) else: - log.info("Command set read failed") + log.info("Machine config read failed") # Read configured Z0 sensor thickness (GET 0x3900, Pattern B). self._z0_thickness_mm = self.get_z0_sensor_thickness_mm() diff --git a/rollingmill/tui.py b/rollingmill/tui.py index 7407e43..c443323 100644 --- a/rollingmill/tui.py +++ b/rollingmill/tui.py @@ -94,10 +94,11 @@ # VPanel (its "NC Code setting..." button is enabled for modes 1 and 2). _CMD_GROUPS = [ ('command_set', 'Command set', list(_machine.CMDSET_LABELS.items()), False), - ('offset_type', 'Offset type', list(_machine.OFFSET_TYPE_LABELS.items()), True), + ('offset_type', 'Tool-dia offset paths', list(_machine.OFFSET_TYPE_LABELS.items()), True), ('decimal_method', 'Decimal point', list(_machine.DECIMAL_LABELS.items()), True), ('optional_block', 'Optional block', list(_machine.OPTIONAL_BLOCK_LABELS.items()), True), ] +_CMD_OPT_COL = 26 # option column: after the longest group label plus the ' *' pending mark _CMD_ROWS = [(key, value, label, nc_only) for key, _group, options, nc_only in _CMD_GROUPS for value, label in options] @@ -445,8 +446,8 @@ def _draw_cmd_dialog(self, stdscr: curses.window, rows: int, cols: int) -> None: BOLD = curses.A_BOLD dh = 17 - dw = min(66, cols - 2) - if rows < dh + 2 or dw < 62: + dw = min(70, cols - 2) + if rows < dh + 2 or dw < 70: self._put(stdscr, 0, 0, "Command mode: enlarge terminal; Esc cancels") stdscr.refresh() return @@ -477,7 +478,8 @@ def _draw_cmd_dialog(self, stdscr: curses.window, rows: int, cols: int) -> None: attr = CP(_CP_DIM) else: attr = CP(_CP_VALUE) | (BOLD if chosen else 0) - self._put(win, r, 18, f"{'(•)' if chosen else '( )'} {label}".ljust(dw - 19)[:dw - 19], attr) + width = dw - _CMD_OPT_COL - 1 + self._put(win, r, _CMD_OPT_COL, f"{'(•)' if chosen else '( )'} {label}".ljust(width)[:width], attr) r += 1 info = ('Stored in the machine; kept across power cycles.' if nc_prefs_enabled diff --git a/tests/test_machine_config.py b/tests/test_machine_config.py index f905b0d..2dd4cab 100644 --- a/tests/test_machine_config.py +++ b/tests/test_machine_config.py @@ -4,6 +4,8 @@ the "NC Code setting" page (decimal method, tool-diameter offset type, optional block), bytes 0 and 5 are only round-tripped. See machine.MachineConfig. """ +import logging + import pytest import usb.core @@ -120,3 +122,43 @@ def test_with_changes_leaves_other_bytes_alone(): cfg = MachineConfig(bytes([7, 1, 2, 1, 0, 9])) assert cfg.with_changes(optional_block=1).raw == bytes([7, 1, 2, 1, 1, 9]) assert cfg.with_changes().raw == cfg.raw + + +# ── labels and the startup banner ──────────────────────────────────────────── + +def test_offset_type_is_labelled_type_a_and_type_b(): + assert M.OFFSET_TYPE_LABELS == {M.OFFSET_TYPE_A: "Type A", M.OFFSET_TYPE_B: "Type B"} + + +def test_summary_names_every_field_and_shows_the_raw_bytes(): + cfg = MachineConfig(bytes([0, 1, 2, 1, 1, 5])) + assert cfg.summary() == ("command set NC Code, tool-dia offset paths Type B, " + "decimal point Calculator Method [F], optional block Disabled " + "[00 01 02 01 01 05]") + + +def test_summary_survives_values_the_dialog_does_not_know(): + text = MachineConfig(bytes([0, 9, 7, 3, 5, 255])).summary() + assert "command set unknown (9)" in text and "decimal point unknown (7)" in text + assert "[00 09 07 03 05 ff]" in text + + +def test_startup_banner_prints_the_whole_config(caplog): + with caplog.at_level(logging.INFO, logger="rollingmill.machine"): + MDX40A(_Config(bytes([0, 2, 1, 0, 0, 5]))) + banner = [r.getMessage() for r in caplog.records if r.getMessage().startswith("Machine config:")] + assert banner == ["Machine config: command set Selected automatically (RML-1/NC Code), " + "tool-dia offset paths Type A, decimal point Calculator Method [All], " + "optional block Enabled [00 02 01 00 00 05]"] + + +def test_startup_banner_when_the_config_cannot_be_read(caplog): + class _Failing(_Config): + def vend_get(self, wValue, length): + if wValue == 0x0001: + raise usb.core.USBError("boom") + return super().vend_get(wValue, length) + + with caplog.at_level(logging.INFO, logger="rollingmill.machine"): + MDX40A(_Failing()) + assert any(r.getMessage() == "Machine config read failed" for r in caplog.records)