Files
cimtechniques-service-suite/docs/HARDWARE-VERIFICATION.md
andy a665993a7c feat(da07): decode ~P channel-serial frames; verify ~H on hardware; refit channel columns on device switch
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>
2026-06-12 10:58:47 -04:00

25 KiB
Raw Permalink Blame History

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 (print data), 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?

  1. Connect the DA-12, note its COM port.
  2. .venv\Scripts\python -m cim_suite.shell.app --module da12 --port COMx
  3. Click Refresh.
  4. Expected: the Sensors / Alarm Limits / Statistics tabs populate with the channels on the station; the status bar shows activity and a station clock.
  5. If nothing appears, capture the raw bytes and compare the frame shape to what cim_suite/modules/da12/protocol/framing.py expects ({...} CR-terminated). Fix-up location: framing.py and decoder.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).

  1. Click Set Clock (sends {T000<hex8>} with the PC's current time).
  2. Click Refresh and read the station clock in the status bar (from the F frame) and the per-sensor Timestamp column.
  3. Expected: the displayed time matches the wall clock you just set.
  4. 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.

  1. With known sensors attached, compare the Type and Calc columns to what the legacy tool showed for the same units.
  2. Expected: they match (or the Type string is at least meaningful).
  3. If a lookup is needed: add a type_text → label map in cim_suite/modules/da12/protocol/decoder.py (decode, the A branch) and/or render the Calc column as a labeled combo in cim_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 AI inbound types and the AM SendSetting letters, but this wasn't confirmed on current hardware.

  1. Reads: Refresh and confirm sensor records (A), alarm limits (G), and statistics (H) all populate. Confirm station settings rows appear (D/E).
  2. 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.
  3. Expected: each edit/command changes the station as it did in the legacy tool.
  4. If a letter/format differs: the entire outbound mapping is centralized in cim_suite/modules/da12/protocol/encoder.py and cim_suite/modules/da12/domain/controller.py (the set_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.

  1. Capture a real F frame (it arrives on Refresh; see step 0/5 for the CIM_*-style capture approach).
  2. 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-endian record_count tail, and that active_sensors ([13:15]) and comm_activity ([17:19]) land where expected. buffered_low ([15:17]) is decoded but intentionally not retained (record_count is authoritative).
  3. Fix-up location: cim_suite/modules/da12/protocol/decoder.py::_decode_status (and the matching emit in transport/simulator.py::_emit_status). Add the captured frame as a fixture in tests/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.frmneither 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:

  1. 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_backfill accumulates 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 the max_buffer argument passed to StreamFramer in cim_suite/modules/da12/domain/controller.py accordingly.

  2. {c}{J} round-trip — send request_history for 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).

  3. Value scaling{J} values are stored as tenths (native units × 10, signed integer). Confirm a known measurement (e.g. 22.5 °C → value 225) decodes correctly in the chart and table.

  4. 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).


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 Z1 after each data frame to pull the next), and it interleaves Z2 idle polls into the stream whenever the next frame isn't ready — advancing only when the tool answers those idles too (Main.frm: an inbound Z drives SendCommand, which emits Z2 when its queue is empty). The original rebuild sent A once 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 in controller.py::_process (ACK every data frame) and controller.py::_handle_poll (answer Z2/resend on Z0); 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 Z plus an M frame (~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 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/:

  1. PullTime epoch / thresholdprotocol/codecs.py::decode_time. We assumed Unix-seconds local time with a < 2009-01-01 "seconds-since-restart" threshold (legacy PullTime). Confirm the station clock + channel timestamps read correctly.

  2. K set-clock endiannessprotocol/encoder.py::set_clock. The legacy sends K + big-endian Hex8 while inbound times are little-endian — an asymmetry that's faithful to the VB6 but unverified. Set the clock, refresh, and confirm it sticks.

  3. H realtime-frame counter layout — LARGELY VERIFIED 2026-06-12. The steady-state capture (tests/da07/fixtures/capture-2026-06-12-steady-state.txt, 68 real H frames) 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 read 0011… with devices 2,3 in COM fault and the rest OK, exactly matching the live Devices tab (regression-tested in test_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.

  4. Device-type gen_type enum + channel namesprotocol/messages.py (DeviceType). Confirm the type catalog (Analog/PCount/Pulse/CS/Din/Dout/Alarm/ UModbus) and the pipe-separated channel names match the legacy.

  5. Model code → name map — the A header model byte (6/7/9/33). Confirm the station reports the expected code; surface a friendly name if desired.

  6. DA-33 wireless G layoutprotocol/decoder.py (G branch). The decoder parses every G as 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.

  7. Live value frames after the refreshdomain/controller.py::_handle_poll. Idle-polling (answering the station's Z2 with Z2) is now implemented and keeps the link alive, so the station continues sending G/F value frames after the initial load (firmware re-pushes averages→current in the SVC_POLL steady 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 ~G on 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's loadStarted/loadProgress/loadFinished signals): 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 in controller.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 ~H realtime-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 ~H last in its refresh), but the only real DA-07 capture so far (2026-06-03) tallied A B C D E Z + M and 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 ~Z idle 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 ~Z frames). 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).

  8. 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 via CIM_DA07_CAPTURE from 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 phantom disp read — 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, the ChannelRecord dataclass, the Channels tab (Disp column removed; Tag shows name → serial → catalog default), the encoder (CH_DISP dropped), 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 ~P frame, which the capture did not contain; grab one with CIM_DA07_CAPTURE if a station ever shows custom names in the legacy tool. The capture also confirmed (again) that no ~H arrives 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.

  1. M/N alarm-indicator frame layoutprotocol/decoder.py (M/N branches), protocol/messages.py (AlarmIndicator/AlarmUpdatedTimes), the Alarm tab. Our M decode is [index byte][active byte][address byte…] and N is repeating [index byte][time] pairs, reconstructed from the legacy PA-series alarm-indicator group editor (Grid3). Outbound Q (encoder.clear_alarm_indicators) clears the group. Confirm the byte order/length and that a real station accepts Q.
  2. Y traffic frame layoutprotocol/decoder.py (Y branch), protocol/messages.py (TrafficFrame), the Traffic tab. Our Y decode is [direction nibble: 0=REC,1=XMT][data byte…], reconstructed from the legacy RS-485 traffic analyzer. start_traffic/stop_traffic toggle the monitor. Confirm the direction encoding and payload framing against a real bus capture.
  3. Serial-prefix setting label matchdomain/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" (legacy Grid0.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).
  4. Device-type catalog id↔index mapping — the A-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's set_device_type ("C"<dev><DEV_TYPE><type>) is what the station expects for an unconfigured slot (legacy Main.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>, legacy Main.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:3593 writes the type unconditionally). Confirm whether a real station treats a type-0 write to an empty slot as a no-op or as "add a blank device"; if the latter is undesirable, short-circuit on_edit when idx == 0 on 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.

  1. Smoke test. --module iomodbus --simulate should 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.

  2. CRC-16. Standard Modbus (init 0xFFFF, poly 0xA001, low-byte-first append), ported from Support.bas AddCRC. Confirm a real device accepts our requests and our ResponseAssembler validates its replies. Fix in cim_suite/modules/iomodbus/protocol/crc.py if a device uses a non-standard CRC.

  3. Type-3 offset constant 19999 — the legacy ProcessMsg comment 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).

  4. 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: the BINARY32 / BINARY64 branches in codecs.decode_value / encode_value.

  5. Float word order for type 5 (normal) vs type 6 (reversed) — CvtIntToSng order is taken as hi=reg0/lo=reg1 for type 5 and swapped for type 6. Confirm a known float register reads correctly. Fix: the FLOAT / FLOAT_REV branches in codecs.py.

  6. 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 ResponseAssembler decodes them and the controller raises a user alert. Fix: pdu.py / controller._apply_read.

  7. CI-13/15/18 xCalMult (×100) — the calibration multiplier applied when a channel's Serial # begins 6971. 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 -q and rebuild the installer.