Files
cimtechniques-service-suite/docs/superpowers/plans/2026-06-05-iomodbus-firmware-version-display.md

11 KiB
Raw Blame History

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 — add format_firmware(raw) pure function.
  • cim_suite/modules/iomodbus/domain/models.py — add FIRMWARE_LABEL constant, is_firmware field on RegisterCell, and tag the cell in DeviceState._build.
  • cim_suite/modules/iomodbus/domain/controller.py — in _apply_read, override the firmware cell's value with format_firmware.
  • tests/iomodbus/test_codecs.py — unit tests for format_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 display 640F (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 because select_device sets _init_all = True.
  • Existing test helpers in tests/iomodbus/test_controller.py: _wired(catalog), _pump(ctrl, n), _select_cs05(ctrl, catalog). The catalog fixture is in tests/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 12, 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 12 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.txt or regdef.py; the catalog format is unchanged.
  • Keep pytest green and ruff clean — both are part of "done" in this repo.