feat(iomodbus): device settings migrate to the grouped settings list (spec 5.5)

This commit is contained in:
2026-06-11 15:06:24 -04:00
parent fe62e1982d
commit 9a74b874c3
3 changed files with 190 additions and 15 deletions

View File

@@ -1,26 +1,105 @@
"""Device Settings grid (legacy Grid(0)): Property | Value, editable where writable."""
"""Device Settings as the §5.5 grouped settings list (replaces the legacy Grid(0)).
Structure comes from the selected device's catalog rows: one group headed by the
device description (the catalog defines no semantic groups — inventing them would
invent meaning), then one row per catalog setting in catalog order. Editability is
per-register (``spec.writable``); read-only rows render the RO chip. Pick-list
registers edit as §5.5 choice dropdowns whose stored raw IS the catalog *label* —
``controller.write_cell`` already maps a label through ``codecs.selection`` to the
numeric register value, so the wire path is unchanged. Live poll updates flow through
``SettingsList.set_value`` (which never clobbers an open editor); the forced re-read
after a write is the value confirmation, as on the DA-12/DA-07 station tabs.
"""
from __future__ import annotations
import re
from PySide6.QtWidgets import QVBoxLayout, QWidget
from cim_suite.core.export import Cell, Sheet
from cim_suite.core.ui.copy_menu import attach_copy_menu
from cim_suite.core.ui.kit import SettingsList
from cim_suite.core.ui.theme import Space
from ..domain.models import GRID_SETTINGS, RegisterCell
from .. import help as H
from .register_grid import RegisterGrid
from ..protocol.codecs import parse_picklist
# §8: constraint text moves out of label parens into the faint hint.
_PAREN = re.compile(r"\s*\(([^)]*)\)")
HEADERS = ["Property", "Value"] # export header row
class SettingsTab(RegisterGrid):
class SettingsTab(QWidget):
GRID = GRID_SETTINGS
def __init__(self, controller) -> None:
super().__init__(controller, ["Property", "Value"])
self.set_column_help(H.SETTINGS_COLUMNS)
super().__init__()
self._ctrl = controller
self._cells: dict[str, RegisterCell] = {} # key -> writable register cell
self._nrows = 0
def _collect(self):
rows: list[list[str]] = []
row_cells: list[list[RegisterCell | None]] = []
layout = QVBoxLayout(self)
layout.setContentsMargins(Space.LG, Space.LG, Space.LG, Space.LG)
layout.setSpacing(Space.MD)
self.settings_list = SettingsList(self)
self.settings_list.settingEdited.connect(self._on_setting_edited)
layout.addWidget(self.settings_list)
controller.deviceSelected.connect(self.rebuild)
controller.cellUpdated.connect(self._on_cell)
attach_copy_menu(self.settings_list.table)
self.rebuild()
# --- structure / values ------------------------------------------------------
def rebuild(self) -> None:
self.settings_list.clear()
self._cells = {}
state = self._ctrl.state
self._nrows = 0 if state is None else len(state.settings)
if state is None:
return
self.settings_list.add_group(state.dev.description)
for r, srow in enumerate(state.settings):
self._add_setting_row(str(r), srow)
def _add_setting_row(self, key: str, srow) -> None:
match = _PAREN.search(srow.label)
hint = match.group(1).strip() if match else ""
label = _PAREN.sub("", srow.label).strip() or srow.label
cell = srow.cell
if cell is None or not cell.spec.writable:
self.settings_list.add_setting(key, label, hint=hint, read_only=True)
elif cell.spec.picklist:
labels = [lab for _v, lab in parse_picklist(cell.spec.picklist)]
# raw == display label: write_cell maps the label via codecs.selection.
self.settings_list.add_choice(key, label, {lab: lab for lab in labels}, hint=hint)
self._cells[key] = cell
else:
self.settings_list.add_setting(key, label, hint=hint)
self._cells[key] = cell
if cell is not None:
self.settings_list.set_value(key, cell.value)
def _on_cell(self, grid: int, row: int, col: int, value: str) -> None:
if grid != GRID_SETTINGS or col != 1 or row >= self._nrows:
return
self.settings_list.set_value(str(row), value)
# --- edits -------------------------------------------------------------------
def _on_setting_edited(self, key: str, raw: str) -> None:
cell = self._cells.get(key)
if cell is not None:
self._ctrl.write_cell(cell, raw)
# --- export ------------------------------------------------------------------
def export_sheet(self) -> Sheet | None:
state = self._ctrl.state
rows: list[list[Cell]] = []
if state is not None:
rows.append([Cell(state.dev.description), Cell("")]) # group header row
for srow in state.settings:
value = srow.cell.value if srow.cell is not None else ""
rows.append([srow.label, value])
row_cells.append([None, srow.cell]) # col 0 label, col 1 value
return rows, row_cells
rows.append([Cell(srow.label), Cell(value)])
return Sheet(title="", headers=list(HEADERS), rows=rows)

