11 KiB
IOModbus Firmware Version Display Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Show the IOModbus Device Settings "Firmware Version" value as 641D (100.29) — the stored 4-char hex word plus its human-readable <hi-byte>.<lo-byte> decimal form.
Architecture: A pure codec (format_firmware) converts the raw register word to the display string. The firmware setting cell is tagged once at device-state build time (is_firmware), and the controller's read handler substitutes format_firmware for the normal datatype decode on that cell. RegisterCell.value stays the single source of truth, so the grid (and any downstream consumer) is consistent.
Tech Stack: Python 3.11+, PySide6, pytest / pytest-qt. Tests run headless against the in-memory simulator.
File Structure
cim_suite/modules/iomodbus/protocol/codecs.py— addformat_firmware(raw)pure function.cim_suite/modules/iomodbus/domain/models.py— addFIRMWARE_LABELconstant,is_firmwarefield onRegisterCell, and tag the cell inDeviceState._build.cim_suite/modules/iomodbus/domain/controller.py— in_apply_read, override the firmware cell's value withformat_firmware.tests/iomodbus/test_codecs.py— unit tests forformat_firmware.tests/iomodbus/test_controller.py— integration test against the simulated CS-05.
Reference facts (verified):
- The simulator seeds CS-05 (new) register 0 with the device header version
0x640F→ expected display640F (100.15). - The CS-05 (new) firmware row is
Firmware Version~0|R|1|S||(register 0, read-only). It is read on the first poll sweep becauseselect_devicesets_init_all = True. - Existing test helpers in
tests/iomodbus/test_controller.py:_wired(catalog),_pump(ctrl, n),_select_cs05(ctrl, catalog). Thecatalogfixture is intests/iomodbus/conftest.py.
Task 1: format_firmware codec
Files:
-
Modify:
cim_suite/modules/iomodbus/protocol/codecs.py -
Test:
tests/iomodbus/test_codecs.py -
Step 1: Write the failing test
Add to the end of tests/iomodbus/test_codecs.py:
# --- firmware version ------------------------------------------------------ #
def test_format_firmware():
assert c.format_firmware(0x641D) == "641D (100.29)"
assert c.format_firmware(0x6405) == "6405 (100.05)" # minor zero-padded to 2
assert c.format_firmware(0x640F) == "640F (100.15)"
assert c.format_firmware(0x64FF) == "64FF (100.255)" # minor > 99 keeps all digits
assert c.format_firmware(0x0A01) == "0A01 (10.01)" # raw zero-padded to 4, upper
assert c.format_firmware(0x0000) == "0000 (0.00)"
def test_format_firmware_masks_to_16_bits():
assert c.format_firmware(0x1641D) == "641D (100.29)" # high bits ignored
- Step 2: Run test to verify it fails
Run: .venv\Scripts\python -m pytest tests/iomodbus/test_codecs.py::test_format_firmware -q
Expected: FAIL with AttributeError: module ... has no attribute 'format_firmware'
- Step 3: Write minimal implementation
In cim_suite/modules/iomodbus/protocol/codecs.py, add this function at the end of the file (after _to_float):
# --------------------------------------------------------------------------- #
# Firmware version (read-only): one 16-bit word shown as raw hex + hi.lo decimals.
# The catalog declares this row with inconsistent datatype/dp across devices, so we
# format it directly from the raw word instead of via decode_value. The high byte is
# the major version, the low byte the minor (e.g. 0x641D -> "641D (100.29)").
# --------------------------------------------------------------------------- #
def format_firmware(raw: int) -> str:
"""Firmware register word -> '641D (100.29)': raw hex + hi.lo byte decimals."""
raw &= 0xFFFF
return f"{raw:04X} ({(raw >> 8) & 0xFF}.{raw & 0xFF:02d})"
- Step 4: Run test to verify it passes
Run: .venv\Scripts\python -m pytest tests/iomodbus/test_codecs.py -q
Expected: PASS (all codec tests, including the two new ones)
- Step 5: Commit
git add cim_suite/modules/iomodbus/protocol/codecs.py tests/iomodbus/test_codecs.py
git commit -m "feat(iomodbus): add format_firmware codec (641D -> '641D (100.29)')"
Task 2: Tag the firmware cell in the device state
Files:
-
Modify:
cim_suite/modules/iomodbus/domain/models.py -
Test:
tests/iomodbus/test_controller.py(a model-level unit test; no controller needed) -
Step 1: Write the failing test
Add to the end of tests/iomodbus/test_controller.py:
def test_device_state_tags_firmware_cell(catalog):
from cim_suite.modules.iomodbus.domain.models import DeviceState
cs05 = next(d for d in catalog.devices if d.description == "CS-05 (new)")
state = DeviceState(address=1, dev=cs05)
fw_row = next(r for r in state.settings if r.label == "Firmware Version")
assert fw_row.cell is not None
assert fw_row.cell.is_firmware is True
# a different setting must NOT be tagged
addr_row = next(r for r in state.settings if r.label == "Address")
assert addr_row.cell is not None
assert addr_row.cell.is_firmware is False
- Step 2: Run test to verify it fails
Run: .venv\Scripts\python -m pytest tests/iomodbus/test_controller.py::test_device_state_tags_firmware_cell -q
Expected: FAIL with AttributeError: 'RegisterCell' object has no attribute 'is_firmware'
- Step 3: Write minimal implementation
In cim_suite/modules/iomodbus/domain/models.py:
(a) Add the constant next to the grid constants (after GRID_CHANNELS = 1):
FIRMWARE_LABEL = "Firmware Version" # the one read-only row shown as hex + hi.lo decimals
(b) Add the field to RegisterCell (after the force field):
is_firmware: bool = False # render via codecs.format_firmware, not the datatype decode
(c) In DeviceState._build, tag the cell when the setting label matches. Replace the
settings loop:
for r, sdef in enumerate(self.dev.settings):
cell = None
if sdef.spec is not None:
cell = RegisterCell(sdef.spec, GRID_SETTINGS, r, 1)
self.cells.append(cell)
self.settings.append(SettingRow(sdef.label, cell))
with:
for r, sdef in enumerate(self.dev.settings):
cell = None
if sdef.spec is not None:
cell = RegisterCell(sdef.spec, GRID_SETTINGS, r, 1)
cell.is_firmware = sdef.label == FIRMWARE_LABEL
self.cells.append(cell)
self.settings.append(SettingRow(sdef.label, cell))
- Step 4: Run test to verify it passes
Run: .venv\Scripts\python -m pytest tests/iomodbus/test_controller.py::test_device_state_tags_firmware_cell -q
Expected: PASS
- Step 5: Commit
git add cim_suite/modules/iomodbus/domain/models.py tests/iomodbus/test_controller.py
git commit -m "feat(iomodbus): tag the Firmware Version cell (is_firmware) at build time"
Task 3: Apply the firmware format in the controller
Files:
-
Modify:
cim_suite/modules/iomodbus/domain/controller.py:286-299(_apply_read) -
Test:
tests/iomodbus/test_controller.py -
Step 1: Write the failing test
Add to the end of tests/iomodbus/test_controller.py:
def test_poll_formats_firmware_version(qtbot, catalog):
ctrl, _ = _wired(catalog)
_select_cs05(ctrl, catalog)
fw_row = next(r for r in ctrl.state.settings if r.label == "Firmware Version")
assert fw_row.cell is not None
# simulator seeds register 0 with the CS-05 header version 0x640F
assert fw_row.cell.value == "640F (100.15)"
# a non-firmware setting still decodes normally (regression guard)
addr_row = next(r for r in ctrl.state.settings if r.label == "Address")
assert addr_row.cell.value not in ("", "640F (100.15)")
- Step 2: Run test to verify it fails
Run: .venv\Scripts\python -m pytest tests/iomodbus/test_controller.py::test_poll_formats_firmware_version -q
Expected: FAIL — fw_row.cell.value is the raw datatype decode ("640F"), not "640F (100.15)"
- Step 3: Write minimal implementation
In cim_suite/modules/iomodbus/domain/controller.py, edit _apply_read. Replace:
spec = cell.spec
value = cdc.decode_value(
resp.data, spec.datatype, dp=spec.dp, bits=spec.bits,
bstart=spec.bstart, chars=spec.chars, picklist=spec.picklist,
)
cell.value = value
cell.last_raw = cdc.reg_word(resp.data)
self.cellUpdated.emit(cell.grid, cell.row, cell.col, value)
with:
spec = cell.spec
cell.last_raw = cdc.reg_word(resp.data)
if cell.is_firmware:
value = cdc.format_firmware(cell.last_raw)
else:
value = cdc.decode_value(
resp.data, spec.datatype, dp=spec.dp, bits=spec.bits,
bstart=spec.bstart, chars=spec.chars, picklist=spec.picklist,
)
cell.value = value
self.cellUpdated.emit(cell.grid, cell.row, cell.col, value)
(Note: cell.last_raw is now computed before the branch so format_firmware can use it; the logging block below is unchanged.)
- Step 4: Run test to verify it passes
Run: .venv\Scripts\python -m pytest tests/iomodbus/test_controller.py -q
Expected: PASS (all controller tests, including the new one)
- Step 5: Commit
git add cim_suite/modules/iomodbus/domain/controller.py tests/iomodbus/test_controller.py
git commit -m "feat(iomodbus): show Firmware Version as '641D (100.29)' in Device Settings"
Task 4: Full verification
Files: none (verification only)
- Step 1: Run the full test suite
Run: .venv\Scripts\python -m pytest -q
Expected: PASS (no regressions)
- Step 2: Run the linter
Run: .venv\Scripts\python -m ruff check cim_suite tests
Expected: clean (no errors)
- Step 3: Smoke-test in the simulator (manual, optional)
Run: .venv\Scripts\python -m cim_suite.shell.app --module iomodbus --simulate
Then: scan addresses 1–2, select CS-05 (new), open the Device Settings tab, and
confirm the Firmware Version row reads 640F (100.15).
- Step 4: Commit (only if Steps 1–2 required any fixes)
git add -A
git commit -m "chore(iomodbus): firmware-version display — test/lint fixes"
Notes for the implementer
- This is a read-only display field — there is no encode/write path. Do not add one.
- The firmware word is always a single 16-bit register across every catalog device, so the hi/lo byte split is well-defined.
- Do not touch
resources/IOModbus.txtorregdef.py; the catalog format is unchanged. - Keep
pytestgreen andruffclean — both are part of "done" in this repo.