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:
@@ -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`
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
93
docs/DA07-FIELD-NOTES.md
Normal 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 ~1–2 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.)*
|
||||
@@ -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/`:
|
||||
|
||||
|
||||
189
tests/da07/test_optimistic_writes.py
Normal file
189
tests/da07/test_optimistic_writes.py
Normal 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
|
||||
Reference in New Issue
Block a user