View File

@@ -0,0 +1,95 @@
"""Device Settings on the §5.5 SettingsList: group, RO, choices, live values."""
from PySide6.QtCore import Qt
from cim_suite.core.ui.kit.settings_list import CHOICES_ROLE, GROUP_ROLE, RO_ROLE
from cim_suite.modules.iomodbus.domain.controller import ModbusController
from cim_suite.modules.iomodbus.transport.simulator import SimulatedModbusBus
from cim_suite.modules.iomodbus.ui.main_window import MainWindow
def _select(catalog, desc="CS-05 (new)"):
sim = SimulatedModbusBus(catalog)
ctrl = ModbusController(catalog)
ctrl.attach(sim)
ctrl.start()
ctrl.scan(1, 2)
for _ in range(40):
ctrl._tick()
dev = next(d for d in ctrl.devices if d.description == desc)
ctrl.select_device(dev)
for _ in range(300):
ctrl._tick()
return ctrl
def test_group_header_is_the_device_description(qtbot, catalog):
ctrl = _select(catalog)
win = MainWindow(ctrl)
qtbot.addWidget(win)
t = win.settings_tab.settings_list.table
assert t.item(0, 0).data(GROUP_ROLE)
assert t.item(0, 0).text() == "CS-05 (new)"
def test_read_only_register_renders_ro(qtbot, catalog):
ctrl = _select(catalog)
win = MainWindow(ctrl)
qtbot.addWidget(win)
tab = win.settings_tab
ro_row = next(
i for i, s in enumerate(ctrl.state.settings)
if s.cell is not None and not s.cell.spec.writable
)
entry = tab.settings_list._rows[str(ro_row)]
value = tab.settings_list.table.item(entry.row, 1)
assert value.data(RO_ROLE)
assert not (value.flags() & Qt.ItemFlag.ItemIsEditable)
def test_edit_writes_through_write_cell(qtbot, catalog):
ctrl = _select(catalog)
win = MainWindow(ctrl)
qtbot.addWidget(win)
tab = win.settings_tab
addr_row = next(i for i, s in enumerate(ctrl.state.settings) if s.label == "Address")
entry = tab.settings_list._rows[str(addr_row)]
tab.settings_list.table.item(entry.row, 1).setText("9") # itemChanged → settingEdited
for _ in range(300):
ctrl._tick()
assert ctrl.state.settings[addr_row].cell.value == "9"
def test_picklist_register_is_a_choice_row(qtbot, catalog):
ctrl = _select(catalog)
win = MainWindow(ctrl)
qtbot.addWidget(win)
tab = win.settings_tab
# CS-05 (new) carries writable pick-list registers (Baud Rate, Power Mode).
pick_rows = [
i for i, s in enumerate(ctrl.state.settings)
if s.cell is not None and s.cell.spec.writable and s.cell.spec.picklist
]
assert pick_rows, "CS-05 (new) should expose writable pick-list settings"
entry = tab.settings_list._rows[str(pick_rows[0])]
item = tab.settings_list.table.item(entry.row, 1)
assert item.data(CHOICES_ROLE) # dropdown labels present; raw == label by design
def test_live_poll_updates_flow_into_values(qtbot, catalog):
ctrl = _select(catalog)
win = MainWindow(ctrl)
qtbot.addWidget(win)
tab = win.settings_tab
addr_row = next(i for i, s in enumerate(ctrl.state.settings) if s.label == "Address")
ctrl.cellUpdated.emit(0, addr_row, 1, "42") # GRID_SETTINGS == 0
assert tab.settings_list.value(str(addr_row)) == "42"
def test_export_sheet_snapshots_all_rows(qtbot, catalog):
ctrl = _select(catalog)
win = MainWindow(ctrl)
qtbot.addWidget(win)
sheet = win.settings_tab.export_sheet()
assert sheet.headers == ["Property", "Value"]
assert len(sheet.rows) == len(ctrl.state.settings) + 1 # + group header row

View File

@@ -66,15 +66,16 @@ def test_select_populates_channels_grid(qtbot, catalog):
assert tab.table.item(0, value_col).text() == "20.0"
def test_settings_grid_editable_writes(qtbot, catalog):
def test_settings_list_editable_writes(qtbot, catalog):
ctrl, _ = _wired(catalog)
win = MainWindow(ctrl)
qtbot.addWidget(win)
_scan_select(ctrl)
tab = win.settings_tab
# Find the "Address" row (writable) and edit it.
# Find the "Address" row (writable) and edit it via the settings list.
addr_row = next(i for i, s in enumerate(ctrl.state.settings) if s.label == "Address")
tab.table.item(addr_row, 1).setText("9")
entry = tab.settings_list._rows[str(addr_row)]
tab.settings_list.table.item(entry.row, 1).setText("9")
_pump(ctrl, 300)
assert ctrl.state.settings[addr_row].cell.value == "9"