diff --git a/README.md b/README.md index b7ab28c..62d0e92 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 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/sending-nc-code.md b/docs/sending-nc-code.md index 40a9569..9547415 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 @@ -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..f02e18a 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`); 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 | + +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. diff --git a/rollingmill/machine.py b/rollingmill/machine.py index 0e17299..6f57c48 100644 --- a/rollingmill/machine.py +++ b/rollingmill/machine.py @@ -37,6 +37,85 @@ 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: "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]", + 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 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": + """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 +299,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 +1193,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 +1206,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 +1280,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 +1439,13 @@ 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. + cfg = self.read_config() + if cfg is not None: + log.info("Machine config: %s", cfg.summary()) + else: + 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() if self._z0_thickness_mm is not None: diff --git a/rollingmill/tui.py b/rollingmill/tui.py index 1eeb021..c443323 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,40 @@ # ── 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', '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] + + +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 +182,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 +279,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 +374,126 @@ 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(70, cols - 2) + if rows < dh + 2 or dw < 70: + 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) + 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 + 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 +501,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 +700,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 +736,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 +770,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 +865,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_machine_config.py b/tests/test_machine_config.py new file mode 100644 index 0000000..2dd4cab --- /dev/null +++ b/tests/test_machine_config.py @@ -0,0 +1,164 @@ +"""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 logging + +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 + + +# ── 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) 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()