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>
408 lines
25 KiB
Markdown
408 lines
25 KiB
Markdown
# 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 `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<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 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.
|