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()