fix(da07): apply settings writes to the local model optimistically

Found on real hardware (first DA-07 + one pod): every edit reverted within
seconds once the STATUS column populated. The DA-07 never echoes a settings
write back (the legacy VB6 grid WAS the model and kept the edited cell), but
the controller only updated its models from inbound frames - so the periodic
~H/~G/~F frames rebuilt the tabs from the stale model and reverted the edit.
The simulator masked it: it only sends ~H during a refresh, never periodically.

Every controller set_* now applies the value to its local model right after
sending and emits the matching *Changed signal (set_channel_active also emits
devicesChanged for the Devices-tab roll-up; remove_device drops the device and
its channels locally). The station stays the source of truth - the next
Refresh overwrites local state with whatever it actually stored.

Hardware findings are now logged in docs/DA07-FIELD-NOTES.md (newest first),
cross-linked from HARDWARE-VERIFICATION.md and CLAUDE.md. Open follow-ups
recorded there: confirm writes survive a Refresh on hardware, and check
whether DA-12 has the same latent bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 08:58:54 -04:00
parent 3ad55161d5
commit 53ab24284f
7 changed files with 400 additions and 21 deletions

View File

@@ -29,7 +29,12 @@ into it. The controller must both ACK every inbound data frame with `Z1`
(`da07/domain/controller.py::_process`) **and** answer the station's `Z2` idles with
`Z2` (`_handle_poll`; `Z0`=NAK→resend) — miss either and the load stalls partway. This
was hardware-verified 2026-06-03; the simulator models the stream via `handshake=True`.
(A real station also sends an unmodelled `M` frame the controller ACKs past.) The DA-07 VB6
(A real station also sends an unmodelled `M` frame the controller ACKs past.) The DA-07
also **never echoes settings writes**, so every controller `set_*` applies the value to
the local model optimistically (hardware-found 2026-06-12; without this, periodic
`~H`/`~G` frames rebuild the tabs from the stale model and revert every edit). Bugs
found against real DA-07 hardware are logged in `docs/DA07-FIELD-NOTES.md` — read it
before debugging this module. The DA-07 VB6
baseline is at `cim_suite/modules/da07/legacy/` (read-only). Spec:
`docs/superpowers/specs/2026-06-02-da07-service-tool-rebuild-design.md`. Deferred
production features and hardware-verification items are tracked in `docs/BACKLOG.md`

View File

