The ~H operational-statistics frame is the only source of device status and
is not solicitable; it arrives once per second only after the full refresh
sequence drains into SVC_POLL. Reviewed and accepted as-is; candidate
perception-only mitigations noted in the entry instead of BACKLOG.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Hardware-found: toggling two device rows' Active and refreshing showed only the
second toggle landed. The 2026-06-12 capture explains it - 16 burst channel
writes drew only 2 Z1 ACKs; the station processes one inbound frame at a time
and silently drops the rest. The legacy never burst: MakeCommand only queued,
and each inbound Z popped exactly one command (Main.frm SendCommand).
The controller now queues commands and keeps one in flight: Z1 confirms and
advances; Z0 retransmits; a Z2 idle while unconfirmed means the frame was
dropped, so it retransmits (all queued commands are idempotent - this improves
on the legacy, which lost silent drops), capped at 3 transmissions then dropped
with errorOccurred. Link frames (data-frame ACKs, idles) bypass the queue. The
queue is cleared on stop() so a closed port cannot wedge it; a refresh queued
behind writes re-arms the load-settle timer at transmit time.
pendingWritesChanged(depth) drives a new footer WORKING dot - the modern
version of the legacy "Working n" status caption, per user request: lit while
settings changes await the station's acknowledgement.
The simulator now Z1-ACKs every command frame like the real station (applying
the command BEFORE acking, so synchronous pumping cannot reorder same-field
writes) - its perfect burst handling is exactly why this bug was sim-invisible.
CIM_DA07_CAPTURE now also records outbound frames ("> "-prefixed) so the next
hardware session sees both sides of the handshake.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
set_summary replaced its count labels with label.setParent(None) while they
were visible - reparenting a visible widget to None promotes it to a real
top-level window, which Windows shows with full native decoration (app icon,
min/max/close, label-sized blank body) until the deferred deleteLater runs.
Every grid rebuild in every module flashed one such ghost per replaced label;
the DA-07 optimistic-apply change made it glaring (8 flashes per device-row
Active toggle, one per channel write). Fix: hide the dying label and let
deleteLater collect it while still parented.
Found via a live user session on DA-07 hardware: synthetic probes could not
reproduce it (offscreen platforms swallow the flash; QTest clicks missed the
toggle), so this adds an env-gated diagnostic - CIM_UI_SPY=<log path> installs
a window spy (core/ui/window_spy.py, hooked in shell/app.py) that logs every
top-level Show with the widget creation stack; the user's log produced 84 ghost
windows pointing at the exact line. Screenshot kept in docs/samples; session
write-up in docs/DA07-FIELD-NOTES.md.
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>
2a39bfe folded the xlsx exports into the Catalog menu with hand-rolled handlers
(collect_sheet/collect_all/save_sheets_dialog + bound-method connections),
which broke two things:
1. Teardown crash (tests/iomodbus/test_ui_smoke.py::test_calibration_dialog_
computes): pytest-qt holds only weakrefs to registered widgets. The shared
add_export_actions closures (capturing parent) were the only strong,
C++-anchored reference keeping the MainWindow's Python wrapper alive past
the test frame; with only weakly-stored bound-method slots left, the window
wrapper died by refcount at function exit, shiboken deleted the ownerless
C++ QMainWindow, and the cascade deleted the parented CalibrationDialog's
C++ object - while the dialog's wrapper survived in qframelesswindow's
windowEffect reference cycle, so qtbot teardown closed a dead C++ object
(RuntimeError: Internal C++ object already deleted). Verified by weakref/
shiboken6.isValid experiments with and without a window-anchoring closure.
2. Wrong menu: spreadsheet export of live grid data is not a device-catalog
(IOModbus.txt) file operation.
Rework per the Instrument spec: data export gets its own "Export" QToolButton
menu between the Refresh/poll group and the Catalog menu (separator-delimited),
built by the shared add_export_menu_actions engine - restoring the empty-export
notice and the window-anchoring closures. The Catalog menu is back to Import
Devices / Export Devices / Supported Devices. Both menus keep tooltips visible.
Toolbar sizeHint: 1190px themed offscreen (1019px untheme) vs 1240px budget.
Tests: tests/iomodbus 145 passed; full suite 1012 passed, zero errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract the shared _export_actions builder from add_export_actions so the same
dialog/file-naming/collection logic can target either a toolbar (DA-12, DA-07)
or a QMenu (for toolbars on a width budget). The builder's docstring documents
that the handlers must stay closures: a closure slot is stored strongly by the
connection, so the actions anchor the parent window's Python wrapper for the
life of the C++ action - bound-method slots are stored weakly, and rewiring
them as window methods lets an otherwise-unreferenced window be GC'd mid-test,
cascading C++ deletion into child dialogs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add suiteRequested Signal; act_suite triggers it (SuiteWindow wires this to
show_launcher via the existing open_module hook - no shell change needed)
- Add ConnectionChip (chip) to toolbar; set_connection_source(source) feeds it
from module._start_simulator ("SIM") and _connect_to_port (port string)
- _on_connection updates chip and toggles act_connect text Connect/Disconnect
- _toggle_connection dispatches to _ctrl.stop() (disconnect) or _connect() (connect)
- Delete _build_menu / menuBar usage; fold Import Devices, Export Devices,
Supported Devices into Catalog toolbar QToolButton menu with tooltips visible
- Also fold Export This Tab / Export All Tabs into Catalog menu (BL-DS5 trim):
with IBM Plex Mono loaded the two export toolbar buttons push sizeHint above
1240px; grouping all file-level operations under Catalog is semantically
coherent and keeps the toolbar at ~1093px (themed offscreen) with real margin
- "Log Measurements" -> "Log" (tooltip preserved) - further trim per task spec
- Drop " Address: " and "Update: " bare QLabels; addr_edit gets
setPlaceholderText("Address"), update_combo gets a tooltip
- resize 1100x720 -> 1280x760 (suite default)
- module.py: set_connection_source("SIM"/"port") before attach in both
_start_simulator and _connect_to_port
Tests: 5 new in tests/iomodbus/test_main_window_toolbar.py; suite 1010 passed.
Toolbar sizeHint: 1093px (themed+offscreen) vs 1240px limit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>