Second hardware capture (saved as tests/da07/fixtures/capture-2026-06-12-
steady-state.txt, now with "> "-prefixed outbound lines) settles the remaining
protocol questions:
- ~P is a per-channel CT sensor serial (P + device + channel + 16-hex), matching
the E-frame tails byte-for-byte - decoded as ChannelSerial and applied to the
channel model. It is NOT a custom-name echo: a ~D field-11 name write is
Z1-ACKed but never reported back, so channel names are write-only by protocol
(a typed Tag reverts on Refresh, same as the legacy). BL-E5 part 2 closed.
- The ~H realtime layout is verified against 68 real frames: counters, LE
buffered count, LE time, then 16 per-device status nibbles that matched the
live Devices tab (devices 2,3 COM, rest OK). Indicator triples remain
unobserved (no active alarm groups on the test station).
- The write queue is hardware-confirmed: 26 writes drew 25 ACKs with one
observed idle-retransmit recovery; both device toggles survived a Refresh.
UI fix: the Channels tab now refits its columns after a device switch. TableTab
auto-fits only when the row count changes, but switching devices swaps the whole
content at the same row count, leaving Serial/Model sized for the previous pod
(fitted to empty serials, truncating 16-char ones).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified against the repo's first real DA-07 capture (157 frames, saved as
tests/da07/fixtures/capture-2026-06-12-refresh.txt): the ~E payload ends at the
alarm byte, followed only by an optional 8-byte CT sensor serial - there is no
disp field and no name field on the wire. The phantom disp read (inherited from
the VB6, which had the same bug feeding its hidden Disp column) ate the serial's
first byte, producing the reported "Tag column cuts off the first characters"
symptom.
Removed disp everywhere: decoder E branch, ChannelRecord, the Channels-tab Disp
column (later columns shift left one), controller.set_channel_disp, encoder
CH_DISP (ICD §11 has no ~D field 9), the simulator's disp byte, repo snapshot,
and help text. The simulator now emits the wire-faithful E layout (serial-only
tail). The Tag column shows name -> serial -> catalog default, matching legacy
precedence; a written name lives only in the local model until ~P (the presumed
custom-name frame, absent from this capture) is decoded - BL-E5 part 2 stays
open, needs-capture.
The capture also re-confirmed no ~H arrives during a refresh (it streams
periodically afterwards), which is what made the stale-model revert fire every
second once live. Docs updated: BL-E5 part 1 done, HW-VERIFICATION item 8
resolved, field-notes entry added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
- SettingHistoryDialog.export_to now writes the currently-shown (filtered)
rows instead of always the full set, matching what the user sees.
- Flag the station-settings wholesale-record assumption in
HARDWARE-VERIFICATION.md for confirmation against real D/E/B/C frames.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ~H tail carries, after the device-status nibbles, one triple per active
alarm-indicator group: index(byte) + local(nibble) + server(nibble), states
OK/WARN/ALARM/ERROR (ICD §5.H/§8.4). It was kept as an opaque tail and the
Alarm tab's Local/Server columns were hard-coded "—".
- decoder: pure parse_indicator_states(tail, device_count) helper (the
controller supplies device_count = config.max_devices, since the pure
decoder can't know it)
- messages: AlarmIndicator gains local/server; new IndicatorState
- models: AlarmIndicatorTable.set_live_state, preserved across ~M upserts
- controller._apply_status: apply triples after the device nibbles
- simulator _p_status: emit capacity device nibbles + a triple per active
indicator (seeded local OK / server WARN)
- alarm_tab: render real live state, severity-tinted -- closes the BL-E2
deferred-polish (a) placeholder
- load-bearing decoder test pins the nibble-vs-byte boundary + state enum
HW-pending (flagged): device-nibble count = MAX_DEVICES(16), and the §8.4
nibble being a single-valued enum.
606 passed; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(a) Traffic-capture keepalive: while a capture is active the Traffic tab
arms a 20s QTimer that calls controller.send_keepalive() -> writes a ~Z idle
frame, staying under the firmware's >25s bus-silence auto-disable (ICD
§4.2/§10.1). Tied to watching state (not tab visibility); send_keepalive
no-ops when the link isn't open (warm-cache suspend race).
(b) Deterministic refresh completion: the load now ends on the first ~H
frame (SVC_POLL phase, ICD §4.3) -- stop the settle timer, fire loadFinished
immediately -- with the 1.5s idle-settle timer kept as a fallback. Moved the
per-frame load bookkeeping before the ACK so the synchronous simulator
recursion still reports progress in arrival order and H-completion fires on
the true last frame.
HW-pending (flagged in HARDWARE-VERIFICATION.md): the H-termination path is
sim-validated but the only real capture showed no ~H (settle-timer fallback
covers it); the keepalive's effect on the >25s auto-disable is needs-confirm.
601 passed; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Outbound ~F/~G were mislabeled: ~F = request server config update (not
"read averages"), ~G = ResetHistory() which erases the station's outgoing
buffer (destructive, not "read inputs"). Live values arrive unsolicited via
the idle-poll loop, so there is no "request values" command at all.
- encoder: request_averages->request_server_config, request_inputs->
erase_outgoing_buffer (docstrings cite ICD 11; ~G flagged destructive)
- controller: matching renamed methods
- simulator _dispatch: ~F accept/no-echo stub, ~G clears _buffered_records
backlog; removed orphaned _emit_averages
- docs: amend HARDWARE-VERIFICATION item 7 and the M-frame guess
- tests: assert ~F/~G emit no value frames and ~G clears the backlog
595 passed; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real-hardware report: the Channels tab Tag (channel-name) column drops the
first 1-2 characters of a 16-char serial-style name, and widening the column
doesn't reveal them. A systematic-debugging pass ruled out display clipping
(current source renders 16-char values fully under every style/scale) and
isolated the cause to the decoder: the name is parsed as r.rest() after the
fixed-width E-frame fields, so a real frame with an extra field/byte before the
name makes rest() start mid-name. This is the same HW-unverified region as the
per-channel-serial layout question; the simulator never reproduces it.
Fixing it correctly needs a real E-frame capture (the repo has none yet), so
this commit adds the means to get one rather than guessing at offsets:
- controller.py: env-gated raw-frame capture (CIM_DA07_CAPTURE), off by
default, best-effort (never stalls the load). Appends every inbound frame
body so real E frames can be saved as decoder fixtures.
- tests: cover the capture hook on/off.
- docs: record the symptom + copy-paste capture recipe under
HARDWARE-VERIFICATION item 8, and track the bug as BACKLOG BL-E5.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The DA-07 rebuild loaded nothing from real hardware: unlike the DA-12 (which
streams its reply unsolicited), a real DA-07 runs a polled handshake -- it sends
a refresh one frame per ACK and interleaves Z2 idle polls, advancing only when
the tool answers both. The rebuild sent "A" once and answered nothing, so the
station stopped after frame one. The simulator hid this by dumping every frame
at once.
Fix (hardware-verified on a real DA-07, COM5):
- controller._process now ACKs (Z1) every inbound data frame to pull the next.
- controller._handle_poll answers the station's Z2 idle polls (and Z0 NAK ->
resend). Idle-polling turned out to be required for the initial load, not just
live mode -- an ACK-only build deadlocked partway through the stream.
- SimulatedStation models the frame-per-ACK stream (handshake=True); the
frame-content round-trip tests opt out with handshake=False.
A full Refresh now loads config + 45 device types + 28 settings + present
devices + channels (~120 frames over ~20s) where the broken build loaded 1.
Loading overlay (requested): a blocking, dimmed, gradient-blue panel covers the
DA-07 window during the multi-second refresh and clears when it completes.
- New reusable core/ui/loading_overlay.py (LoadingOverlay + animated gradient bar,
colors from theme tokens) and a SCRIM token + QSS rules.
- Controller emits loadStarted/loadProgress/loadFinished; completion is inferred
by a 1.5s idle-settle timer (no end marker on the wire; 1.5s clears the real
~0.55s max inter-frame gaps with margin). Verified: clears 1.5s after the last
frame, no premature clear.
233 tests pass (new test_handshake.py, test_loading.py), ruff clean. Docs updated
(CLAUDE.md, HARDWARE-VERIFICATION.md, BACKLOG.md, REBUILD-STATUS.md).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>