@@ -18,7 +18,7 @@ from cim_suite.core.ui.help import normalize_label
from ..protocol import encoder as enc
from ..protocol import messages as m
from ..protocol.codecs import device_status
from ..protocol.decoder import decode, parse_indicator_states
from ..protocol.decoder import EMPTY_DEVICE_TYPES, decode, parse_indicator_states
from ..protocol.framing import Framer
from .logger import MeasurementLogger
from .models import AlarmIndicatorTable, ChannelTable, DeviceTable, DeviceTypeTable, StationSettings
@@ -30,6 +30,21 @@ from .models import AlarmIndicatorTable, ChannelTable, DeviceTable, DeviceTypeTa
_SERIAL_PREFIX_LABEL = "High 4 bytes of Serial Number"
def _to_int(value: str) -> int:
"""Parse a UI value the way the firmware does (best effort; 0 on garbage)."""
try:
return int(float(value))
except (ValueError, TypeError):
return 0
def _to_float(value: str) -> float | None:
try:
return float(value)
except (ValueError, TypeError):
return None
class StationController(QObject):
configChanged = Signal()
devicesChanged = Signal()
@@ -330,6 +345,10 @@ class StationController(QObject):
def remove_device(self, index: int) -> None:
self._send(enc.remove_device(index))
self.devices.remove(index)
self.channels.remove_device(index)
self.devicesChanged.emit()
self.channelsChanged.emit()
def clear_channel_disables(self) -> None:
"""``~I`` — re-enable every channel the station auto-disabled (ICD §11)."""
@@ -367,31 +386,75 @@ class StationController(QObject):
if self._transport is not None and getattr(self._transport, "is_open", True):
self._send(enc.idle())
# --- settings writes -----------------------------------------------------
# Every setter applies the value to the local model immediately after sending
# ("optimistic apply"). The DA-07 NEVER echoes a settings write back — the
# legacy VB6 grid simply kept the edited cell (the grid WAS the model). Without
# this, the periodic ~H / ~G / ~F frames rebuild the tabs from the stale model
# and visually revert every edit within seconds (found on real hardware
# 2026-06-12). The station stays the source of truth: the next Refresh
# overwrites these with whatever it actually stored. See docs/DA07-FIELD-NOTES.md.
# station settings (Grid0)
def set_station_setting(self, row: int, value: str) -> None:
self._send(enc.set_station_setting(row, value))
line = self.settings.get(row)
if line is not None:
line.value = value
self.settingsChanged.emit()
# device settings (Grid1)
def set_device_type(self, device: int, value: str) -> None:
self._send(enc.set_device_field(device, enc.DEV_TYPE, value))
dtype = _to_int(value)
dev = self.devices.get(device)
if dtype in EMPTY_DEVICE_TYPES:
self.devices.upsert(m.DeviceRecord(device, 0, False, "", "", "", ""))
elif dev is None or not dev.present:
# picking a type on an empty slot adds the pod; the station fills in
# address/serial on the next refresh
self.devices.upsert(m.DeviceRecord(device, dtype, True, "", "", "", ""))
else:
dev.type = dtype
self.devicesChanged.emit()
def _set_device_field(self, device: int, field: int, value: str, attr: str) -> None:
self._send(enc.set_device_field(device, field, value))
dev = self.devices.get(device)
if dev is not None:
setattr(dev, attr, value)
self.devicesChanged.emit()
def set_device_address(self, device: int, value: str) -> None:
self._send(enc.set_device_field(device, enc.DEV_ADDRESS, value))
self._set_device_field(device, enc.DEV_ADDRESS, value, "address")
def set_device_control(self, device: int, value: str) -> None:
self._send(enc.set_device_field(device, enc.DEV_CONTROL, value))
self._set_device_field(device, enc.DEV_CONTROL, value, "control")
def set_device_delay(self, device: int, value: str) -> None:
self._send(enc.set_device_field(device, enc.DEV_DELAY, value))
self._set_device_field(device, enc.DEV_DELAY, value, "delay")
def set_device_serial(self, device: int, value: str) -> None:
value = value.strip()
value = value[-6:] if len(value) > 6 else value.rjust(6, "0")
self._send(enc.set_device_field(device, enc.DEV_SERIAL, value))
self._set_device_field(device, enc.DEV_SERIAL, value, "serial")
# channel settings (Grid2)
def _set_channel_field(
self, device: int, channel: int, field: int, wire_value: str, attr: str, local_value
) -> None:
self._send(enc.set_channel_field(device, channel, field, wire_value))
ch = self.channels.get(device, channel)
if ch is not None:
setattr(ch, attr, local_value)
self.channelsChanged.emit()
def set_channel_active(self, device: int, channel: int, on: bool) -> None:
self._send(enc.set_channel_field(device, channel, enc.CH_ACTIVE, "1" if on else "0"))
self._set_channel_field(
device, channel, enc.CH_ACTIVE, "1" if on else "0", "active", on
)
# the Devices-tab 'Active' column is a roll-up of the pod's channels
self.devicesChanged.emit()
def set_device_active(self, device: int, on: bool) -> None:
"""Activate/deactivate every channel of a pod (legacy MakeAllChansActive).
@@ -404,43 +467,48 @@ class StationController(QObject):
self.set_channel_active(device, ch.channel, on)
def set_channel_name(self, device: int, channel: int, value: str) -> None:
self._send(enc.set_channel_field(device, channel, enc.CH_NAME, value))
self._set_channel_field(device, channel, enc.CH_NAME, value, "name", value)
def set_channel_scale(self, device: int, channel: int, value: str) -> None:
self._send(enc.set_channel_field(device, channel, enc.CH_SCALE, value))
self._set_channel_field(device, channel, enc.CH_SCALE, value, "scale", _to_float(value))
def set_channel_offset(self, device: int, channel: int, value: str) -> None:
self._send(enc.set_channel_field(device, channel, enc.CH_OFFSET, value))
self._set_channel_field(device, channel, enc.CH_OFFSET, value, "offset", _to_float(value))
def set_channel_disp(self, device: int, channel: int, value: str) -> None:
self._send(enc.set_channel_field(device, channel, enc.CH_DISP, value))
self._set_channel_field(device, channel, enc.CH_DISP, value, "disp", value)
def set_channel_calc(self, device: int, channel: int, value: str) -> None:
self._send(enc.set_channel_field(device, channel, enc.CH_CALC, value))
self._set_channel_field(device, channel, enc.CH_CALC, value, "calc", _to_int(value))
def set_channel_alarm_ind(self, device: int, channel: int, value: str) -> None:
self._send(enc.set_channel_field(device, channel, enc.CH_ALARM_IND, value))
self._set_channel_field(device, channel, enc.CH_ALARM_IND, value, "alarm_ind", value)
def _set_limit(self, device: int, channel: int, field: int, value: str) -> None:
def _set_limit(self, device: int, channel: int, field: int, attr: str, value: str) -> None:
value = value.strip() or enc.EMPTY_LIMIT # blank limit -> "NAN" (legacy)
self._send(enc.set_channel_field(device, channel, field, value))
local = None if value == enc.EMPTY_LIMIT else _to_float(value)
self._set_channel_field(device, channel, field, value, attr, local)
def set_channel_lo_alarm(self, device: int, channel: int, value: str) -> None:
self._set_limit(device, channel, enc.CH_LO_ALARM, value)
self._set_limit(device, channel, enc.CH_LO_ALARM, "lo_alarm", value)
def set_channel_lo_warn(self, device: int, channel: int, value: str) -> None:
self._set_limit(device, channel, enc.CH_LO_WARN, value)
self._set_limit(device, channel, enc.CH_LO_WARN, "lo_warn", value)
def set_channel_hi_warn(self, device: int, channel: int, value: str) -> None:
self._set_limit(device, channel, enc.CH_HI_WARN, value)
self._set_limit(device, channel, enc.CH_HI_WARN, "hi_warn", value)
def set_channel_hi_alarm(self, device: int, channel: int, value: str) -> None:
self._set_limit(device, channel, enc.CH_HI_ALARM, value)
self._set_limit(device, channel, enc.CH_HI_ALARM, "hi_alarm", value)
# alarm-indicator settings (Grid3)
def set_alarm_active(self, indicator: int, on: bool) -> None:
# field 0 = active flag (Main.frm Grid3_AfterEdit)
self._send(enc.set_alarm_indicator(indicator, 0, 1 if on else 0))
ind = self.alarms.get(indicator)
if ind is not None:
ind.active = on
self.alarmsChanged.emit()
def set_alarm_address(self, indicator: int, slot: int, value: str) -> None:
# slot is the 1-based device column (Main.frm: i% = Col - 4)
@@ -449,6 +517,10 @@ class StationController(QObject):
except (ValueError, TypeError):
addr = 0
self._send(enc.set_alarm_indicator(indicator, slot, addr))
ind = self.alarms.get(indicator)
if ind is not None and 1 <= slot <= len(ind.addresses):
ind.addresses[slot - 1] = addr
self.alarmsChanged.emit()
def clear_alarm_indicators(self) -> None:
self._send(enc.clear_alarm_indicators())

View File

@@ -46,6 +46,10 @@ class DeviceTable:
def set_status(self, index: int, status: str) -> None:
self._status[index] = status
def remove(self, index: int) -> None:
self._rows.pop(index, None)
self._status.pop(index, None)
def status(self, index: int) -> str:
return self._status.get(index, "")
@@ -100,6 +104,10 @@ class ChannelTable:
if ch is not None:
ch.average = value
def remove_device(self, device: int) -> None:
for key in [k for k in self._rows if k[0] == device]:
del self._rows[key]
def for_device(self, device: int) -> list[m.ChannelRecord]:
rows = [v for (d, _), v in self._rows.items() if d == device]
return sorted(rows, key=lambda c: c.channel)

View File

@@ -13,7 +13,9 @@ from __future__ import annotations
from . import codecs as c
from . import messages as m
_EMPTY_DEVICE_TYPES = (0x00, 0xFF)
# Device-type codes that mean "empty slot" (no pod). Shared with the controller's
# optimistic apply for set_device_type.
EMPTY_DEVICE_TYPES = (0x00, 0xFF)
def _station_value(r: c.Reader, type_code: int) -> str:
@@ -83,7 +85,7 @@ def decode(body: str) -> m.Message | None:
if t == "D":
index = r.byte()
dtype = r.byte()
if dtype in _EMPTY_DEVICE_TYPES:
if dtype in EMPTY_DEVICE_TYPES:
return m.DeviceRecord(index, 0, False, "", "", "", "")
address = r.base1()
delay = r.base1()

93
docs/DA07-FIELD-NOTES.md Normal file
View File

@@ -0,0 +1,93 @@
# DA-07 Hardware Field Notes
A running log of bugs found while debugging the DA-07 module **against real
hardware**, with root causes and fix locations. The simulator can mask whole bug
classes (see each entry's "why the simulator missed it"), so when a tab misbehaves
on hardware but tests are green, **check this file first** — the pattern is
probably already named here.
Companion docs: `HARDWARE-VERIFICATION.md` (the flagged-protocol checklist),
`DA-07 SERVICE-TOOL-ICD.md` (the wire protocol), `VB6-MIGRATION-PLAYBOOK.md`
(general VB6 traps).
---
## 2026-06-12 — Edits revert within seconds once live frames flow ("stale-model rebuild")
**Setup:** first DA-07 with one pod attached. Reported on the Devices tab: the
Active switch toggles, then snaps back within ~12 s — but only *after* the
STATUS column populates. Same on the Channels tab Active column, and (predicted,
same mechanism) on **every editable field** of the Devices / Channels / Station /
Alarm tabs.
**Root cause:** the controller's `set_*` methods wrote the command to the wire
but **never updated the local models**, and the DA-07 protocol has **no settings
echo** — a real station never re-sends a value you just wrote. (The legacy VB6
had no such problem because its grid *was* the model: `Grid2_AfterEdit` sent the
command and the grid simply kept the edited cell.) On real hardware the station
streams periodic frames in the steady state:
| inbound frame | controller signal(s) | tabs that rebuild |
|---|---|---|
| `~H` realtime status | `devicesChanged` + `alarmsChanged` | Devices, Alarm (and Channels' device combo) |
| `~G` inputs / `~F` averages | `channelsChanged` | Channels |
Each signal rebuilds the grid **from the stale model**, visually reverting the
edit. That's why toggles "worked" before STATUS populated: no `~H` yet → no
rebuilds → the checkbox kept its widget-local state (the model was wrong the
whole time).
**Why the simulator missed it:** the sim only sends `~H` once, at the end of a
refresh — never periodically — so nothing ever rebuilt the Devices tab after
load. (Channels-tab live ticks *would* have shown it, but nobody toggled Active
in live sim mode and the tests always called `refresh()` between write and
assert, which re-reads the sim's state and hides the gap.)
**Fix — "optimistic apply" (2026-06-12):** every
controller setter now applies the value to its local model immediately after
sending, and emits the matching `*Changed` signal. The station remains the
source of truth — the next Refresh overwrites local state with whatever the
station actually stored.
- `domain/controller.py` — the whole "settings writes" section (`set_station_setting`,
`_set_device_field`, `set_device_type`, `_set_channel_field`, `_set_limit`,
`set_alarm_*`, `remove_device`).
- `domain/models.py``DeviceTable.remove`, `ChannelTable.remove_device`.
- Regression tests: `tests/da07/test_optimistic_writes.py` (includes UI-level
tests that replay the exact symptom: toggle, then deliver a periodic `~H`/`~G`,
assert no revert).
**The general rule this leaves behind:** *any* outbound DA-07 mutation must
update the local model in the same call, because nothing inbound will. If a new
setter is added and its edit "reverts after a second or two" on hardware, the
optimistic apply was forgotten.
**Consequences / things this changes:**
- The §5.6 "pending → echoed" write-feedback marker on the tabs now resolves on
the *next rebuild* (≈1 s on hardware via `~H`/`~G`), not on a true echo — the
DA-07 simply has no per-write confirmation. A NAK (`Z0`) still triggers a
resend of the last frame only.
- `set_device_active` writes one frame per channel; on a NAK only the **last**
frame is resent (pre-existing limitation, unchanged).
**Open questions for the next hardware session:**
- [ ] Confirm the write actually landed on the station: toggle Active, wait, hit
**Refresh** — the value must survive the full reload (proves the `~D` write
frame itself is accepted, which the revert previously made impossible to
observe). If it does NOT survive, there is a *second* bug in the write
frames themselves (`protocol/encoder.py::set_channel_field` etc.) that was
hidden behind this one.
- [ ] **DA-12 may have the same latent bug.** Its `set_*` methods also send
without updating models (`modules/da12/domain/controller.py`), and its sim
also applies writes without echoing. DA-12 was assumed to re-stream full
`A` sensor records unsolicited (which would refresh config naturally) —
verify on real DA-12 hardware whether edits revert when `C`/`I` value
frames rebuild the Sensors tab. If they do, port the same optimistic-apply
pattern.
---
*(Add new entries above this line, newest first, dated, with: symptom → root
cause → why the sim missed it → fix locations → open questions.)*

View File

@@ -192,6 +192,16 @@ expect the Devices and Channels tabs to populate.
> and still undecoded. The controller still ACKs past any frame the decoder returns
> `None` for, so unmodelled types never stall the load.
> **No settings echo — found and fixed on hardware 2026-06-12.** The DA-07 never
> echoes a settings write back, but the controller only updated its models from
> inbound frames — so on real hardware every edit (Active toggles first noticed)
> visually reverted within seconds when the periodic `~H`/`~G`/`~F` frames rebuilt
> the tabs from the stale model. Fixed by optimistic local apply in every
> controller `set_*`. Full write-up, the general rule, and the open follow-ups
> (incl. "does the same latent bug exist in DA-12?") live in
> **`docs/DA07-FIELD-NOTES.md`** — check that file first when a DA-07 tab
> misbehaves on hardware but tests are green.
The DA-07 wire format differs from DA-12 and the fix-up files are all under
`cim_suite/modules/da07/`:

View File

@@ -0,0 +1,189 @@
"""Regression: DA-07 settings writes must apply to the local model immediately.
Found on real hardware 2026-06-12 (first DA-07 + one attached pod): the DA-07
never echoes a settings write back — the legacy VB6 grid simply kept the edited
cell (the grid WAS the model). Our controller only updated its models from
inbound frames, so every edit lived only in the widget until the next periodic
``~H`` (devicesChanged/alarmsChanged) or ``~G``/``~F`` (channelsChanged) frame
rebuilt the grid from the stale model and visually reverted it within seconds.
The simulator masked this: it only sends ``~H`` during a refresh, never
periodically, so nothing rebuilt the Devices tab after load.
Fix: every controller ``set_*`` applies the value to its model right after
sending (the station stays the source of truth — the next Refresh overwrites).
Full write-up: docs/DA07-FIELD-NOTES.md.
"""
from cim_suite.modules.da07.domain.controller import StationController
from cim_suite.modules.da07.transport.simulator import SimulatedStation
from cim_suite.modules.da07.ui.channels_tab import ChannelsTab
from cim_suite.modules.da07.ui.devices_tab import DevicesTab
from PySide6.QtCore import Qt
def _wired(devices=2, channels=2, capacity=None):
sim = SimulatedStation(devices=devices, channels=channels, capacity=capacity)
ctrl = StationController()
ctrl.attach(sim)
sim.start()
ctrl.refresh()
return ctrl, sim
# --- controller: each write lands in the local model with NO refresh ---------
def test_channel_active_applies_locally(qtbot):
ctrl, _ = _wired(devices=1, channels=2)
ctrl.set_channel_active(0, 0, False)
assert ctrl.channels.get(0, 0).active is False
def test_channel_fields_apply_locally(qtbot):
ctrl, _ = _wired(devices=1, channels=2)
ctrl.set_channel_name(0, 0, "Inlet")
ctrl.set_channel_scale(0, 0, "3.5")
ctrl.set_channel_offset(0, 0, "-0.25")
ctrl.set_channel_disp(0, 0, "2")
ctrl.set_channel_calc(0, 0, "1")
ctrl.set_channel_alarm_ind(0, 0, "1")
ch = ctrl.channels.get(0, 0)
assert ch.name == "Inlet"
assert ch.scale == 3.5
assert ch.offset == -0.25
assert ch.disp == "2"
assert ch.calc == 1
assert ch.alarm_ind == "1"
def test_channel_limits_apply_locally_blank_means_none(qtbot):
ctrl, _ = _wired(devices=1, channels=1)
ctrl.set_channel_lo_alarm(0, 0, "1.5")
ctrl.set_channel_hi_alarm(0, 0, " ") # blank -> "NAN" on the wire -> None locally
ch = ctrl.channels.get(0, 0)
assert ch.lo_alarm == 1.5
assert ch.hi_alarm is None
def test_device_fields_apply_locally(qtbot):
ctrl, _ = _wired(devices=1, channels=1)
ctrl.set_device_address(0, "9")
ctrl.set_device_control(0, "2")
ctrl.set_device_delay(0, "5")
ctrl.set_device_serial(0, "ABC") # normalized to 6 hex digits
dev = ctrl.devices.get(0)
assert dev.address == "9"
assert dev.control == "2"
assert dev.delay == "5"
assert dev.serial == "000ABC"
def test_device_type_change_applies_locally(qtbot):
ctrl, _ = _wired(devices=1, channels=1)
ctrl.set_device_type(0, "5") # CS05 in the sim catalog
assert ctrl.devices.get(0).type == 5
def test_device_type_on_empty_slot_creates_local_record(qtbot):
ctrl, _ = _wired(devices=1, channels=1, capacity=4)
ctrl.set_device_type(2, "1")
dev = ctrl.devices.get(2)
assert dev is not None and dev.present and dev.type == 1
def test_device_type_zero_empties_slot_locally(qtbot):
ctrl, _ = _wired(devices=1, channels=1)
ctrl.set_device_type(0, "0") # "None" in the type dropdown
assert ctrl.devices.get(0).present is False
def test_set_device_active_applies_to_all_channels_locally(qtbot):
ctrl, _ = _wired(devices=2, channels=4)
ctrl.set_device_active(0, False)
assert all(not c.active for c in ctrl.channels.for_device(0))
assert all(c.active for c in ctrl.channels.for_device(1))
def test_station_setting_applies_locally(qtbot):
ctrl, _ = _wired(devices=1, channels=1)
line = ctrl.settings.all()[0]
ctrl.set_station_setting(line.row, "renamed")
assert ctrl.settings.get(line.row).value == "renamed"
def test_alarm_writes_apply_locally(qtbot):
ctrl, _ = _wired(devices=2, channels=1)
ctrl.set_alarm_active(0, False) # seeded active
ctrl.set_alarm_address(0, 1, "7")
ind = ctrl.alarms.get(0)
assert ind.active is False
assert ind.addresses[0] == 7
def test_remove_device_applies_locally(qtbot):
ctrl, _ = _wired(devices=2, channels=2)
ctrl.remove_device(1)
assert {d.index for d in ctrl.devices.present()} == {0}
assert ctrl.channels.for_device(1) == []
# --- controller: writes announce themselves so every view + repo stay live ---
def test_channel_write_emits_channels_and_devices_changed(qtbot):
# devicesChanged too: the Devices-tab Active column is a roll-up of channels.
ctrl, _ = _wired(devices=1, channels=1)
with qtbot.waitSignals([ctrl.channelsChanged, ctrl.devicesChanged], timeout=1000):
ctrl.set_channel_active(0, 0, False)
def test_device_write_emits_devices_changed(qtbot):
ctrl, _ = _wired(devices=1, channels=1)
with qtbot.waitSignal(ctrl.devicesChanged, timeout=1000):
ctrl.set_device_address(0, "3")
def test_station_write_emits_settings_changed(qtbot):
ctrl, _ = _wired(devices=1, channels=1)
row = ctrl.settings.all()[0].row
with qtbot.waitSignal(ctrl.settingsChanged, timeout=1000):
ctrl.set_station_setting(row, "x")
def test_alarm_write_emits_alarms_changed(qtbot):
ctrl, _ = _wired(devices=1, channels=1)
with qtbot.waitSignal(ctrl.alarmsChanged, timeout=1000):
ctrl.set_alarm_active(0, False)
# --- UI: the reported bug — toggles must survive the periodic frames ---------
def test_devices_tab_active_toggle_survives_status_frame(qtbot):
"""The reported symptom: toggle Active, then a periodic ~H status frame
arrives (devicesChanged -> rebuild) — the toggle must NOT revert."""
ctrl, sim = _wired(devices=2, channels=2)
tab = DevicesTab(ctrl)
qtbot.addWidget(tab)
item = tab.table.item(0, 1)
assert item.checkState() == Qt.CheckState.Checked
item.setCheckState(Qt.CheckState.Unchecked) # the user's click
sim._send(sim._p_status()) # the periodic ~H a real station streams
assert tab.table.item(0, 1).checkState() == Qt.CheckState.Unchecked
assert all(not c.active for c in ctrl.channels.for_device(0))
def test_channels_tab_active_toggle_survives_input_frame(qtbot):
"""Same symptom on the Channels tab: a live ~G input frame (channelsChanged
-> rebuild) must not revert a just-toggled Active checkbox."""
ctrl, sim = _wired(devices=1, channels=2)
tab = ChannelsTab(ctrl)
qtbot.addWidget(tab)
target = tab._channels[0]
item = tab.table.item(0, 1)
assert item.checkState() == Qt.CheckState.Checked
item.setCheckState(Qt.CheckState.Unchecked) # the user's click
sim.tick() # live mode: emits ~G input frames
assert tab.table.item(0, 1).checkState() == Qt.CheckState.Unchecked
assert ctrl.channels.get(0, target.channel).active is False