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

408 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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:
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 `<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).
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"<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.
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 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.