From 74a2e2fd2cf18ed4f10476d442afb1b398f4b535 Mon Sep 17 00:00:00 2001 From: Aiden <68633820+awils27@users.noreply.github.com> Date: Tue, 26 May 2026 14:01:10 +1000 Subject: [PATCH] testing around OK state --- README.md | 11 + docs/pt2-protocol.md | 592 ++++++++++++++++++++++++++++++++ h8536/connect_ok_matrix.py | 387 +++++++++++++++++++++ scripts/connect_ok_matrix.py | 14 + tests/test_connect_ok_matrix.py | 45 +++ 5 files changed, 1049 insertions(+) create mode 100644 docs/pt2-protocol.md create mode 100644 h8536/connect_ok_matrix.py create mode 100644 scripts/connect_ok_matrix.py create mode 100644 tests/test_connect_ok_matrix.py diff --git a/README.md b/README.md index 0f79c33..a25e352 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ To start the current emulator harness: .\.venv\Scripts\python.exe h8536_emulator_rx_probe.py --preset connect-lcd .\.venv\Scripts\python.exe h8536_emulator_rx_divergence.py --default-frames --uart-timing --wait-heartbeats 2 --summary-only .\.venv\Scripts\python.exe scripts\bench_connect_lcd_sequence.py --port COM5 --relay-port COM6 --prompt-screen +.\.venv\Scripts\python.exe scripts\connect_ok_matrix.py --suite minimal --prompt-observation --result-json captures\connect-ok-minimal-result.json .\.venv\Scripts\python.exe scripts\serial_ack_probe.py --ack-frame "05 00 40 00 00 1F" .\.venv\Scripts\python.exe scripts\serial_scenario.py scenarios\ack-race-000-001.json --log captures\ack-race-000-001.txt --result-json captures\ack-race-000-001-result.json .\.venv\Scripts\python.exe scripts\serial_scenario.py scenarios\table-sweep-ack-000-07f.json --log captures\table-sweep-ack-000-07f.txt --result-json captures\table-sweep-ack-000-07f-result.json @@ -60,6 +61,8 @@ To start the current emulator harness: The real-device bench helper uses `pyserial`; install repo dependencies with `.\.venv\Scripts\python.exe -m pip install -r requirements.txt` if needed. +The current PT2/protocol reconstruction is documented in [docs/pt2-protocol.md](docs/pt2-protocol.md). + ## Real Bench Serial Format The real RCP serial link is `38400 8E1`, not `38400 8N1`. This is backed by the ROM SCI1 init: @@ -124,6 +127,7 @@ Minimal smoke-test shape: - Includes an emulator probe that reports hot PCs, recent P9/SCI accesses, serial report queue/gate traces, RAM lifecycle watches, final SCI1/TXI state, and captured P9 byte candidates while running the real ROM. - Includes an RX command probe that boots until SCI1 RXI is serviceable, injects host six-byte frames through RDR/RDRF, can optionally schedule bench-style UART byte arrivals at real spacing, listens for device TX frames, and reports serial latch/table/LCD-buffer and emulated-LCD effects. - Includes a bench helper for replaying the emulator-derived CONNECT LCD frame sequence against the real device through COM5, with optional COM6 relay power cycling and timestamped capture logs. +- Includes a CONNECT: OK bench matrix runner that power-cycles between cases and tests the known sequence, single frames, primer pairs, order permutations, inter-frame gaps, repeats, and hold time to separate magic-frame, primer, cadence, and latch behavior. - Includes a bench ACK probe that reproduces the `01 00 00...` -> `01 00 01...` visible retry burst, waits for `07 80 40 20 90 2D`, then sends a candidate command-5 ACK and reports whether the target keeps repeating. - Includes a checksum-resynchronizing bench receiver that scans RX byte streams for valid six-byte frames, avoids common shifted-heartbeat false locks, and can fall back to the old fixed six-byte slicer with `--sync fixed`. - Includes a JSON scenario bench runner for repeatable multi-step serial tests, including low-latency ACK-aware command-1 probes that can send the current command-5 ACK candidate immediately after the retry frame appears, with explicit max-ACK/max-target guardrails. @@ -142,6 +146,8 @@ Current serial observations: - Runtime-confirmed heartbeat path: `loc_4067` writes `H'0000` into the queue via a zero-extended word move, `loc_BAF2/loc_BB08` dequeue it, `loc_BB1C/loc_BB20/loc_BB2B` stage the TX bytes, and `loc_BA26` emits `00 00 00 00 80 DA`. - Emulator LCD finding: the ROM writes the boot/no-active-session message to the LCD bus as ` CONNECT:NOT ACT` on line 0 by the time SCI1 RX is serviceable. Valid and invalid six-byte host frames leave that display active while normal serial replies/heartbeats continue. - Bench serial-format finding: real hardware talks `38400 8E1`. Earlier `8N1` captures primarily exercised SCI1 parity/error handling and retry echoes, not the normal command path. After switching bench scripts to even parity, the selector-zero CONNECT path can reach `CONNECT: OK`. +- Bench CONNECT recovery finding: `CONNECT:NOT ACT` is recoverable without a power cycle. This makes it a normal no-active-session/cleared-state display rather than a terminal latch; tests can now probe from the idle NOT ACT state directly, then separately check whether OK is held or needs periodic CCU-like refresh traffic. +- Bench CONNECT cadence finding: the `40 -> 80 -> C0` sequence stayed at `CONNECT:NOT ACT` with 10 ms, 50 ms, and 150 ms gaps, but produced `CONNECT: OK` then returned to `CONNECT:NOT ACT` with 700 ms and 1.5 s gaps. At 700 ms, single `40`/`80`/`C0` frames did not work, but all tested two-frame pairs did. Repeated `80 -> 80` at about 700 ms also worked, so the values do not need to differ. The no-power-cycle NOT ACT recovery capture produced repeated `02 00 02 00 00 5A` OK-path responses before heartbeat traffic resumed. - Board/P9 finding: traced MCU pin 62 `P91` reaches X24164 pin 6 `SCL`, and MCU pin 68 `P97` reaches the shared X24164 pin 5 `SDA` node. The emulator now treats the ROM's `C121/C08B/C0DB/C10C/C142` P9 routines as an X24164-style two-wire EEPROM bus, with ROM logical addresses `0x000-0x7FF` on the `H'A0/H'A1` control-byte family and `0x800-0xFFF` on `H'E0/H'E1`. - EEPROM role finding: `loc_40BB` checks `P7DR.7` and the `F402 == H'6B6F` signature before defaulting EEPROM/shadow tables; `loc_4103` writes ROM default words through `BFE0`, `loc_41D2` reads sixteen 8-byte records into `F7B0-F82F`, and the command-4 path at `BD2B-BD5F` can persist serial table writes when `F76E.7` is set. - EEPROM layout finding: `build\rom_eeprom_layout.txt` currently identifies the ROM factory table at `H'C964-H'CA63`, the F400 shadow defaults, page 0 offset `0x000-0x007` as the signature/options header (`00 00 6B 6F FE 00 00 00`), pages 1-F offset `0x00-0x07` as blank-by-default record slots, and 89 selector mappings from the `H'C564` table into F400/EEPROM offsets. `F404` defaults to `H'FE00` and is tested as option/feature bits, while `F76E` combines persistence enable, dispatch suppression, and low-nibble EEPROM page selection. @@ -291,6 +297,10 @@ python h8536_emulator_rx_divergence.py --help - `h8536_emulator_state_search.py --preset connect-queue --target ok --first-hit --json-out build\connect-state-search-ok.json`: run the bounded emulator state search for the minimum selector-zero queue condition that reaches `CONNECT: OK`. The default matrix varies `E000[0]` and `F730`, seeds `F970[0]=0`, starts at `loc_2806`, and executes real ROM code into the LCD handler. - `h8536_emulator_state_search.py --preset custom --pc 0x2CB9 --word E000=0x8080 --byte F730=0 --target ok`: directly test the CONNECT handler branch with explicit internal state patches. - `scripts\bench_connect_lcd_sequence.py --port COM5 --relay-port COM6 --prompt-screen`: power-cycle the bench device, wait for heartbeat readiness, send `04 00 00 40 00 1E`, `04 00 00 80 00 DE`, `04 00 00 C0 00 9E`, log RX/TX, and prompt for observed LCD text. +- `scripts\bench_connect_lcd_sequence.py --port COM5 --relay-port COM6 --no-power-cycle --prompt-before-send --prompt-screen --post-sequence-read 10 --log captures\connect-notact-to-ok.txt`: prove the recoverable path by waiting for `CONNECT:NOT ACT`, then sending the CONNECT sequence without cycling power. +- `scripts\connect_ok_matrix.py --suite minimal --prompt-observation --result-json captures\connect-ok-minimal-result.json`: run the first reproducibility pass for the 8E1 CONNECT: OK discovery. It power-cycles between cases and tests the known sequence, each single frame, and the likely primer pairs. +- `scripts\connect_ok_matrix.py --suite gap --prompt-observation --result-json captures\connect-ok-gap-result.json`: rerun the known `40 -> 80 -> C0` order with varied inter-frame gaps to test whether cadence matters. +- `scripts\connect_ok_matrix.py --suite hold --prompt-observation --result-json captures\connect-ok-hold-result.json`: rerun the known order with longer post-send observation windows to test whether CONNECT: OK is latched or needs continued traffic. - `h8536_emulator_bench_replay.py captures\bench-connect-lcd-sequence-20260525-214411.txt --assert-bench-parity`: replay a real bench log into the emulator using timed UART RX by default and intentionally fail while any response/LCD state still diverges from the bench-observed `CONNECT NOT ACT` plus `07 80 C0 60 20 5D` path. Pass `--polite-rx` for the old wait-until-consumed injection mode. - Current status: boots from `H'1000`, initializes SCI1, models the traced X24164 EEPROM bus on P9, captures P9 byte candidates, can optionally fast-path known P9 EEPROM routines, schedules FRT1/FRT2 OCIA from timer registers and `--clock-hz`, captures the ROM-driven LCD line ` CONNECT:NOT ACT`, and emits the observed heartbeat frame `00 00 00 00 80 DA`. @@ -343,6 +353,7 @@ python h8536_emulator_rx_divergence.py --help - `h8536_emulator.py`, `h8536_emulator_probe.py`, `h8536_emulator_rx_probe.py`, `h8536_emulator_rx_divergence.py`, `h8536_emulator_bench_replay.py`: emulator CLI wrappers. - `h8536_emulator_state_search.py`: emulator CONNECT state-search CLI wrapper. - `scripts/bench_connect_lcd_sequence.py`: real-device COM5/COM6 bench runner for the CONNECT LCD sequence. +- `scripts/connect_ok_matrix.py`: real-device COM5/COM6 CONNECT: OK reproducibility matrix runner for single-frame, pair, order, gap, repeat, and hold tests. - `scripts/serial_table_dump.py`: read-only COM5/COM6 command-1 table sweep for inferring live EEPROM-backed parameter state. Bench scripts default to `38400 8E1`. - `scripts/serial_scenario.py`: JSON-driven COM5/COM6 bench scenario runner for chained probes, waits, read sweeps, and ACK-on-target experiments. - `scripts/state_map_runner.py`: COM5/COM6 PT2 state-map proof runner and offline bench-log analyzer. diff --git a/docs/pt2-protocol.md b/docs/pt2-protocol.md new file mode 100644 index 0000000..e5f6308 --- /dev/null +++ b/docs/pt2-protocol.md @@ -0,0 +1,592 @@ +# PT2 Protocol Working Notes + +This document is the current working model for the serial protocol spoken by the Sony RCP-TX7 panel ROM. + +A later RCP manual mentions a "PT2 compatibility mode" for controlling the same CCU family this panel was made for. We are using "PT2" here as a practical label for this six-byte SCI1 protocol model. It is not yet a claim that every field name matches Sony's official PT2 terminology. + +## Current High-Confidence Facts + +- The real bench link is `38400 8E1`, not `38400 8N1`. +- The ROM uses H8/536 SCI1 through the MAX202 RS232 transceiver. +- Frames are six bytes long. +- The checksum is `0x5A XOR byte0 XOR byte1 XOR byte2 XOR byte3 XOR byte4`. +- The ROM validates the checksum before normal command dispatch. +- The first byte is decoded as `command = byte0 & 0x07`. +- Bytes 1 and 2 encode a logical selector. +- Bytes 3 and 4 are a 16-bit value for write-style commands. +- The protocol is stateful. Some commands only work while a continuation/session latch is live. +- `CONNECT:NOT ACT` is recoverable without a power cycle. +- `CONNECT: OK` can be reached on real hardware after using the correct `8E1` serial format. + +## Hardware Path + +SCI1 is the external serial path: + +- H8/536 pin 66, `P95/TXD`, goes to MAX202 pin 11. +- MAX202 pin 12 goes to H8/536 pin 67, `P96/RXD`. +- The ROM initializes SCI1 as async 8-bit, even parity, 1 stop. + +ROM evidence: + +- `build/rom_decompiled.asm:437`: `SCI1_SMR = H'24`. +- `build/rom_decompiled.asm:438`: `SCI1_SCR = H'3C`. +- `build/rom_decompiled.asm:439`: `SCI1_BRR = H'07`. + +Bench implication: + +- Use `38400 8E1` for all real-device captures and probes. +- Old `38400 8N1` captures mostly exercised parity/error handling and retry echoes. Do not assign normal protocol meaning to old `8N1` `07...` frames until they are reproduced under `8E1`. + +## Frame Format + +Working host/RCP frame layout: + +```text +byte0 command byte; ROM uses byte0 & 0x07 +byte1 selector page/high bits; byte1.7 is rejected by normal handlers +byte2 selector low byte +byte3 value high byte +byte4 value low byte +byte5 checksum = 0x5A XOR byte0..byte4 +``` + +Examples: + +```text +00 00 00 80 80 5A ; command 0, selector 0x000, value 0x8080 +01 00 00 00 00 5B ; command 1, read selector 0x000 +04 00 00 80 00 DE ; command 4 shape, selector 0x000, value high 0x80 +07 00 00 00 00 5D ; command 7, repeat previous finalized TX frame +``` + +## Selector Decode + +The ROM builds a raw selector from bytes 1 and 2, then maps it through `loc_622B`. + +Known decode: + +| byte1 page | byte2 range | selector | +| --- | --- | --- | +| page 0, or pages 4-7 | `00-7F` | `0x000 + byte2` | +| page 1 | `00-FF` | `0x080 + byte2` | +| page 2 | `00-7F` | `0x180 + byte2` | +| page 3 | any | `0x1FF` fallback | +| invalid range | any | `0x1FF` fallback | + +Important caveats: + +- `byte1.7` is rejected before normal command handling. +- Frames like `01 80 40 ...` may look like a selector encoding, but the normal command path rejects `byte1.7`. +- Pages 4-7 appear to alias the page-0 path when the low byte is in range. + +## Checksum + +Checksum formula: + +```python +checksum = 0x5A +for b in frame[:5]: + checksum ^= b +``` + +The ROM path: + +- RXI captures bytes into `F868-F86D`. +- Main-loop processing copies them to `F860-F865`. +- Physical error latch `FAA4.7` is checked before checksum dispatch. +- Checksum mismatch enters the retry/error path. +- Valid checksum clears retry counter `FAA6`, decodes selector, and dispatches on `byte0 & 0x07`. + +Key ROM areas: + +- RXI/ERI capture: `BB57`, `BB67`. +- Validation and checksum: `BBAB-BBF0`. +- Selector decode call: `BC01 -> 622B`. +- Command dispatch: `BC08-BC67`. + +## Command Model + +The biggest protocol lesson is that the command set has two modes: + +- Initial dispatcher: active while `FAA2 == 0`. +- Continuation dispatcher: active while `FAA2 != 0`. + +That means command numbers are not globally meaningful. Command `4`, `5`, and `6` are continuation-path commands, not normal idle commands. + +| Command | Path | Current meaning | Response | +| --- | --- | --- | --- | +| `0x00` | initial | Set primary/current value, queue selector processing | Immediate `0x04` echo-style response | +| `0x01` | initial | Read primary value table | Immediate `0x04` readback response | +| `0x02` | initial | Quiet clear/no-op style command | No immediate response seen in ROM | +| `0x04` | continuation | Set/update value without immediate response | Usually no immediate response | +| `0x05` | continuation | ACK/session-clear/pending handling | Usually no immediate response | +| `0x06` | continuation | Set secondary value table | Usually no immediate response | +| `0x07` | both | Retransmit previous finalized TX frame | Repeats last TX frame | + +## Command Details + +### Command 0: Initial Set Value + +Path: `BC69`. + +Conditions: + +- Valid checksum. +- `FAA2 == 0`. +- `byte1.7 == 0`. + +Effects: + +- Writes value into `E000 + 2*selector`. +- Writes value into `E800 + 2*selector`. +- Sets dirty flag bit 7 in `EC00 + selector`. +- Calls `BE70`, which appends the selector into the `F970` selector-processing queue. +- Sends an immediate `0x04` response through `BA26`. + +Selector-zero special case: + +- For selector `0x000`, the low byte is forced to `0x80`. +- This makes selector-zero writes look like `xx80`, not arbitrary `xxLL`. + +Important candidate: + +```text +00 00 00 80 80 5A ; selector 0 = 0x8080, strongest CONNECT OK seed +``` + +### Command 1: Read Value + +Path: `BCD7`. + +Effects: + +- Reads `E000 + 2*selector`. +- Stages a `0x04` response. +- Clears `FAA2.7`. + +Useful readback examples: + +```text +01 00 00 00 00 5B ; read selector 0x000 +01 00 40 00 00 1B ; read selector 0x040 +01 01 76 00 00 2C ; read selector 0x0F6 +``` + +Bench implication: + +- Command 1 verifies table state. +- It is not an ACK and does not enter continuation handling. +- Command 7 after command 1 can repeat the last finalized readback. + +### Command 2: Initial Clear/No-Op Candidate + +Path: `BD04`. + +Effects: + +- Clears `FAA2.7`. +- Returns without staging an obvious response. + +Meaning is still unclear. Treat it as a quiet/session-control candidate, not as a data write. + +### Command 4: Continuation Set Value + +Path: `BD0E`. + +Conditions: + +- Valid checksum. +- `FAA2 != 0`. +- Command bit 2 set. +- `byte1.7 == 0`. + +Effects: + +- Writes value into `E000 + 2*selector`. +- Selector zero also updates `E800`. +- Nonzero selectors set dirty flag bit 7 in `EC00 + selector`. +- Can mirror/persist mapped nonzero selectors through the `F400`/EEPROM path when `F76E.7` allows it. +- If `FAA2.3` was set by a queued report, command 4 can advance `F9B5` to consume that report. +- Clears `FAA3` and `FAA2` before exit. + +Known CONNECT test frames: + +```text +04 00 00 40 00 1E +04 00 00 80 00 DE +04 00 00 C0 00 9E +``` + +ROM caveat: + +- A standalone command 4 from a truly idle `FAA2 == 0` state should not reach `BD0E`. +- Bench evidence now proves the panel can still recover from `CONNECT:NOT ACT` without power cycling, so visible `NOT ACT` is not equivalent to "all serial continuation state is impossible". + +### Command 5: Continuation ACK/Clear Candidate + +Path: `BD80`. + +Conditions: + +- Continuation path only. + +Effects: + +- Usually no immediate response. +- Selectors `0x006C`, `0x006D`, and `0x006E` call `BE70`. +- If `F731.7` is set, selectors `0x006B`, `0x0096`, `0x0097`, `0x00C6`, and `0x00F8` clear `F731.7/F790.7`. +- If `FAA2.3` was set by a queued report, command 5 can advance `F9B5`. +- Clears `FAA3` and `FAA2` before exit. + +Bench implication: + +- Command 5 is not a generic always-live ACK. +- It only has ACK-like meaning when the continuation latch is live. + +### Command 6: Continuation Secondary Set + +Path: `BDDB`. + +Effects: + +- Writes value into `E400 + 2*selector`. +- Sets dirty flag bit 6 in `EC00 + selector`. +- Can advance queued-report state when `FAA2.3` is live. +- Clears `FAA3` and `FAA2`. + +### Command 7: Repeat Previous TX + +Path: `BE05`. + +Effects: + +- Copies the previous finalized TX frame back into staging. +- Sends it again through `BA26`. +- Works from initial and continuation paths. + +Bench implication: + +- Command 7 is useful as a "what did you last finalize?" probe. +- It does not prove a hidden continuation token by itself. + +## RCP Transmit Frames + +The TX side uses the same six-byte checksum model. + +TX staging: + +- `F850-F854`: staging bytes. +- `F858-F85C`: finalized bytes. +- `F85D`: computed checksum. +- `BA26`: finalizes and starts SCI1 TX. +- TXI sends bytes 1-5 after the first TDR write. + +Known RCP-origin frames: + +| Frame | Confidence | Current meaning | +| --- | --- | --- | +| `00 00 00 00 80 DA` | high | idle heartbeat / selector-zero report | +| `00 00 07 80 00 DD` | medium-high | observed CAM POWER button/report candidate | +| `00 00 15 80 00 CF` | medium-high | observed CALL on/report candidate | +| `00 00 15 00 00 4F` | medium-high | observed CALL off/report candidate | +| `02 00 02 00 00 5A` | medium | emulator CONNECT OK path response candidate | + +Heartbeat: + +- Idle frame: `00 00 00 00 80 DA`. +- Observed cadence: about 700 ms. +- ROM path: `loc_4067` enqueues selector 0, `loc_BAF2/BB08` dequeues it, `BB1C/BB20/BB2B` stages TX bytes, `BA26` emits the frame. +- FRT2 timing model: `TCR=H'02`, `OCRA=H'7A12`, modeled as a 100 ms tick at 10 MHz; `F9C4=0x07` gives about 700 ms post-send heartbeat delay. + +## Retry/Error 07 Frames + +`07...` frames are easy to misread. + +The ROM can generate a `0x07` retry/error echo when: + +- A physical RX error occurs, or +- A checksum mismatch occurs, and +- `FAA5.7` is set, and +- Retry count `FAA6` is below two. + +Path: + +- `BE29` retry gate. +- `BE4D` stages `F850=0x07`. +- `F851-F854` copy host `RX[1:4]`. +- `BA26` sends it. + +Bench implication: + +- A visible `07...` frame is not automatically a normal status report or ACK. +- Old `8N1` captures produced many misleading `07...` frames because parity errors exercised this path. + +## Table Model + +The ROM behaves like a selector-indexed state machine. The CCU likely seeds values, and the RCP updates LCD/lamp/control behavior from those values. + +| Table | Range | Role | +| --- | --- | --- | +| Primary value table | `E000-E3FF` | Command 0/4 writes, command 1 reads | +| Secondary value table | `E400-E7FF` | Command 6 writes | +| Current/report table | `E800-EBFF` | Used when RCP builds outbound report frames | +| Dirty/flag table | `EC00-EFFF` | Per-selector flags, bit7 for primary writes, bit6 for secondary writes | +| EEPROM/shadow | `F400-F4FF` | Optional mapped persistence/config surface | + +Important details: + +- Selector zero is special in command 0 and command 4. +- Command 0 writes both `E000` and `E800`. +- Command 4 writes `E800` only for selector zero in the current ROM evidence. +- `BAF2` reads `E800 + 2*selector` when building autonomous RCP reports. +- `BE70/F970` is a selector-processing queue. +- `3E54/F870` is a separate serial-visible report queue. + +Do not mix up: + +- `F970`: "process this selector internally". +- `F870`: "send this selector/report over serial". + +## State And Queues + +Important RAM/state bytes: + +| Address | Working name | Meaning | +| --- | --- | --- | +| `FAA2.7` | RX command in progress | Set on initial parse, cleared on exits | +| `FAA2.3` | queued report continuation needed | Set after autonomous report send | +| `FAA3.7` | pending resend mask | Set after queued report send | +| `FAA4.7` | RX physical error latch | Set by SCI1 ERI | +| `FAA5.7` | RX session gate | Set while `F9C5` is alive after complete RX | +| `FAA6` | retry counter | Limits retry/error echoes | +| `F9C1` | inter-byte timeout | Reloaded on RXI | +| `F9C3` | RX byte count | Counts up to six | +| `F9C4` | heartbeat/report cadence gate | Controls idle heartbeat enqueue | +| `F9C5` | RX/session timeout | Loaded with `0x14` after full RX frame | +| `F9B0/F9B5` | serial report queue cursors | Drive `F870`/`BAF2` | +| `F9B4/F9B9` | selector-processing queue cursors | Drive `F970`/`2806` | + +Session expiry: + +- A complete six-byte RX frame loads `F9C5=0x14`. +- FRT2 decrements `F9C5`. +- When `F9C5` reaches zero, `loc_3FEF` can clear queues/session state. +- If `FAA5.7` was set, expiry calls `loc_400C`. +- `loc_400C` clears connection/session RAM and refreshes inactive display state. + +This explains why random traffic tends to settle back to `CONNECT:NOT ACT`. + +## CONNECT State + +`CONNECT` strings are built through the LCD driver, not received as literal serial text. + +Known LCD path: + +```text +FAF0-FAFF line buffer -> 3ECC -> 3F28 -> 3F40 -> F200/F201 LCD ports +``` + +ROM/emulator findings: + +- Boot/no-active-session display can show `CONNECT:NOT ACT`. +- Direct emulator entry at `loc_2CB9` with `E000[0]=0x8080` and `F730=0` reaches `CONNECT: OK`. +- Queued selector-zero path reaches OK when: + - `F970[0]=0` + - `F9B9=0` + - `F9B4=1` + - `E000[0]=0x8080` + - `F730=0` +- Selector zero dispatches through the `28A6` jump table into the CONNECT handler window. + +Bench findings: + +- Correct `8E1` serial format made the CONNECT path work on real hardware. +- Real hardware can recover from `CONNECT:NOT ACT` to `CONNECT: OK` without a power cycle. +- Successful active-looking state included: + - `CONNECT: OK` + - CAM POWER lamp illuminated + - numeric readouts illuminated as `----` +- Matrix tests show that cadence matters: + - `40 -> 80 -> C0` with 10 ms, 50 ms, or 150 ms inter-frame gaps stayed at `CONNECT:NOT ACT` after a fresh power-cycle test. + - The same order with 700 ms and 1.5 s inter-frame gaps produced `CONNECT: OK` before falling back to `CONNECT:NOT ACT`. + - At 700 ms gaps, no single frame worked by itself. + - At 700 ms gaps, every tested two-frame pair worked: `40 -> 80`, `80 -> C0`, and `40 -> C0`. + - A repeated identical pair also worked: `80 -> 80` at about 700 ms produced eight `02 00 02 00 00 5A` OK-path responses, then heartbeat traffic resumed. + - The no-power-cycle recovery test from an already visible `CONNECT:NOT ACT` state produced repeated `02 00 02 00 00 5A` OK-path responses, then returned to heartbeat traffic. + +Current interpretation: + +- `CONNECT:NOT ACT` is a normal no-active-session/cleared-state display, not a terminal latch. +- `CONNECT: OK` is table/state driven, probably selector-zero active/connect state. +- `0x8080` at selector zero is the strongest known active/connect value. +- The panel likely expects the CCU to keep seeding or refreshing state after entering OK. +- The working fake-CCU sequence is probably not "three frames as fast as possible"; it appears to need CCU-like cadence or a live session window, roughly on the heartbeat/report timescale. +- A single selector-zero continuation-shaped frame is insufficient in the current tests; two selector-zero writes at the working cadence are enough. They do not need to carry different values, because `80 -> 80` also worked. + +## Candidate CCU Seed Values + +These are syntactically valid host frames produced from ROM table mining. Use them as candidate fake-CCU state seeds, not as final protocol truth. + +| Selector | Candidate value | Frame | Why it matters | +| --- | --- | --- | --- | +| `0x000` | `0x8080` | `00 00 00 80 80 5A` | strongest CONNECT OK seed | +| `0x003` | `0x8000` | `00 00 03 80 00 D9` | ROM default enabled bit candidate | +| `0x040` | `0xFFFF` | `00 00 40 FF FF 1A` | ROM default all-ones/status block candidate | +| `0x040` | `0x4030` | `00 00 40 40 30 6A` | bench-touched 0x40 family value | +| `0x0F6` | `0x2000` | `00 01 76 20 00 0D` | `loc_48FA` tests `E1EC.13` and can enqueue report `0x00F6` | + +Readbacks: + +```text +01 00 00 00 00 5B ; selector 0x000 +01 00 03 00 00 58 ; selector 0x003 +01 00 40 00 00 1B ; selector 0x040 +01 01 76 00 00 2C ; selector 0x0F6 +``` + +## Observed Button/Panel Reports + +Before the protocol format was corrected, the RCP appeared to emit only a few report families by itself: + +```text +00 00 00 00 80 DA ; heartbeat +00 00 07 80 00 DD ; CAM POWER candidate +00 00 15 80 00 CF ; CALL on candidate +00 00 15 00 00 4F ; CALL off candidate +``` + +Current interpretation: + +- The RCP can report some panel events. +- Many other controls probably need CCU-provided state before they become reportable or meaningful. +- The CCU likely streams display/lamp/readout state to the RCP, while the RCP reports operator changes back. + +## EEPROM And Board Config + +The P9 bus is not the external PT2 serial link. It is a bit-banged EEPROM/config path: + +- H8 pin 62, `P91`, reaches X24164 pin 6 `SCL`. +- H8 pin 68, `P97`, reaches shared X24164 pin 5 `SDA`. + +ROM findings: + +- `loc_40BB` checks `P7DR.7` and `F402 == H'6B6F` before deciding whether to default EEPROM/shadow tables. +- `loc_4103` writes ROM default words through `BFE0`. +- `loc_41D2` reads sixteen 8-byte records into `F7B0-F82F`. +- Command 4 can persist mapped serial table writes when `F76E.7` is set. + +Current interpretation: + +- EEPROM stores panel/config/default state. +- It can affect startup and option behavior. +- After the `8E1` discovery, EEPROM is less likely to be the fundamental reason CONNECT failed, but it can still influence which selectors/features are active. + +## Known Useful Bench Commands + +Minimal CONNECT sequence runner: + +```powershell +.\.venv\Scripts\python.exe scripts\bench_connect_lcd_sequence.py --port COM5 --relay-port COM6 --parity E --prompt-screen +``` + +Recover from `CONNECT:NOT ACT` without power cycling: + +```powershell +.\.venv\Scripts\python.exe scripts\bench_connect_lcd_sequence.py --port COM5 --relay-port COM6 --no-power-cycle --parity E --prompt-before-send --prompt-screen --post-sequence-read 10 --log captures\connect-notact-to-ok.txt +``` + +Run the reproducibility/minimization matrix: + +```powershell +.\.venv\Scripts\python.exe scripts\connect_ok_matrix.py --suite minimal --parity E --prompt-observation --result-json captures\connect-ok-minimal-result.json +``` + +Test timing/cadence: + +```powershell +.\.venv\Scripts\python.exe scripts\connect_ok_matrix.py --suite gap --parity E --prompt-observation --result-json captures\connect-ok-gap-result.json +``` + +Test whether OK is held: + +```powershell +.\.venv\Scripts\python.exe scripts\connect_ok_matrix.py --suite hold --parity E --prompt-observation --result-json captures\connect-ok-hold-result.json +``` + +Current matrix result summary: + +```text +minimal suite at 150 ms gaps: all cases stayed CONNECT NOT ACT +gap suite at 10/50/150 ms: stayed CONNECT NOT ACT +gap suite at 700 ms and 1.5 s: CONNECT OK, then CONNECT NOT ACT +minimal suite at 700 ms gaps: singles stayed CONNECT NOT ACT; all pairs reached CONNECT OK then CONNECT NOT ACT +repeated 80 -> 80 at about 700 ms: CONNECT OK responses, then CONNECT NOT ACT +hold suite at 150 ms gaps: stayed CONNECT NOT ACT +no-power-cycle NOT ACT recovery: CONNECT OK responses observed, then heartbeat resumes +``` + +Read table state: + +```powershell +.\.venv\Scripts\python.exe scripts\serial_table_dump.py --port COM5 --relay-port COM6 --start 0x000 --count 0x200 --parity E --log captures\table-read-8e1.txt +``` + +## Current Best Model Of Normal CCU/RCP Communication + +The protocol looks like a shared selector table with stateful reporting: + +1. CCU sends initial state seeds into selector tables, especially selector zero and status/display selectors. +2. RCP updates LCD, lamps, and numeric readouts from selector dispatch handlers. +3. RCP emits heartbeat/report frames from `E800` via the `F870 -> BAF2` report queue. +4. Host/CCU uses continuation commands to consume/ACK/update live reports while `FAA2/FAA3` gates are active. +5. If the CCU stops talking or session state expires, RCP clears volatile session state and returns to `CONNECT:NOT ACT`. + +This fits the real panel behavior: + +- Idle panel emits heartbeat. +- Correct fake-CCU traffic can wake it to `CONNECT: OK`. +- Without richer CCU state, readouts illuminate but show placeholders like `----`. + +## What Is Still Unknown + +- The official PT2 names for commands and selectors. +- Which selectors drive every lamp and numeric display. +- Whether the CCU sends a periodic refresh stream after CONNECT OK. +- Exact hold time before OK falls back to NOT ACT, if no refresh traffic follows. +- Whether command 4 CONNECT success depends on an existing continuation latch, timing, or a side effect created by earlier frames. +- How EEPROM option bits change selector behavior. +- Whether all old visible `07...` families can be reproduced under `8E1`. + +## Next Best Refinements + +1. Finish the CONNECT matrix runs: + - rerun `hold` with 700 ms gaps to measure how long OK remains without refresh traffic. +2. Test whether periodic `80` refreshes hold CONNECT OK, and find the longest safe refresh interval. +3. Dump selector table state before and after CONNECT OK. +4. Seed selectors `0x003`, `0x040`, and `0x0F6` after selector-zero OK and watch lamps/readouts. +5. Mine selector dispatch handlers for known UI text terms: `IRIS`, `GAIN`, `SHUTTER`, `BARS`, `BLACK`, `CALL`, `AUTO`, `DIAG`. +6. Build a fake-CCU streamer that repeatedly writes a small selector set and logs which RCP reports appear. + +## Source Files And Reports + +Generated evidence: + +- `build/rom_decompiled.asm` +- `build/rom_rx_branch_trace.txt` +- `build/rom_ccu_seed_hints.txt` +- `build/rom_eeprom_layout.txt` +- `build/rom_table_xrefs.txt` +- `build/connect-state-search-ok.json` + +Useful tools: + +- `h8536_protocol_trace.py` +- `h8536_protocol_capture.py` +- `h8536_rx_branch_trace.py` +- `h8536_ccu_seed_hints.py` +- `h8536_emulator_rx_probe.py` +- `h8536_emulator_state_search.py` +- `scripts/bench_connect_lcd_sequence.py` +- `scripts/connect_ok_matrix.py` +- `scripts/serial_table_dump.py` +- `scripts/serial_scenario.py` diff --git a/h8536/connect_ok_matrix.py b/h8536/connect_ok_matrix.py new file mode 100644 index 0000000..050b8be --- /dev/null +++ b/h8536/connect_ok_matrix.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +import argparse +import json +import sys +import time +from dataclasses import dataclass +from datetime import datetime +from itertools import permutations +from pathlib import Path +from typing import Any, TextIO + +from .bench_connect_lcd import ( + BenchLogger, + COMMAND7_REPEAT_FRAME, + FrameDetector, + add_serial_format_args, + _import_serial, + open_device_serial, + _read_for, + _relay_command, + _relay_settle, + _send_frame, + _wait_for_ready, + format_frame, + frame_checksum_ok, + serial_format_label, +) + + +FRAME_40_DXC = bytes.fromhex("04000040001E") +FRAME_80_OK = bytes.fromhex("0400008000DE") +FRAME_C0_PRIORITY = bytes.fromhex("040000C0009E") + +NAMED_FRAMES = { + "40": FRAME_40_DXC, + "80": FRAME_80_OK, + "c0": FRAME_C0_PRIORITY, +} + + +@dataclass(frozen=True) +class MatrixCase: + name: str + frames: tuple[bytes, ...] + gap: float = 0.150 + repeat: int = 1 + post_read: float = 3.0 + note: str = "" + + +def default_log_path(suite: str) -> Path: + safe_suite = "".join(char if char.isalnum() or char in "-_" else "-" for char in suite) + return Path("captures") / f"connect-ok-matrix-{safe_suite}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.txt" + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Run a reproducibility matrix for the CONNECT: OK bench behavior." + ) + parser.add_argument("--suite", choices=("baseline", "minimal", "single", "pair", "order", "gap", "repeat", "hold", "all"), default="minimal") + parser.add_argument("--case", action="append", help="run only matching case names; repeatable") + parser.add_argument("--limit", type=int, help="run only the first N selected cases") + parser.add_argument("--port", default="COM5", help="RS232 serial port connected to the RCP") + parser.add_argument("--baud", type=int, default=38400, help="RCP serial baud rate") + add_serial_format_args(parser) + parser.add_argument("--relay-port", default="COM6", help="Pico relay serial port") + parser.add_argument("--relay-baud", type=int, default=115200, help="Pico relay serial baud rate") + parser.add_argument("--no-power-cycle", action="store_true", help="do not power-cycle between cases") + parser.add_argument("--power-off-command", default="off", help="relay command used to remove DUT power") + parser.add_argument("--power-on-command", default="on", help="relay command used to apply DUT power") + parser.add_argument("--off-seconds", type=float, default=1.5, help="seconds to hold DUT power off between cases") + parser.add_argument("--relay-settle", type=float, default=2.0, help="seconds to wait after opening the relay port") + parser.add_argument("--ready-timeout", type=float, default=10.0, help="seconds to wait for heartbeat after power-on") + parser.add_argument("--ready-heartbeats", type=int, default=2, help="heartbeat frames to observe before sending") + parser.add_argument("--require-ready", action="store_true", help="abort a case if readiness heartbeats are not observed") + parser.add_argument("--pre-case-drain", type=float, default=0.250, help="seconds to drain/log RX before sending each case") + parser.add_argument("--post-case-read", type=float, default=3.0, help="default seconds to listen after each case") + parser.add_argument("--default-gap", type=float, default=0.150, help="default seconds to listen between frames") + parser.add_argument("--gaps", default="0.010,0.050,0.150,0.700,1.500", help="comma-separated gaps for --suite gap") + parser.add_argument("--repeat-counts", default="1,2,4", help="comma-separated repeat counts for --suite repeat") + parser.add_argument("--hold-seconds", default="3,8,20", help="comma-separated post-read times for --suite hold") + parser.add_argument("--command7-after", action="store_true", help="send command-7 previous-frame probe after each case") + parser.add_argument("--sync", choices=("checksum", "fixed"), default="checksum", help="RX frame sync strategy") + parser.add_argument("--prompt-observation", action="store_true", help="prompt for observed LCD/lamp state after each case") + parser.add_argument("--pause-between-cases", action="store_true", help="wait for Enter before starting the next case") + parser.add_argument("--log", type=Path, help="capture log path") + parser.add_argument("--result-json", type=Path, help="write machine-readable case summary") + parser.add_argument("--dry-run", action="store_true", help="print selected cases without opening serial ports") + return parser + + +def main(argv: list[str] | None = None, *, stdout: TextIO = sys.stdout) -> int: + args = build_arg_parser().parse_args(argv) + cases = select_cases(args) + log_path = args.log or default_log_path(args.suite) + + if args.dry_run: + _print_dry_run(args, cases, log_path, stdout) + return 0 + + serial = _import_serial() + logger = BenchLogger(log_path, stdout=stdout) + results: list[dict[str, Any]] = [] + try: + logger.emit("CONNECT: OK bench matrix") + logger.emit( + f"suite={args.suite} cases={len(cases)} device={args.port} {args.baud} {serial_format_label(args)} " + f"relay={args.relay_port} {args.relay_baud} sync={args.sync}" + ) + logger.emit(f"log={log_path}") + with open_device_serial(serial, args) as device: + relay = None + try: + if not args.no_power_cycle: + relay = serial.Serial(args.relay_port, args.relay_baud, timeout=0.25) + _relay_settle(relay, args.relay_settle, logger) + for index, case in enumerate(cases, start=1): + if args.pause_between_cases and index > 1: + input(f"Press Enter to start case {index}/{len(cases)}: {case.name}") + result = _run_case(args, device, relay, logger, case, index, len(cases)) + results.append(result) + if args.require_ready and not result["ready"]: + logger.event("ABORT readiness was required") + break + finally: + if relay is not None: + relay.close() + _emit_matrix_summary(logger, results) + if args.result_json: + _write_result_json(args.result_json, log_path, args, results) + return 0 + finally: + logger.close() + + +def select_cases(args: argparse.Namespace) -> list[MatrixCase]: + cases = build_cases( + args.suite, + default_gap=args.default_gap, + default_post_read=args.post_case_read, + gaps=_parse_float_csv(args.gaps), + repeat_counts=_parse_int_csv(args.repeat_counts), + hold_seconds=_parse_float_csv(args.hold_seconds), + ) + if args.case: + filters = [item.lower() for item in args.case] + cases = [case for case in cases if any(fragment in case.name.lower() for fragment in filters)] + if args.limit is not None: + cases = cases[: max(0, args.limit)] + if not cases: + raise SystemExit("no matrix cases selected") + return cases + + +def build_cases( + suite: str, + *, + default_gap: float = 0.150, + default_post_read: float = 3.0, + gaps: list[float] | None = None, + repeat_counts: list[int] | None = None, + hold_seconds: list[float] | None = None, +) -> list[MatrixCase]: + gaps = gaps or [0.010, 0.050, 0.150, 0.700, 1.500] + repeat_counts = repeat_counts or [1, 2, 4] + hold_seconds = hold_seconds or [3.0, 8.0, 20.0] + + def case(name: str, keys: tuple[str, ...], *, gap: float = default_gap, repeat: int = 1, post_read: float = default_post_read, note: str = "") -> MatrixCase: + return MatrixCase(name=name, frames=tuple(NAMED_FRAMES[key] for key in keys), gap=gap, repeat=repeat, post_read=post_read, note=note) + + baseline = [ + case("baseline-40-80-c0", ("40", "80", "c0"), note="known emulator-derived order"), + ] + single = [ + case("single-40", ("40",), note="tests whether the DXC/low path alone wakes the panel"), + case("single-80", ("80",), note="tests whether the OK/high path alone wakes the panel"), + case("single-c0", ("c0",), note="tests whether the priority-combined path alone wakes the panel"), + ] + pair = [ + case("pair-40-80", ("40", "80"), note="tests primer-then-OK without the C0 branch"), + case("pair-80-c0", ("80", "c0"), note="tests OK followed by priority branch"), + case("pair-40-c0", ("40", "c0"), note="tests DXC/low followed by priority branch"), + case("pair-80-40", ("80", "40"), note="tests reverse OK/DXC ordering"), + case("pair-c0-80", ("c0", "80"), note="tests C0 as primer for OK"), + case("pair-c0-40", ("c0", "40"), note="tests C0 as primer for DXC"), + ] + order = [ + case("order-" + "-".join(keys), keys, note="three-frame order/permutation test") + for keys in permutations(("40", "80", "c0"), 3) + ] + gap_cases = [ + case(f"gap-{_gap_name(gap)}-40-80-c0", ("40", "80", "c0"), gap=gap, note="same order with varied inter-frame delay") + for gap in gaps + ] + repeat_cases = [ + case(f"repeat-{count}x-40-80-c0", ("40", "80", "c0"), repeat=count, note="tests whether repeated cadence is required") + for count in repeat_counts + ] + hold_cases = [ + case(f"hold-{_gap_name(seconds)}s-40-80-c0", ("40", "80", "c0"), post_read=seconds, note="tests whether CONNECT OK persists without continued traffic") + for seconds in hold_seconds + ] + + suites = { + "baseline": baseline, + "minimal": baseline + [single[1], single[0], single[2]] + pair[:3], + "single": single, + "pair": pair, + "order": order, + "gap": gap_cases, + "repeat": repeat_cases, + "hold": hold_cases, + "all": baseline + single + pair + order + gap_cases + repeat_cases + hold_cases, + } + return _dedupe_cases(suites[suite]) + + +def _run_case( + args: argparse.Namespace, + device: Any, + relay: Any | None, + logger: BenchLogger, + case: MatrixCase, + index: int, + total: int, +) -> dict[str, Any]: + detector = FrameDetector(sync_mode=args.sync) + logger.emit() + logger.emit(f"CASE {index}/{total} {case.name}") + logger.emit(f"note={case.note or '(none)'}") + logger.emit(f"gap={case.gap:.3f}s repeat={case.repeat} post_read={case.post_read:.3f}s") + for frame_index, frame in enumerate(case.frames, start=1): + logger.emit(f"case_frame[{frame_index}]={format_frame(frame)} checksum_ok={int(frame_checksum_ok(frame))}") + + if args.no_power_cycle: + device.reset_input_buffer() + logger.event("POWER_CYCLE skipped") + else: + if relay is None: + raise SystemExit("relay was not opened") + _relay_command(relay, args.power_off_command, logger) + time.sleep(max(0.0, args.off_seconds)) + device.reset_input_buffer() + _relay_command(relay, args.power_on_command, logger) + + ready = _wait_for_ready(device, detector, logger, args.ready_timeout, args.ready_heartbeats) + if args.require_ready and not ready: + observation = _prompt_observation(args, logger, case) + return _case_result(case, detector, ready=ready, observation=observation) + + if args.pre_case_drain > 0: + logger.event(f"DRAIN before case {args.pre_case_drain:.3f}s") + _read_for(device, detector, logger, args.pre_case_drain) + + for repeat_index in range(max(1, case.repeat)): + if case.repeat > 1: + logger.event(f"BEGIN case_repeat {repeat_index + 1}/{case.repeat}") + for frame_index, frame in enumerate(case.frames, start=1): + _send_frame(device, frame, logger, f"{case.name}.r{repeat_index + 1}.f{frame_index}") + _read_for(device, detector, logger, case.gap) + + if args.command7_after: + _send_frame(device, COMMAND7_REPEAT_FRAME, logger, f"{case.name}.command7_after") + _read_for(device, detector, logger, case.gap) + + if case.post_read > 0: + logger.event(f"POST_READ {case.post_read:.3f}s") + _read_for(device, detector, logger, case.post_read) + + observation = _prompt_observation(args, logger, case) + result = _case_result(case, detector, ready=ready, observation=observation) + logger.event( + f"CASE_RESULT {case.name} ready={int(ready)} rx_frames={result['rx_frames']} " + f"labels={json.dumps(result['labels'], sort_keys=True)}" + ) + return result + + +def _prompt_observation(args: argparse.Namespace, logger: BenchLogger, case: MatrixCase) -> str: + if not args.prompt_observation: + return "" + prompt = f"{case.name}: LCD/lamps/readouts observation, or Enter to skip: " + observation = input(prompt).strip() + logger.event(f"OBSERVATION {case.name}: {observation or '(no note)'}") + return observation + + +def _case_result(case: MatrixCase, detector: FrameDetector, *, ready: bool, observation: str) -> dict[str, Any]: + return { + "name": case.name, + "note": case.note, + "frames": [format_frame(frame) for frame in case.frames], + "gap": case.gap, + "repeat": case.repeat, + "post_read": case.post_read, + "ready": ready, + "rx_frames": len(detector.frames), + "labels": dict(detector.labels), + "resync_events": detector.resync_events, + "dropped_bytes": detector.dropped_bytes, + "trailing_unframed_bytes": len(detector.buffer), + "observation": observation, + } + + +def _emit_matrix_summary(logger: BenchLogger, results: list[dict[str, Any]]) -> None: + logger.emit() + logger.emit("Matrix Summary") + logger.emit(f"cases={len(results)}") + for result in results: + labels = ", ".join(f"{key}={value}" for key, value in sorted(result["labels"].items())) or "no_rx_frames" + note = result["observation"] or "(no observation)" + logger.emit( + f"{result['name']}: ready={int(result['ready'])} rx_frames={result['rx_frames']} " + f"{labels} observation={note}" + ) + + +def _write_result_json(path: Path, log_path: Path, args: argparse.Namespace, results: list[dict[str, Any]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "suite": args.suite, + "log": str(log_path), + "serial_format": f"{args.baud} {serial_format_label(args)}", + "cases": results, + } + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _print_dry_run(args: argparse.Namespace, cases: list[MatrixCase], log_path: Path, stdout: TextIO) -> None: + print(f"suite={args.suite}", file=stdout) + print(f"cases={len(cases)}", file=stdout) + print(f"device={args.port} {args.baud} {serial_format_label(args)}", file=stdout) + print(f"relay={args.relay_port} {args.relay_baud}", file=stdout) + print(f"power_cycle_between_cases={int(not args.no_power_cycle)}", file=stdout) + print(f"log={log_path}", file=stdout) + for index, case in enumerate(cases, start=1): + print( + f"case[{index}]={case.name} gap={case.gap:.3f}s repeat={case.repeat} " + f"post_read={case.post_read:.3f}s", + file=stdout, + ) + for frame in case.frames: + print(f" frame={format_frame(frame)} checksum_ok={int(frame_checksum_ok(frame))}", file=stdout) + if case.note: + print(f" note={case.note}", file=stdout) + if args.command7_after: + print(f"command7_after={format_frame(COMMAND7_REPEAT_FRAME)}", file=stdout) + + +def _dedupe_cases(cases: list[MatrixCase]) -> list[MatrixCase]: + seen: set[str] = set() + deduped: list[MatrixCase] = [] + for case in cases: + if case.name in seen: + continue + seen.add(case.name) + deduped.append(case) + return deduped + + +def _parse_float_csv(text: str) -> list[float]: + return [float(part.strip()) for part in text.split(",") if part.strip()] + + +def _parse_int_csv(text: str) -> list[int]: + return [int(part.strip(), 0) for part in text.split(",") if part.strip()] + + +def _gap_name(value: float) -> str: + if value >= 1: + return f"{value:.1f}".rstrip("0").rstrip(".").replace(".", "p") + milliseconds = int(round(value * 1000)) + return f"{milliseconds}ms" + + +__all__ = [ + "FRAME_40_DXC", + "FRAME_80_OK", + "FRAME_C0_PRIORITY", + "MatrixCase", + "build_arg_parser", + "build_cases", + "main", + "select_cases", +] diff --git a/scripts/connect_ok_matrix.py b/scripts/connect_ok_matrix.py new file mode 100644 index 0000000..1ea2aed --- /dev/null +++ b/scripts/connect_ok_matrix.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Bench runner for CONNECT: OK reproducibility matrix tests.""" + +import sys +from pathlib import Path + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from h8536.connect_ok_matrix import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_connect_ok_matrix.py b/tests/test_connect_ok_matrix.py new file mode 100644 index 0000000..d464417 --- /dev/null +++ b/tests/test_connect_ok_matrix.py @@ -0,0 +1,45 @@ +import io +import unittest + +from h8536.connect_ok_matrix import build_cases, main + + +class ConnectOkMatrixTest(unittest.TestCase): + def test_minimal_suite_starts_with_known_sequence_and_single_ok(self): + cases = build_cases("minimal") + + self.assertEqual(cases[0].name, "baseline-40-80-c0") + self.assertEqual(cases[1].name, "single-80") + self.assertIn("pair-40-80", [case.name for case in cases]) + + def test_gap_suite_uses_requested_inter_frame_gaps(self): + cases = build_cases("gap", gaps=[0.01, 0.7]) + + self.assertEqual([case.name for case in cases], ["gap-10ms-40-80-c0", "gap-700ms-40-80-c0"]) + self.assertEqual([case.gap for case in cases], [0.01, 0.7]) + + def test_dry_run_defaults_to_even_parity_and_lists_frames(self): + stdout = io.StringIO() + + exit_code = main(["--dry-run", "--suite", "minimal", "--limit", "2"], stdout=stdout) + + self.assertEqual(exit_code, 0) + output = stdout.getvalue() + self.assertIn("device=COM5 38400 8E1", output) + self.assertIn("case[1]=baseline-40-80-c0", output) + self.assertIn("case[2]=single-80", output) + self.assertIn("frame=04 00 00 80 00 DE checksum_ok=1", output) + + def test_case_filter_selects_named_subset(self): + stdout = io.StringIO() + + exit_code = main(["--dry-run", "--suite", "all", "--case", "pair-c0-80"], stdout=stdout) + + self.assertEqual(exit_code, 0) + output = stdout.getvalue() + self.assertIn("cases=1", output) + self.assertIn("case[1]=pair-c0-80", output) + + +if __name__ == "__main__": + unittest.main()