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>
25 KiB
Hardware Verification Checklist
The protocol layer was reverse-engineered from the legacy VB6 source and is fully unit-tested, but a handful of details can only be confirmed against a real DA-12. This checklist walks through each one. Do it with a DA-12 connected via serial/USB-serial.
Tip: the app's Debug-free design means raw frames aren't shown in the UI. To capture real traffic, either (a) temporarily log in
SerialTransport._reader(printdata), or (b) use a serial sniffer (e.g. the legacy tool on one port via a com0com pair, or a hardware tap). Save captures — they become test fixtures.
0. Smoke test: does it talk at all?
- Connect the DA-12, note its COM port.
.venv\Scripts\python -m cim_suite.shell.app --module da12 --port COMx- Click Refresh.
- Expected: the Sensors / Alarm Limits / Statistics tabs populate with the channels on the station; the status bar shows activity and a station clock.
- If nothing appears, capture the raw bytes and compare the frame shape to what
cim_suite/modules/da12/protocol/framing.pyexpects ({...}CR-terminated). Fix-up location:framing.pyanddecoder.py.
1. Station clock epoch/format ⚑
Why: the legacy GetOSTime/FormatTime lived in the CIMScan DLL; we assumed
C time_t (Unix seconds, local time).
- Click Set Clock (sends
{T000<hex8>}with the PC's current time). - Click Refresh and read the station clock in the status bar (from the
Fframe) and the per-sensor Timestamp column. - Expected: the displayed time matches the wall clock you just set.
- If wrong (offset by years, or by a timezone, or scaled): adjust the four
clock helpers in
cim_suite/modules/da12/protocol/codecs.py:decode_clock_time,decode_clock_datetime,station_time_hex8(e.g. different epoch base, big-endian byte order, or 0.5-second ticks).
2. Sensor "type" codes and the Calculation enum ⚑
Why: the Type column is shown as a raw string today; the Calc column
values (0..5) map to Sigma / MKT / Rate per sec / Rate per min / Rate per hr / From Count per the legacy Grid1 combo, but the numeric encoding wasn't verified.
- With known sensors attached, compare the Type and Calc columns to what the legacy tool showed for the same units.
- Expected: they match (or the Type string is at least meaningful).
- If a lookup is needed: add a
type_text→ label map incim_suite/modules/da12/protocol/decoder.py(decode, theAbranch) and/or render the Calc column as a labeled combo incim_suite/modules/da12/ui/sensors_tab.py.
3. Message set & setting-letter mapping on 16-channel firmware ⚑
Why: current firmware (16 channels, no wireless) should still use the A–I
inbound types and the A–M SendSetting letters, but this wasn't confirmed on
current hardware.
- Reads: Refresh and confirm sensor records (
A), alarm limits (G), and statistics (H) all populate. Confirm station settings rows appear (D/E). - Writes — one at a time, watching the station react:
- Edit a sensor Name (→
{B7..}), Scale (→{C4..}), Offset (→{D3..}); Refresh and confirm the change stuck. - Edit an Alarm Limit field (→
{H..}–{M..}); confirm. - Clear Avg (
{N0..}), Clear Stats ({O0..}), Remove ({R0..}), Add New ({S700..}); confirm each.
- Edit a sensor Name (→
- Expected: each edit/command changes the station as it did in the legacy tool.
- If a letter/format differs: the entire outbound mapping is centralized in
cim_suite/modules/da12/protocol/encoder.pyandcim_suite/modules/da12/domain/controller.py(theset_sensor_*/set_limit_*methods) — change it in one place.
4. The F status frame layout ⚑
Why: the legacy F parser mixes fixed 2-char byte reads with tab-delimited
fields. As of BL-D6 (2026-06-04) our decoder._decode_status is no longer a
loose reconstruction — it was aligned to the firmware-authoritative byte
layout (docs/da12c_status.py::parse_f_frame / build_f): nine fixed-offset
2-hex byte fields (outgoing, retries, values, incoming, errors, time_since_last_min, active_sensors, buffered_low, comm_activity), an 8-hex
big-endian station clock at [19:27], then a tab and a little-endian
record_count tail (buffered records waiting to upload). StationStatus now
carries those as named fields, and the simulator emits the same layout. This is
firmware-source-verified but still needs a live capture to retire the flag.
- Capture a real
Fframe (it arrives on Refresh; see step 0/5 for theCIM_*-style capture approach). - Decode it by hand and confirm each named field against the legacy display /
the unit: in particular the big-endian clock at
[19:27], the little-endianrecord_counttail, and thatactive_sensors([13:15]) andcomm_activity([17:19]) land where expected.buffered_low([15:17]) is decoded but intentionally not retained (record_countis authoritative). - Fix-up location:
cim_suite/modules/da12/protocol/decoder.py::_decode_status(and the matching emit intransport/simulator.py::_emit_status). Add the captured frame as a fixture intests/test_messages_decoder.py.
Surfaced in the UI (BL-D6). The DA-12 status bar now shows active_sensors
("Sensors N"), the buffered-upload backlog ("Buffered N", tinted amber when it
grows frame-to-frame), the station clock, and a live ⚡ activity indicator whose
hover tooltip lists the per-interval throughput counters (outgoing/retries/values
as deltas, plus the free-running comm_activity). Confirm these read sensibly on a
real unit when you capture the frame.
Server-connection counters (added 2026-06-03). The Server pill in the status
bar reads {F} counter 3 (good server messages) and counter 4 (bad-checksum
server messages) as "a server frame arrived this interval", and counter 5
(minutes since last) as a coarse backstop; the comm-loss timeout comes from
setting row 8 ({E08}). These offsets are verified against the DA-12C C source
(see docs/da12c_status.py) but not yet against a live capture. To verify:
with a unit connected to a known-up server, confirm the Server pill is green and
the tooltip's "last message ~N s ago" stays small; unplug the LAN and confirm it
goes red within the comm-loss timeout. Save the first real {F} frames (server
up and server down) as test fixtures, and confirm the row-8 timeout against the
station's configured Host Comm Loss value.
5. Capture real frames for regression fixtures
Note: the repo's Exceptions.txt is only a VB6 crash log (access violations,
2009) and VB187.tmp is an older backup of Main.frm — neither contains
serial traffic, so there's no ground-truth capture to mine. The first real
frames you capture from the DA-12 (step 0/4 above) are therefore valuable: save
them and add representative {...} bodies as decoder vectors in
tests/test_messages_decoder.py.
6. Sensor history / {J} frame (BL-D5, added 2026-06-05) ⚑
The per-sensor History dialog (double-click a Sensors-tab row, or right-click
"Show history…") issues a {c0<id><max_records:08X>} request and decodes the
resulting {J} reply as one batch of [timestamp, value×10] pairs. The decode
follows the firmware reference (docs/da12c_status.py::parse_plot_data) but has
not been validated against a real DA-12. Items to confirm:
-
Single frame vs multiple frames — the firmware doc raises its frame-reader guard to 512 for
{J}replies, implying a large reply could arrive as one long frame. Confirm whether a request for many records produces one{J}frame or several. Our decoder handles one frame = one batch;MeasurementHistory.merge_backfillaccumulates and deduplicates across multiple calls, so either way is handled, but verify the frame count on hardware. Framer cap:StreamFramer(cim_suite/modules/da12/protocol/framing.py) has a 4096-char incomplete-frame guard. A single{J}frame exceeding ~4096 chars (roughly >290 records) split across serial reads would be truncated. If hardware confirms one large{J}frame rather than multiple smaller ones, raise themax_bufferargument passed toStreamFramerincim_suite/modules/da12/domain/controller.pyaccordingly. -
{c}→{J}round-trip — sendrequest_historyfor a sensor with known buffered records; confirm a{J}reply arrives within a normal response window and that the History dialog populates. Also confirm the station's behavior when no buffered records exist (empty reply vs no reply vs an empty{J}frame). -
Value scaling —
{J}values are stored as tenths (native units × 10, signed integer). Confirm a known measurement (e.g. 22.5 °C → value225) decodes correctly in the chart and table. -
Gap-detection interval — the dialog uses station setting #06 (Update Interval) as the expected cadence for gap markers. Confirm this is the right setting index on the actual firmware and that gap markers appear where expected when the station has paused recording.
As with all DA-12 frames: the first real {J} frame you capture should be saved
as a test fixture in tests/test_messages_decoder.py (consistent with the guidance
in section 5 above).
DA-07 ("eLink") — its own flagged items ⚑
The DA-07 module (module #2) was reverse-engineered the same way and has its own
short list. Verify with a real DA-07/09/33 connected at 9600,N,8,1 (note the
slower baud vs DA-12). Run --module da07 --port COMx, click Refresh, and
expect the Devices and Channels tabs to populate.
Handshake — fixed AND hardware-verified 2026-06-03. Unlike the DA-12 (which streams its reply unsolicited), the DA-07 runs a polled handshake: it sends a refresh one frame per ACK (the tool replies
Z1after each data frame to pull the next), and it interleavesZ2idle polls into the stream whenever the next frame isn't ready — advancing only when the tool answers those idles too (Main.frm: an inboundZdrivesSendCommand, which emitsZ2when its queue is empty). The original rebuild sentAonce and answered nothing, so on real hardware only the first frame arrived; an ACK-only first attempt then deadlocked partway (the station idled and we stayed silent). The fix lives incontroller.py::_process(ACK every data frame) andcontroller.py::_handle_poll(answerZ2/resend onZ0); the simulator models the frame-per-ACK stream (simulator.py,handshake=True).Verified on a real DA-07 (COM5, 2026-06-03): a full Refresh loads the config header, all 45 device-type definitions, 28 station settings, both present devices (idx 2/3), and their channels — 154 frames, where the broken build loaded 1. Frame-type tally seen:
A B C D E Zplus anMframe (~9 of them). Resolved (BL-E6, ICD §5.M): inbound~M= alarm-indicator settings, now decoded (decoder.py::decode,t == "M"→AlarmIndicator). The earlier guess ("UModbussChannInfo") was wrong — that is~W, which is 07C-only (ICD §5.W) and still undecoded. The controller still ACKs past any frame the decoder returnsNonefor, 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/~Fframes rebuilt the tabs from the stale model. Fixed by optimistic local apply in every controllerset_*. Full write-up, the general rule, and the open follow-ups (incl. "does the same latent bug exist in DA-12?") live indocs/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/:
-
PullTimeepoch / threshold —protocol/codecs.py::decode_time. We assumed Unix-seconds local time with a< 2009-01-01"seconds-since-restart" threshold (legacyPullTime). Confirm the station clock + channel timestamps read correctly. -
Kset-clock endianness —protocol/encoder.py::set_clock. The legacy sendsK+ big-endianHex8while inbound times are little-endian — an asymmetry that's faithful to the VB6 but unverified. Set the clock, refresh, and confirm it sticks. -
Hrealtime-frame counter layout — ✅ LARGELY VERIFIED 2026-06-12. The steady-state capture (tests/da07/fixtures/capture-2026-06-12-steady-state.txt, 68 realHframes) confirms the decode: 15 counter bytes, LE uint16 buffered count (300 on the test station), LE station time, then one status nibble per device slot (16 =max_devices) — the tail read0011…with devices 2,3 in COM fault and the rest OK, exactly matching the live Devices tab (regression-tested intest_decoder.py::test_realtime_status_real_frame). Still[needs-capture]from BL-E7: the indicator triples after the device nibbles (the test station had no active alarm groups, so none were emitted) and the §8.4 single-valued-enum assumption. -
Device-type
gen_typeenum + channel names —protocol/messages.py(DeviceType). Confirm the type catalog (Analog/PCount/Pulse/CS/Din/Dout/Alarm/ UModbus) and the pipe-separated channel names match the legacy. -
Model code → name map — the
Aheadermodelbyte (6/7/9/33). Confirm the station reports the expected code; surface a friendly name if desired. -
DA-33 wireless
Glayout —protocol/decoder.py(Gbranch). The decoder parses everyGas the non-33 (status+float per channel) layout; the model-33 layout (RSSI/battery/TX-power) is deferred (BL-E3). Needs a real DA-33 capture. -
Live value frames after the refresh —
domain/controller.py::_handle_poll. Idle-polling (answering the station'sZ2withZ2) is now implemented and keeps the link alive, so the station continues sendingG/Fvalue frames after the initial load (firmware re-pushes averages→current in theSVC_POLLsteady state). Confirm on hardware that the Channels tab keeps updating once the refresh completes. Do NOT try to "actively request" values (BL-E6): outbound~F= request a config update from the server and outbound~G=ResetHistory()which erases the station's outgoing buffer (ICD §11). There is no outbound "read values" command — the idle poll is the only mechanism, and calling~Gon a timer would destroy buffered data. (This item previously, wrongly, suggested doing exactly that.)A loading progress overlay is implemented (
core/ui/loading_overlay.py, driven by the controller'sloadStarted/loadProgress/loadFinishedsignals): a blocking, dimmed, gradient-blue panel covers the DA-07 window during the multi-second refresh. Completion is inferred by an idle-settle timer (no new data frame for 1.5 s), since the real stream has no explicit end marker. Verified on hardware 2026-06-03: a full load streams ~120 frames over ~20 s with a max inter-frame gap of 0.55 s, and the overlay clears 1.5 s after the last frame — no premature clear, no lingering. If a future unit pauses longer mid-stream, raise the settle interval incontroller.py::__init__.BL-E11(b) — deterministic completion on
~H(UNCONFIRMED on hardware). The controller now also ends the load the instant it sees the first~Hrealtime-status frame (the SVC_POLL phase, ICD §4.3), clearing the overlay immediately instead of waiting out the 1.5 s settle timer; the settle timer is kept as the fallback. This is sim-validated (the simulator emits~Hlast in its refresh), but the only real DA-07 capture so far (2026-06-03) talliedA B C D E Z+Mand no~H— so on real hardware completion may still fall back to the settle timer. Confirm a real refresh actually terminates with an~H; if it never does, that's fine (the fallback covers it) but the early-completion path is dead code on that unit.BL-E11(a) — traffic-capture keepalive (
[needs-confirm]). The Traffic tab now sends a~Zidle frame every 20 s while a capture is active, to stay under the firmware's >25 s bus-silence auto-disable (ICD §4.2, §10.1, where capture only watches for the tool's~Zframes). Confirm on hardware that the 20 s keepalive actually prevents the auto-disable on a quiet bus (the threshold/behavior is firmware-sourced, not yet observed live). -
Per-channel sensor serial on the
'E'frame — ✅ RESOLVED 2026-06-12 (BL-E5 part 1). The repo's first real capture (tests/da07/fixtures/capture-2026-06-12-refresh.txt, recorded viaCIM_DA07_CAPTUREfrom a CS-31-equipped station) settled the layout: the'E'payload ends at the alarm byte, followed only by an optional 8-byte CT sensor serial (no tab, no disp field, no name field). The phantomdispread — inherited from the VB6, which had the same bug feeding its hidden Disp column — ate the serial's first byte and produced the truncated-Tag symptom. The decoder, theChannelRecorddataclass, the Channels tab (Disp column removed; Tag shows name → serial → catalog default), the encoder (CH_DISPdropped), and the simulator are all fixed and regression-tested against the captured frames. Still open (BL-E5 part 2): where a custom channel name echoes back — presumably the~Pframe, which the capture did not contain; grab one withCIM_DA07_CAPTUREif a station ever shows custom names in the legacy tool. The capture also confirmed (again) that no~Harrives during a refresh — it streams later, periodically, in the steady state. Session details:docs/DA07-FIELD-NOTES.md.
DA-07 polish-pass items (2026-06-04, all reconstructed from VB6, sim-only) ⚑
The 2026-06-04 polish pass revived four legacy features whose wire details and label assumptions are reverse-engineered from the VB6 baseline and verified against the simulator only — confirm each against a real DA-07.
M/Nalarm-indicator frame layout —protocol/decoder.py(M/Nbranches),protocol/messages.py(AlarmIndicator/AlarmUpdatedTimes), the Alarm tab. OurMdecode is[index byte][active byte][address byte…]andNis repeating[index byte][time]pairs, reconstructed from the legacy PA-series alarm-indicator group editor (Grid3). OutboundQ(encoder.clear_alarm_indicators) clears the group. Confirm the byte order/length and that a real station acceptsQ.Ytraffic frame layout —protocol/decoder.py(Ybranch),protocol/messages.py(TrafficFrame), the Traffic tab. OurYdecode is[direction nibble: 0=REC,1=XMT][data byte…], reconstructed from the legacy RS-485 traffic analyzer.start_traffic/stop_traffictoggle the monitor. Confirm the direction encoding and payload framing against a real bus capture.- Serial-prefix setting label match —
domain/controller.py::full_serial(_SERIAL_PREFIX_LABEL = "High 4 bytes of Serial Number"). The full device serial shown on the Devices tab is assembled as<this station setting's value> + <device 6-hex> + "00"(legacyGrid0.Cell(4,1) & PullHex(3) & "00"). The prefix is matched by label text; confirm a real station streams a setting with exactly that label (the code falls back to the raw 6-hex, never fabricating a prefix, if it's absent). - Device-type catalog
id↔index mapping — theA-frame device-type catalog that drives the Devices "Type" dropdown. The simulator seeds e.g. catalog id 44 ↔CS-31(the user's reference example) and id 1 ↔Analog. Confirm the real catalog's id/index → model-label mapping streamed by a live station.
Save the first real DA-07 frames as decoder fixtures in tests/da07/test_decoder.py.
Devices tab — add/remove/active (added 2026-06-05)
Verify against a real DA-07 (e.g. COM5):
- Inline add: on an empty device slot, picking a Type from the dropdown configures
the slot — the station echoes a
present=True'D'frame and the row fills in. Confirm the encoder'sset_device_type("C"<dev><DEV_TYPE><type>) is what the station expects for an unconfigured slot (legacyMain.frm:3598). - Active toggle: ticking the Active checkbox activates every channel of the pod;
unticking deactivates them; after a refresh the box reflects channel state
(derived, mirrors
UpdateDeviceActiveFlag). - Remove: right-click → Remove Device clears the slot on the station
(
remove_device→"S"<dev>, legacyMain.frm:2342). - Blank "None" type on an empty slot: the Type dropdown's first entry is blank,
which sends type
0(VB6-faithful —Main.frm:3593writes the type unconditionally). Confirm whether a real station treats a type-0write to an empty slot as a no-op or as "add a blank device"; if the latter is undesirable, short-circuiton_editwhenidx == 0on a not-present row (devices_tab.py).
IOModbus — its own flagged items ⚑
IOModbus is a standard Modbus RTU master, so most of the wire format is the
public Modbus spec (high confidence). The items below are the parts reverse-engineered
from the VB6 that a real device should confirm. Each is isolated to a clearly-marked
spot. The bundled cim_suite/modules/iomodbus/resources/IOModbus.txt catalog is
itself the register-map ground truth, so per-device maps are not on this list.
-
Smoke test.
--module iomodbus --simulateshould scan, list the seeded devices, and show live channel values. On hardware: Connect… (pick port + baud), enter the device address, Scan — the device should appear in Available Devices; select it and the Settings + I/O Channels grids should populate and update. -
CRC-16. Standard Modbus (init
0xFFFF, poly0xA001, low-byte-first append), ported fromSupport.basAddCRC. Confirm a real device accepts our requests and ourResponseAssemblervalidates its replies. Fix incim_suite/modules/iomodbus/protocol/crc.pyif a device uses a non-standard CRC. -
Type-3 offset constant
19999— the legacyProcessMsgcomment says it was "changed for TEC-9300 EMES", i.e. it is device-specific, not universal. Read a known type-3 register on a real device and confirm the bias. Fix:codecs.OFFSET_BIAS(and consider making it per-device if it varies). -
Type-7 (32-bit) and type-8 (64-bit) binary combine/byte order. The legacy type-7 combine was hand-patched and looks suspect; we use the natural big-endian
uint32. Type-8 is displayed as per-register byte-swapped hex (legacy behavior). Verify both against a device that exposes such registers. Fix: theBINARY32/BINARY64branches incodecs.decode_value/encode_value. -
Float word order for type 5 (normal) vs type 6 (reversed) —
CvtIntToSngorder is taken as hi=reg0/lo=reg1 for type 5 and swapped for type 6. Confirm a known float register reads correctly. Fix: theFLOAT/FLOAT_REVbranches incodecs.py. -
Function-code selection & exceptions — reads use FC2/FC1 for digital (input/coil) and FC4/FC3 for analog (input/holding) depending on writability; writes use FC5 (coil) / FC6 (single register). Confirm the device honors these, and that Modbus exception responses (function byte with the high bit set) are handled — the legacy effectively ignored them; our
ResponseAssemblerdecodes them and the controller raises a user alert. Fix:pdu.py/controller._apply_read. -
CI-13/15/18
xCalMult(×100) — the calibration multiplier applied when a channel's Serial # begins 69–71. Verify against a real CI-13/15/18 sensor. Fix:calibration.cal_multiplier.
Save the first real RTU request/response frames as fixtures in tests/iomodbus/
(e.g. extend test_pdu.py / test_simulator.py).
Device settings repository — station config-only assumption ⚑
The cross-module device settings repository (2026-06-06) change-logs configuration
settings only; live/streaming values are excluded by an explicit per-field whitelist in
each module's domain/repo_snapshot.py. Sensor/channel whitelists are field-level and
safe. Station settings are recorded wholesale — every SettingLine (DA-12) /
StationSetting (DA-07) with a value — on the assumption that station config arrives in
the D/E (DA-12) / B/C (DA-07) config frames while live counters/clock arrive in the
separate status frame (StationStatus / RealtimeStatus). This holds against the
simulators' static seed lists and the current decoders. Verify on real hardware that no
station D/E/B/C row carries a value that changes on its own between refreshes (e.g. a
live clock or uptime/battery counter surfaced as a setting row). If one does, exclude that
row's setting_key in the module's snapshot() so it doesn't log a row every refresh.
When everything checks out
- Add the captured frames as regression fixtures (
tests/). - Remove the ⚑ FLAGGED comments from
codecs.py/decoder.py. - Note any firmware quirks in
docs/REBUILD-STATUS.md. - Re-run
pytest -qand rebuild the installer.