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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ The interactive TUI exposes the following keybindings:
| <kbd>c</kbd> | coordinate systems (activate / move-to / overwrite) |
| <kbd>m</kbd> | Move-To picker (presets + User Specify numeric entry) |
| <kbd>t</kbd> | tool diameter offsets |
| <kbd>n</kbd> | 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); <kbd>Space</kbd> selects, <kbd>Enter</kbd> applies |
| <kbd>b</kbd> | Z0 sensor probe dialog (sets the Z origin of User, EXOFS or G54–G59 using probe) |
| <kbd>q</kbd> | quit |

Expand Down
10 changes: 7 additions & 3 deletions docs/sending-nc-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

---

Expand Down
23 changes: 21 additions & 2 deletions docs/usb-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down
7 changes: 4 additions & 3 deletions docs/z-probing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
182 changes: 149 additions & 33 deletions rollingmill/machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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).
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading