# 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}` 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 `A–I` inbound types and the `A–M` `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.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}` 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). --- ## 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 `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 / threshold** — `protocol/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 endianness** — `protocol/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 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. 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` layout** — `protocol/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 refresh** — `domain/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. 9. **`M`/`N` alarm-indicator frame layout** — `protocol/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`. 10. **`Y` traffic frame layout** — `protocol/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. 11. **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 ` + + "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). 12. **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"`) 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"`, 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. 0. **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. 1. **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. 2. **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). 3. **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`. 4. **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`. 5. **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`. 6. **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 -q` and rebuild the installer.