diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 5b5c400..1376122 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -8,6 +8,26 @@ permissions: contents: read jobs: + # Separate top-level job on purpose ([L5]): the `build` job below is an + # 11-entry matrix, so a step added there would run the host test eleven times. + # This needs no toolchain beyond the runner's stock g++ and gates every push + # alongside the firmware builds. tools/ is invisible to every firmware build + # (build_src_filter is relative to src_dir and no env adds tools/), so the + # test file cannot affect the matrix. + host-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build and run the nonce-window host test + # -fsanitize=undefined is not decoration: it is what catches the `x << 64` + # UB in the bitmap shift automatically rather than relying on the test + # author to predict it. See Decision D in + # docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. + run: | + g++ -std=c++17 -Wall -Wextra -Werror -O1 -fsanitize=undefined,address \ + tools/test_nonce_window.cpp -o /tmp/test_nonce_window + /tmp/test_nonce_window + # The environment list lives in .github/firmware-targets.json, shared with # release.yml. Keeping one list means CI can't build a target the release # forgets to ship (which is how esp32-N4 went unreleased) or vice versa. diff --git a/docs/FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md b/docs/FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md new file mode 100644 index 0000000..b3b85a5 --- /dev/null +++ b/docs/FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md @@ -0,0 +1,706 @@ +# Adversarial Review — PLAN_FREEZE_PROOFING_2026-07-26 + +**Date:** 2026-07-26 +**Reviewing:** `docs/PLAN_FREEZE_PROOFING_2026-07-26.md` (branch `debug/ble-hardening`) +**Scope:** `Firmware` repo — nRF52840 (Bluefruit) + ESP32-S3/C6/C3/classic (NimBLE-Arduino 2.5.0) + +> All Critical/High/Medium corrections below have been folded back into +> `PLAN_FREEZE_PROOFING_2026-07-26.md` (revised 2026-07-26), tagged `[C1]`…`[X7]`. +> This document is retained as the rationale record — read it before re-litigating any +> plan decision, especially the phase ordering and the progress-stamp definition. + +--- + +## Verdict + +**Sound in shape, wrong in several load-bearing details — ship only with the Critical +corrections below.** The diagnosis (§Context items 1–5) is accurate and I verified four of +the five wedge mechanisms against source. The phase decomposition is sensible and most of +the platform facts the author claims to have "verified this session" hold up. + +But the plan has **four Critical defects**, two of which mean the headline deliverable +does not work: + +- **The 10-minute supervisor cannot fire in the exact wedge it was designed for**, because + `markSessionProgress()` is stamped on *command dispatch* and *successful notify* — both + of which keep ticking while the device answers every command `RESP_AUTH_REQUIRED`. +- **Phase 4's second-central refusal is a no-op on the wire**: `BLE_ERR_CONN_LIMIT` (0x09) + is not a legal `HCI_Disconnect` reason, so `ble_gap_terminate` is rejected by the + controller. +- **The `pwrmgmLock` 10 s steal is shorter than a legitimate lock hold** and destroys the + lock's mutual exclusion when it fires. +- **Phase 4's refusal path re-enters the shared disconnect-cleanup flag**, which on the two + non-WiFi envs has no owner guard at all. + +None of these require restructuring the plan. All four are surgical. + +--- + +## CRITICAL + +### C1 — Progress stamps on "command dispatch" and "successful notify" make the supervisor blind to the primary wedge + +**Plan element:** Phase 6 — *"Progress stamps (`markSessionProgress()`): command dispatch, +pipe frame accept, successful notify, LAN frame dispatch, refresh completion."* + +**Why it breaks:** The wedge the plan opens with (§Context item 1) is: `integrity_failures +>= 3` → `clearEncryptionSession()` mid-transfer, after which +`src/communication.cpp:664-670` answers **every** command with +`{RESP_ACK, cmd, RESP_AUTH_REQUIRED}` via `sendResponseUnencrypted()`. That is a *command +dispatch* and a *successful notify* on every single client retry. + +**Concrete sequence** (ESP32, W=16 pipe transfer, HA push): + +| t | event | state | +|---|---|---| +| 0 s | `0x0080` START accepted | `directWriteActive=true`, `pipeState.active=true` | +| 41 s | window loss burst; 3 nonce rejections | `encryption.cpp:691-696` → `clearEncryptionSession()` | +| 41 s+ | client retransmits `0x0081` frames | each reaches `imageDataWritten` → **stamp**; each is answered `RESP_AUTH_REQUIRED` → notify → **stamp** | +| 56 s | py-opendisplay hits `MAX_PTO=3 × TIMEOUT_PIPE_DATA_COMPRESSED=5.0 s` (`device.py:465`, `commands.py:93`) and raises `ProtocolError` | client stops sending; **but does not necessarily drop the link** | +| 56 s → ∞ | HA keeps the `Device` object; any subsequent poll/telemetry command re-stamps | `now - g_lastProgressMs` never exceeds 600 000 | + +The wedge predicate `(transferActive() || chunkedWriteState.active || directWriteActive)` +is **true** the whole time (`directWriteActive` is only cleared by +`cleanupDirectWriteState`, `display_service.cpp:2011`). The supervisor therefore *sees* the +wedge and *never times it out*. Phase 6 delivers nothing for the case it was written for. + +The same defect hits Phase 5: `g_lastLinkRxMs` is stamped in `onWrite` +(`esp32_ble_callbacks.h:81`), so a client that keeps writing doomed commands defeats the +5-minute idle disconnect too. With Phase 6 subsuming the 15-min watchdogs (below), the +device ends up with **strictly less** recovery than today. + +**Correction:** `markSessionProgress()` must mean *the state machine advanced*, not *bytes +moved*. Stamp only at: +- `pipeState.expected_seq` actually advancing (inside the in-order accept in + `handlePipeWriteData`, not at frame receipt), +- `directWriteBytesWritten` increasing (`display_service.cpp:2005`), +- `chunkedWriteState.receivedChunks` incrementing (`communication.cpp:565`), +- `partialCtx` byte counter advancing, +- refresh completion (`display_service.cpp:2436`). + +Do **not** stamp on command dispatch, on notify, or on LAN frame dispatch. Add one extra +stamp on `handleAuthenticate` success so a legitimate re-auth resets the clock. + +--- + +### C2 — `pwrmgmLock` 10 s steal is shorter than legitimate holds, and stealing corrupts the lock + +**Plan element:** Phase 0 — *"`pwrmgmLockTake`: 10 s deadline +(`OD_PWRMGM_LOCK_TIMEOUT_MS = 10000`); on expiry log ERROR and force-take (steal)."* + +**Legitimate holds already exceed 10 s.** The lock is held across panel I/O in three +places (`display_service.cpp:439`, `:496`, `:513`), and the bb_epaper busy wait inside +those paths is bounded at **30 000 ms for 3-/4-/7-colour panels**: + +``` +.pio/libdeps/esp32-s3-N16R8/bb_epaper/src/bb_ep.inl:3959-3975 + int iMaxTime = 5000; // B/W + if (pBBEP->iFlags & (BBEP_3COLOR|BBEP_4COLOR|BBEP_7COLOR)) iMaxTime = 30000; +``` + +- `epdSessionForceOffLocked()` (`display_service.cpp:417-433`) calls `bbepSleep(&bbep,1)`, + which for UC81xx 3-/4-colour panels calls `bbepWaitBusy()` (`bb_ep.inl:4122`, `:4129`) → + **up to 30 s under the lock**, plus `delay(50)`. +- `epdSessionAcquire()` (`:437-487`) holds the lock across `bbepWakeUp` + + `bbepSendCMDSequence` + `epdAlignCustomPartialRamMode`; `bbepSetAddrWindow` alone ends in + a `bbepWaitBusy` (`bb_ep.inl:4104`). + +So on any Spectra/ACeP tag, a **normal** `cleanupDirectWriteState(true)` → `epdSessionForceOff()` +can hold `pwrmgmLock` for ~30 s. On nRF the transfer runs on the Bluefruit Callback task +while `epdSessionTick()` runs on loop — the plan's own rationale for the lock existing. +A 10 s steal fires on healthy hardware. + +**What the steal does when it fires** (`display_service.cpp:401-413`): + +```c +static void pwrmgmLockTake(void) { while (__atomic_exchange_n(&pwrmgmLock,1,ACQUIRE)) delay(1); } +static void pwrmgmLockGive(void) { __atomic_store_n(&pwrmgmLock, 0, RELEASE); } +``` + +`pwrmgmLock` is a plain 0/1 flag with no owner field. After a steal there are two threads +that each believe they hold it: + +1. Stealer runs `epdSessionForceOffLocked()` → `bbepSleep` + rail cut while the true holder + is mid-`bbepSendCMDSequence` → **two tasks driving the same SPI bus and CS line**. +2. True holder finishes and calls `pwrmgmLockGive()` → flag = 0 **while the stealer still + holds it**. Mutual exclusion is now gone for every subsequent operation, permanently. + `epdSessionTick()`'s `pwrmgmLockTryTake()` (`:520`) will now succeed mid-transfer and + rail-cut a live push — the precise failure the lock was introduced to prevent. +3. `pwrmgmState` ends up whatever the *loser* wrote last: the holder's `pwrmgmState = + PWR_ACTIVE` (`:467`) lands after the stealer's `PWR_OFF`, leaving the firmware convinced + the panel is powered when the rail is down. `epdSessionTick()` then never re-arms + (`:520` early-returns unless `PWR_WARM`), so the panel stays permanently mis-tracked. + +**Correction:** do not steal. Make `pwrmgmLockTake` return `bool` with a **60 s** deadline +(≥ 2× the worst-case `bbepWaitBusy`), and on expiry **fail the operation** — log ERROR, +return false, let the caller skip its panel work and set a "panel state unknown" flag that +`abortToKnownState` reports. If a forced recovery is genuinely required, add an owner field +(`volatile TaskHandle_t pwrmgmOwner`) and have the stealer set `pwrmgmOwner = self` so the +original holder's `Give` becomes a detectable no-op instead of a silent unlock. + +--- + +### C3 — `BLE_ERR_CONN_LIMIT` is not a legal `HCI_Disconnect` reason; the exclusivity refusal silently no-ops + +**Plan element:** §Key platform facts and Phase 4 — *"refuse via `pServer->disconnect(connInfo, +BLE_ERR_CONN_LIMIT)`"*. + +`NimBLEServer::disconnect(connInfo, reason)` forwards straight to `ble_gap_terminate`: + +``` +.pio/libdeps/esp32-s3-N16R8/NimBLE-Arduino/src/NimBLEServer.cpp:321-332 +bool NimBLEServer::disconnect(uint16_t connHandle, uint8_t reason) const { + int rc = ble_gap_terminate(connHandle, reason); + ... NIMBLE_LOGE("ble_gap_terminate failed: rc=%d ..."); return false; +``` + +The Core Spec restricts the `HCI_Disconnect` Reason parameter to a small allowlist — +`0x05` Authentication Failure, `0x13`/`0x14`/`0x15` (remote/local user or low-resources +termination), `0x1A` Unsupported Remote Feature, `0x29` Pairing with Unit Key Not Supported, +`0x3B` Unacceptable Connection Parameters. **`0x09` (Connection Limit Exceeded) is not in +that set**; the controller answers `Invalid HCI Command Parameters (0x12)`, +`ble_gap_terminate` returns non-zero, and `disconnect()` returns `false` after logging. + +Result: the gatecrasher stays connected. Phase 4's central promise — single-owner +exclusivity, and the "completes the earlier BLE-max-connections task" claim — does not hold, +while the code *looks* like it worked. This one is easy to miss on the bench because the +second central often disconnects on its own. + +**Correction:** use `BLE_ERR_REM_USER_CONN_TERM` (0x13). Check the `bool` return and log +WARN on failure. Add the same fix to the LAN side (`incoming.stop()` needs no reason code, +so LAN is unaffected). + +--- + +### C4 — Refusing the second central re-enters the shared disconnect-cleanup flag, which has no owner guard on `esp32-N4` + +**Plan element:** Phase 4 — *"ESP32 `onConnect`: refuse second central … `disconnect(connInfo, …)`"*. + +Terminating the refused link fires `MyBLEServerCallbacks::onDisconnect` +(`esp32_ble_callbacks.h:57-70`), which is a blind flag-setter with **no conn-handle +discrimination**: + +```c +bleDisconnectCleanupPending = true; +bleRestartAdvertisingPending = true; +``` + +`serviceBleDisconnectCleanup()` (`main.cpp:321-348`) then decides whether to tear down. Its +only protection is the `ownerStillUp` guard — and that guard is inside +`#ifdef OPENDISPLAY_HAS_WIFI` (`main.cpp:328-338`). + +`OPENDISPLAY_HAS_WIFI` is `TARGET_ESP32 && OPENDISPLAY_ENABLE_WIFI` (`wifi_service.h:13-15`). +Expanding `extends` in `platformio.ini`, exactly two of the eleven envs lack it: +`nrf52840custom` (no NimBLE at all) and **`esp32-N4`** (`platformio.ini:284-299`). + +**Concrete failure on `esp32-N4`:** client A is mid-pipe-transfer at chunk 90/300. A phone +running the opendisplay.org web client scans and connects. `onConnect` sees +`getConnectedCount() == 2`, refuses B. B's `onDisconnect` sets the flag. Next loop pass, +`serviceBleDisconnectCleanup` runs with the guard compiled out: + +``` +main.cpp:343 if (directWriteActive) cleanupDirectWriteState(true); // panel torn down +main.cpp:346 cleanupPartialWriteOnDisconnect(); +main.cpp:347 resetPipeWriteState(); // A's transfer destroyed +``` + +Client A's transfer is killed by a stranger walking past. **This is a new remote-DoS +introduced by the plan**, on the one env that already has the least headroom. + +**Correction:** two changes, both required. +1. Give the refusal its own path: set a `bleRefusedGatecrasherPending` flag in `onConnect` + and have `onDisconnect` skip raising `bleDisconnectCleanupPending` when the departing + handle is the refused one (capture the conn handle in `onConnect` and compare + `connInfo.getConnHandle()` in `onDisconnect`). +2. Move the `ownerStillUp` early-return **out** of `#ifdef OPENDISPLAY_HAS_WIFI` — the + `pServer->getConnectedCount() > 0` half of it is unconditionally correct and costs + nothing on a no-WiFi build. + +--- + +## HIGH + +### H1 — Phase 5 escalates a recoverable command drop into a forced disconnect, and the ring holds 32, not 33 + +**Plan element:** Phase 5 — *"Command ring full: set `commandQueueOverflowAbort` (host task); +loop services → `abortToKnownState("command queue overflow", dropLink=true)`."* + +Today a full ring drops the newest frame (`esp32_ble_callbacks.h:127-129`). The pipe +protocol is *designed* for that: the missing seq shows up as a zero bit in the next SACK +mask and the client retransmits exactly that chunk (`docs/pipe-write-protocol.md` §5.2; +`device.py:2771-2772`). Loss of one frame costs one round trip. Phase 5 turns it into a +link drop plus a full re-auth plus a restarted transfer. + +Worse, the ring's usable capacity is `COMMAND_QUEUE_SIZE - 1 = 32`, not 33 — the producer +refuses at `nextHead == tail` (`esp32_ble_callbacks.h:122`). The comment at `main.h:365-370` +("33 slots hold a full W=32 in-flight window + END") is off by one. With W=32 negotiated +(`PIPE_MAX_W = 32`, `structs.h:51`) and the loop task stalled in a Spectra SPI write, a full +window fills the ring exactly; **any** interleaved non-pipe command (a `0x0060` telemetry +poll, a CCCD write) overflows it and, under Phase 5, kills the link. + +**Correction:** keep the drop, do not drop the link. Set the flag, log one WARN with the +count, and gate the *abort* on the overflow recurring while `transferActive()` and +`g_lastProgressMs` is already stale — i.e. let the supervisor own the decision. If you want +belt-and-braces, bump `COMMAND_QUEUE_SIZE` to 34 on the envs with DRAM to spare (not +`esp32-N4`) so the documented W=32+END claim is actually true. + +### H2 — Phase 2, read literally, makes the cross-transport clobber *worse*, and it ships two phases before the fix + +**Plan element:** Phase 2 — *"Queue flush + session clear must run even when the +transfer-owner guard early-returns for the other transport."* + +`disconnectWiFiServer()` sets `bleDisconnectCleanupPending = true` (`wifi_service.cpp:812`). +If Phase 2 puts `clearEncryptionSession()` **before** the `ownerStillUp` guard at +`main.cpp:333`, then on every WiFi-lost tick (`main.cpp:452-457`) or LAN client close, a +live, authenticated, mid-transfer BLE session is destroyed — which is §Context item 4, the +bug Phase 4 exists to fix, now reachable from a *second* code path and shipping first. + +The guard as written happens to save you today: `transferSessionOrigin()` returns +`sessionOrigin`, which defaults to `0` (`ORIGIN_BLE`, `display_service.cpp:2114`) and is +only written at transfer START, so `lanOwnsSession` is false and `ownerStillUp` reads +`getConnectedCount() > 0`. Putting the clear before it throws that away. + +**Correction:** place `flushCommandQueue(); flushResponseQueue(); clearEncryptionSession();` +**after** the `ownerStillUp` early-return, and make the guard unconditional (see C4.2). +Better: land the Phase 4 owner token *first* and scope the clear to +`linkOwner() == OWNER_BLE`. The plan's own note ("until then clear when the departing +transport owned it") is the right instinct — make it a hard requirement, not a parenthetical. + +### H3 — Phase 6 deletes the only timeouts that currently work, before the supervisor is trustworthy + +**Plan element:** Phase 6 — *"Subsume the 15-min watchdogs: delete main.cpp:436-442 direct-write +block; retire `checkPartialWriteTimeout`'s 15-min path."* + +Given C1, the supervisor's `g_lastProgressMs` is refreshed by ordinary command traffic. +Deleting `main.cpp:436-442` (which keys on `directWriteStartTime`, a *wall-clock start* +stamp that nothing refreshes) replaces an unconditional 15-minute bound with a conditional +one that a chatty client defeats. Same for `checkPartialWriteTimeout` (`display_service.cpp:578-587`), +which keys on `partialCtx.start_time`. + +**Correction:** keep both wall-clock bounds as a backstop and simply raise them past the +supervisor (e.g. 20 min), or add a second supervisor arm that is *not* progress-based: +`transferActive() && now - g_transferStartMs > OD_TRANSFER_HARD_CAP_MS`. Delete the old +blocks only after hardware soak proves the progress arm fires. + +### H4 — nRF: the "single task, no race" premise has a documented hole, and `abortToKnownState` is reachable from the Bluefruit task + +**Plan element:** Phase 2 — *"nRF `disconnect_callback`: add `clearEncryptionSession()` (same +task as all crypto — no race)"*; Phase 1 — *"nRF variant … callable from Bluefruit task"*; +Phase 6 — *"nRF: `volatile bool g_commandInFlight` around `imageDataWritten`"*. + +The premise is *usually* right and I verified it: both `connect/disconnect_callback` +(`bluefruit.cpp:829`, `:849`) and the characteristic write callback +(`BLECharacteristic.cpp:538-542`) are dispatched through `ada_callback()`, i.e. serialized +on the single "Callback" task (`AdaCallback.c:45`, `:147`). + +The hole: `ada_callback_invoke()` allocates its item with `rtos_malloc` and +`VERIFY(cb_data)` returns **false** on failure — at which point `BLECharacteristic.cpp:541` +invokes `_wr_cb` **inline on the BLE event task**: + +```c +if ( !(_use_ada_cb.write && + ada_callback(request->data, request->len, _wr_cb, ...)) ) +{ + _wr_cb(conn_hdl, this, request->data, request->len); // BLE task, not Callback task +} +``` + +Heap exhaustion during a large nRF52840 transfer is exactly when this triggers. Then +`imageDataWritten` runs concurrently on two tasks, `clearEncryptionSession()`'s +`memset(session_key, 0, 16)` (`encryption.cpp:205`) can land mid-`aes_ccm_decrypt`, and +`g_commandInFlight` — a plain bool, not a counter — is cleared by whichever nests out +first, letting the supervisor abort under a live handler. + +**Correction:** make `g_commandInFlight` a `volatile uint8_t` **depth counter** +(increment/decrement, abort only at 0). On nRF, make `clearEncryptionSession()` from +`disconnect_callback` deferred: set `nrfSessionClearPending` and service it from `loop()` +when the depth counter is 0. Note the inline-fallback path explicitly in the plan so nobody +later relies on "single task" as an invariant. + +--- + +## MEDIUM + +### M1 — Asymmetric nonce window (+128 / −32): not a replay hole, but a 4× DoS amplifier + +**Plan element:** Phase 3 — *"backward stays −32; forward becomes `OD_NONCE_FORWARD_WINDOW = 4*PIPE_MAX_W = 128`"*. + +I worked the exact math on the 64-entry ring and the widening is **not** exploitable as a +replay: + +- `encryption.cpp:136` only consults `replay_window[]` when `counter <= last_seen_counter`. + A forward-accepted counter immediately becomes `last_seen_counter` (`:149-151`), so a + second copy of it takes the `<=` branch and is caught by the ring. +- Backward window (32) < ring depth (64), so every counter reachable via the backward branch + is still in the ring. Sound. + +What *is* real: the window is one-sided. An attacker who captures a single valid frame at +counter `N` and replays it can jam `last_seen_counter` forward. With `+32` today the damage +is 32 counters; with `+128` it is 128. The client's next 96 legitimate frames +(`N+1 … N+96`) then fall outside the `−32` backward window and are rejected. Under Phase 3 +those rejections no longer count as `integrity_failures` (correct), so the session survives +— and stalls, which is exactly the wedge the supervisor must catch. The plan makes the +recovery path load-bearing while widening the trigger. + +Ranking this Medium, not Critical: it requires a local attacker with a captured frame, and +the same class of attack exists today at ¼ the magnitude. + +**Correction:** widen **both** sides to `OD_NONCE_WINDOW = 128` and grow +`replay_window[]` from 64 to 256 entries (`encryption_state.h:21`; +1.5 KB `.bss`, check it +on `esp32-N4`). If the RAM is not there, keep backward at −32 but cap forward at +64 so a +single replay cannot open a gap wider than the ring can police. + +Also: the plan correctly moves `replay_window_index` out of the function-static +(`encryption.cpp:152`) into `encryptionSession`. That is a real bug today — +`clearEncryptionSession()` memsets the ring (`:217`) but leaves the index, so the new +session's first 64 accepts overwrite an arbitrary rotation. **Verified correct; keep it.** + +### M2 — Phase 4 exclusivity breaks reconnect after an abrupt client loss + +If the central's host crashes or its adapter is reset, the device does not learn the link is +gone until its **supervision timeout** expires (typically 4 s, up to 32 s; negotiated by the +central — the repo only requests PHY/DLE, `ble_init.cpp:` `ble_nrf_request_fast_link`, and +sets nothing on ESP32). During that window, `getConnectedCount()` still reads 1, so the +returning client's reconnect is refused. Today, with `CONFIG_BT_NIMBLE_MAX_CONNECTIONS = 3` +(`sdkconfig.h:613`), it just works. + +HA delivery is `async with Device(...)` per push (`device.py:655-697`), so a supervision-timeout +window that eats one delivery attempt will be retried next wake (`delivery.py:452`) — not +fatal, but a visible reliability regression on flaky links. + +**Correction:** on refusal, if the *existing* link has no transfer in flight +(`!transferActive()`) **and** its last RX is older than ~10 s, terminate the **old** link +with `BLE_ERR_REM_USER_CONN_TERM` and accept the new one. Refuse only when the incumbent is +actively transferring. + +### M3 — `touchForceResume()` and `directWriteTouchSuspended` drift out of sync + +**Plan element:** Phase 1 — *"`touchForceResume()` (zero the `s_epd_refresh_suspend` counter)"*. + +The counter has a paired bool guard: `handleDirectWriteStart` and the pipe full-frame +setup set `directWriteTouchSuspended = true` (`display_service.cpp:2137`, `:2800`), and +`cleanupDirectWriteState` consumes it (`:2035-2038`). If `abortToKnownState` zeroes the +counter *before* calling `cleanupDirectWriteState`, the subsequent +`touchResumeAfterEpdRefresh()` early-returns at `touch_input.cpp:418` — harmless — but +`directWriteTouchSuspended` is left `true` only if the abort ordering is different from the +plan's. As written (`cleanupDirectWriteState(true)` first, `touchForceResume()` later) the +ordering is fine. + +The genuine hazard the prompt flags — touch polling I2C concurrently with SPI streaming — +does **not** materialise on ESP32: `processTouchInput()` also gates on `transferActive()` +(`touch_input.cpp:584`), and `abortToKnownState` clears all three transfer flags before +`touchForceResume()`. On nRF there is no such gate at all (the block is `#ifdef TARGET_ESP32`), +but nRF also runs the transfer on a different task from `processTouchInput()`, so nothing +changes. + +**Correction:** have `touchForceResume()` also clear `directWriteTouchSuspended` (expose a +setter or move the bool next to the counter), and assert the counter is 0 afterwards. Low +effort, removes a future footgun. Keep the plan's ordering. + +### M4 — `esp32_set_ble_connectable()` has no failure path; a failed `start()` leaves the radio permanently dark + +**Plan element:** Phase 4 — *"stop → `setConnectableMode(NON/UND)` → re-push +`setAdvertisementData` → start"*. + +The manufacturer-data concern is **handled correctly** — see V4 below. The gap is failure +handling. `NimBLEAdvertising::start()` returns `bool`; `setAdvertisementData()` returns +`bool` and fails if the payload exceeds 31 bytes. If either fails after the `stop()`, the +device is not advertising, is not connected, and nothing retries: `bleRestartAdvertisingPending` +is only raised by `onDisconnect`. That is "unresponsive but not frozen" — a support ticket +that looks like a dead device. + +**Correction:** check both return values; on any failure, force +`setConnectableMode(BLE_GAP_CONN_MODE_UND)`, re-push, `start()` again, and set +`bleRestartAdvertisingPending = true` so the loop keeps retrying. + +### M5 — Drain-abort `break` placement relative to `commandQueue[tail].pending = false` + +**Plan element:** Phase 1 — *"after `imageDataWritten` returns, `if (commandDrainAbortPending) +{ clear; break; }` — break WITHOUT the tail store."* + +The index walk is correct as specified. `flushCommandQueue()` sets +`commandQueueTail := head`, the drain then breaks without executing `main.cpp:417`, so tail +stays at the flushed position and nothing is re-dispatched or leaked. **Verified sound.** + +The one hazard is placement: the check must go **between** `main.cpp:415` and `main.cpp:416`. +If it goes after `:416`, the `commandQueue[tail].pending = false` writes into a slot the +producer may already have re-filled post-flush. `pending` is write-only today (its only +readers are `main.cpp:291/309/416` — all writes), so the consequence is nil, but it is a +landmine for anyone who later gives `pending` meaning. + +**Correction:** state the exact insertion point in the plan, and delete the vestigial +`pending` field while you are in there — it has no readers in either ring. + +--- + +## LOW + +### L1 — Plan factual error: the response ring *is* flushed today + +§Context item 3 asserts *"neither ring is EVER flushed (heads/tails never reset anywhere)"*. +The response ring is drained to empty on every loop pass with no central connected: + +``` +main.cpp:307-312 +} else { + while (responseQueueTail != responseQueueHead) { + responseQueue[responseQueueTail].pending = false; + responseQueueTail = (responseQueueTail + 1) % RESPONSE_QUEUE_SIZE; + } +} +``` + +So Phase 2's `flushResponseQueue()` on BLE disconnect is redundant on ESP32 (it happens one +pass later anyway). Only the **command** ring genuinely survives a disconnect. Harmless to +add, but the plan should not claim it as a fix for a bug that does not exist — and the +finding matters because it means stale *responses* were never the wedge. + +### L2 — The 60 s pipe-error reset is safe but far outside client patience, and the doc needs a line + +The prompt's worry that Phase 2 breaks `docs/pipe-write-protocol.md` §5.1 does **not** hold. +§5.1 (lines 363-365) promises only that later `0x0081` frames are *silently discarded* until +the next `0x0080` or a disconnect. After `resetPipeWriteState()`, `handlePipeWriteData`'s +first line still discards them (`display_service.cpp:2811`: `if (!pipeState.active || +pipeState.error) return;`). Client-observable behaviour is identical. + +What is off is the plan's rationale: *"preserves the 60 s stable-ACK-position window for +client retries."* py-opendisplay treats every `0x81` NACK as fatal and raises immediately +(`device.py`, `ProtocolError` on fatal NACK); nothing re-reads the ACK position. 60 s is +~4× the client's entire `MAX_PTO` budget. The number is harmless but the justification is +fiction — pick 10 s and say it is a hardware-release deadline, not a client-retry window. + +Also: `PipeWriteState` (`structs.h:106-126`) has no timestamp field at all, so +`error_since_ms` is a genuine struct addition, not a rename. Fine on RAM (4 bytes). + +### L3 — "No hardware WDT" is true only because every long wait yields + +The plan's premise is defensible but the surrounding facts are not quite as stated: + +- The FreeRTOS Task WDT **is** enabled with panic on the pinned platform: + `CONFIG_ESP_TASK_WDT_INIT 1`, `CONFIG_ESP_TASK_WDT_PANIC 1`, + `CONFIG_ESP_TASK_WDT_TIMEOUT_S 5`, watching `IDLE_TASK_CPU0` + (`framework-arduinoespressif32-libs/esp32s3/qio_qspi/include/sdkconfig.h:906-910`). Plus + `CONFIG_ESP_INT_WDT` at 300 ms (`:903-904`). +- Every ESP env passes `-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` (e.g. `platformio.ini:189`). + That symbol does not exist in IDF 5.x (it is `CONFIG_ESP_TASK_WDT_TIMEOUT_S`), and the + precompiled `sdkconfig.h` wins anyway — the same trap the repo already documented for + `CONFIG_BT_NIMBLE_MAX_CONNECTIONS`. **The flag is inert.** Either delete it or fix the + name; leaving a dead knob that reads like a 120 s guarantee is how the next reader gets + the sizing wrong. +- On single-core C3/C6 the Arduino loop task shares CPU0 with IDLE0, so a genuinely + non-yielding `loop()` *would* panic-reboot in 5 s. It does not today because every long + wait yields (`waitforrefresh` `delay(10)` at `display_service.cpp:759`; `bbepWaitBusy` + `bbepLightSleep(20)` at `bb_ep.inl:3973`). Worth one sentence in the plan so a future + busy-spin does not get added casually. + +The plan's RTC rationale is **correct and already hardware-proven** — see V5. + +### L4 — Minor line-reference drift in the plan + +Not worth churn, but for the record: `communication.cpp:113-116` is actually `:117-121` +(the ring-full check); `sdkconfig.h:471` is `:613` on the S3 variant; `display_fastepd.cpp:222-231` +lands on `fastepd_full_update` at `:227-231`. Everything else I spot-checked +(`encryption.cpp:149-155`, `display_service.cpp:401-408`, `power_latch.cpp:87-90`, +`main.cpp:406-423`/`:321-348`/`:436-442`, `device_control.cpp:227-240`, +`esp32_ble_callbacks.h:128`, `touch_input.cpp:584`, `wifi_service.cpp:804`/`:879`) is exact. + +--- + +## What the plan MISSES — freeze vectors not addressed + +### X1 — I2C bus hang: `Wire.setTimeOut()` is never called anywhere + +`grep -n "setTimeOut" src/*.cpp` returns nothing. The firmware drives four I2C peripherals +(AXP2101 PMIC in `display_service.cpp`, GT911 touch in `touch_input.cpp`, SHT40, BQ27220) +through `Wire`, whose Arduino-ESP32 default timeout is 50 ms per transaction — survivable — +**but** `wireBeginForOpenDisplay()` (`display_service.cpp:785-800`) and +`invalidateOpenDisplayWire()` never re-assert it after a re-`begin()`. More importantly a +slave holding SDA low (classic GT911 wedge after a rail cut) is not recovered by a timeout: +it needs the nine-clock bus-recovery pulse train, which the firmware never issues. + +This is a real, common e-paper-tag freeze mode (touch controller and panel share the rail +that `epdSessionForceOff` cuts), and the supervisor cannot see it — `transferActive()` is +false while `processTouchInput()` spins. **Recommend:** add explicit `Wire.setTimeOut(25)` +after every `Wire.begin()`, and a nine-clock SDA-recovery routine invoked when +`rt->i2c_fail_streak` crosses a threshold (`touch_input.cpp:400` already tracks it). + +### X2 — The boot refresh is invisible to every `epdRefreshInProgress` gate + +`refreshBootScreenFull()` (`display_service.cpp:533-542`) calls `touchSuspendForEpdRefresh()` ++ `bbepRefresh` + `waitforrefresh(60)` and **never sets `epdRefreshInProgress`**. Neither +does the FastEPD boot path (`:1588-1594`). Consequences: `serviceBleDisconnectCleanup` +(`main.cpp:322`), `esp32_restart_ble_advertising` (`ble_init.cpp:236`), the `workInFlight` +gate (`main.cpp:478`) and the plan's new `idleLinkTick()` / supervisor "never interrupt a +refresh" rule all mis-read a boot refresh as idle. On a Spectra panel that is a 30–60 s +blind spot on every cold boot, and the retry path (`:1624-1633`) can double it. + +**Recommend:** set/clear `epdRefreshInProgress` around both boot-refresh paths as a Phase 0 +one-liner. It is free and it makes the supervisor's refresh exemption honest. + +### X3 — `fastepd_wait_refresh()` is a stub — the IT8951 path has *zero* firmware-side bound + +``` +src/display_fastepd.cpp:228-231 +bool fastepd_wait_refresh(int timeout_sec) { + (void)timeout_sec; + return !s_init_failed; +} +``` + +`waitforrefresh(60)` short-circuits to this on FastEPD builds (`display_service.cpp:749-751`), +so the "60 s cap" the plan cites does not exist on IT8951/E1004. The plan's Phase 0 mitigation +(log if `fullUpdate` exceeds 120 s) wraps `fastepd_full_update` (`:227-231`) but **not** +`fastepd_direct_refresh`, which is the path a real transfer takes +(`display_service.cpp:2422-2423`). Post-hoc logging on the wrong function is not the +"documented residual risk" the plan claims to have accepted. + +**Recommend:** wrap `fastepd_direct_refresh` too, and implement `fastepd_wait_refresh` as a +real busy poll against the IT8951 LUT-busy register with the passed `timeout_sec`. + +### X4 — Config chunked-write has no timer of its own + +`chunkedWriteState.active` is set at `communication.cpp:496` and cleared only on completion +(`:574`), on a malformed chunk (`:558`), or on an auth failure (`:550`). A client that sends +`0x0040` START and vanishes leaves it latched forever — and, unlike the transfer flags, it +is not covered by any existing watchdog. The plan adds `resetChunkedWriteState()` and puts +`chunkedWriteState.active` in the supervisor predicate, which is correct — but per C1 the +supervisor never fires. Once C1 is fixed this is covered; flagging it so the dependency is +explicit. + +### X5 — Deep-sleep entry with a live transfer + +`enterDeepSleep()` (`main.cpp:577`) is reachable from the advertising-window branch +(`main.cpp:388`) and the idle branch (`:498`). The idle branch is gated by `workInFlight`, +which includes `epdRefreshInProgress` and `getConnectedCount() > 0` — but **not** +`transferActive()`. A pipe transfer whose client link has already dropped (so +`getConnectedCount() == 0`) but whose `pipeState.active` is still latched, with +`bleDisconnectCleanupPending` deferred behind `epdRefreshInProgress`, can reach +`enterDeepSleep()` with the panel rail up. Rare, but the plan's `abortToKnownState` is the +natural place to close it. + +**Recommend:** add `transferActive()` to the `workInFlight` disjunction at `main.cpp:474-479`. +One term, no behaviour change in the normal case. + +### X6 — Buzzer and LED timers are outside every bound + +`buzzerService()` / `processLedFlash()` run unbounded from `loop()`. The plan's +`abortToKnownState` lists "buzzer/LED stop" — good — but nothing bounds a stuck +`buzzer_control` sequence in the first place. Low probability; noting for completeness. + +### X7 — Nothing detects "advertising stopped and never restarted" + +`esp32_restart_ble_advertising()` early-returns without starting when +`epdRefreshInProgress` (`ble_init.cpp:236-239`, re-arming the flag) or when +`getConnectedCount() > 0` (`:232-235`, **clearing** the flag). The second is the hole: if +the count is stale-nonzero (refused gatecrasher, C3/C4) the flag is cleared and advertising +never resumes. **Recommend:** a cheap `advertisingHealthTick()` — if no peer, not advertising +(`BLEDevice::getAdvertising()->isAdvertising()`), and no pending flag for > 30 s, force a +restart and log WARN. + +--- + +## Phase-ordering hazards + +| Phase shipped alone | Verdict | +|---|---| +| **0** | Net **regression** as written, because of the `pwrmgmLockTake` steal (C2). The `powerOff` and drain-cap bounds are fine. Ship Phase 0 only after C2's correction (fail-closed instead of steal). | +| **1** | Safe alone. The drain-trap fix (M5) and the queue flushes are self-contained and correct. `abortToKnownState` has no callers until Phase 5/6, so it is dead code — which is fine, but means Phase 1 provides no field benefit on its own. | +| **2** | **Must not ship before Phase 4** if implemented literally (H2). With the clear placed *after* an unconditional owner guard, it is safe and beneficial alone. The nRF half needs H4's deferral. | +| **3** | Safe alone and the highest value-per-risk phase — it removes the actual root cause of the field failures. Ship it **first**. | +| **4** | Blocked on C3 (wrong reason code) and C4 (cleanup re-entry). Once fixed, safe alone. | +| **5** | Net **regression** alone: the command-ring abort (H1) and the RX/TX-keyed idle timer (C1's twin) each make things worse without the corrected progress accounting from Phase 6. | +| **6** | Net **regression** alone as written: it deletes two working wall-clock watchdogs (H3) and replaces them with a predicate that command traffic defeats (C1). | + +**Recommended order:** 3 → 0(corrected) → 1 → 4(corrected) → 2 → 6(corrected, keeping the +old watchdogs) → 5(corrected). That front-loads the phase that fixes the reported field +failure and puts the owner token in place before anything depends on it. + +--- + +## Verified CORRECT — do not churn on these + +1. **`verifyNonceReplay` commits before tag verification.** `encryption.cpp:149-155` writes + `last_seen_counter` and the ring *before* `aes_ccm_decrypt` runs at `:714`. Real bug, + correctly diagnosed, and the `nonceCheck`/`nonceCommit` split is the right shape. +2. **Nonce failures should not touch `integrity_failures`.** `encryption.cpp:691-697` + currently increments on *any* `verifyNonceReplay` failure — including plain packet loss. + Confirmed as the mechanism behind §Context item 1. Fix is correct. +3. **The pipe fatal-NACK latch has no timeout.** `sendPipeNack` (`display_service.cpp:2562-2578`) + sets `pipeState.error = true` and calls `cleanupDirectWriteState(true)`, which clears + `directWriteActive` (`:2011`) — so `main.cpp:436-442`'s watchdog no longer applies, and + `checkPartialWriteTimeout` (`:578`) only covers `partialCtx`. For a non-partial pipe + transfer there is genuinely **no** bound. Diagnosis exact. +4. **`setConnectableMode(NON)` calls `setFlags(0)` and the re-push is required.** Confirmed + at `NimBLEAdvertising.cpp:82-84`; `setFlags(0)` is one-way (UND does not restore it), and + `setAdvertisementData(*advertisementData)` copies the app object which carries + `setFlags(0x06)` from `ble_init.cpp:302`. The plan's mitigation, and the + "`setAdvertisementData` must be LAST" trap at `ble_init.cpp:307-312`, are both correct. +5. **RTC memory does not survive a non-deep-sleep reset.** `main.cpp:96-100` documents it + from hardware (`FINDINGS_DEEP_SLEEP_WAKE_BOOT_SCREEN_2026-07-07.md`), and + `displayed_etag` is `RTC_DATA_ATTR` (`main.h:294`). The "reset state, never reboot" + decision is well-founded. +6. **NimBLE adds the peer before `onConnect`.** `NimBLEServer.cpp:464-471` fills + `m_connectedPeers` then calls `onConnect`, so `getConnectedCount() > 1` is a valid + gatecrasher test. (The refusal *mechanism* is still wrong — C3.) +7. **`-DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=1` is inert.** `sdkconfig.h:613` defines it to 3. + Correct to enforce in code. +8. **nRF `Bluefruit.begin(1, 0)` already caps at one link.** `ble_init.cpp:152`. Correct. +9. **Clearing the session on disconnect is client-compatible.** py-opendisplay authenticates + inside `__aenter__` on every connection (`device.py:670`) and calls `_clear_session()` in + `__aexit__` (`:697`) and on any setup failure (`:680-683`). HA's delivery path is one + `async with Device(...)` per push. No client assumes session persistence across a link + drop. **No compatibility risk.** +10. **The 10-minute supervisor timeout is not too short for legitimate work.** Worst-case + legitimate blocks: `waitforrefresh(60)` = 60 s (`display_service.cpp:2423`), + `bbepWaitBusy` 30 s, and the E1004 ~960 KB upload — but the client itself gives up far + sooner (`TIMEOUT_PIPE_DATA_COMPRESSED = 5.0` × `MAX_PTO = 3` ≈ 15 s; + `TIMEOUT_REFRESH = 90.0`, `device.py:457-473`). Provided the stamps are moved per C1, no + legitimate transfer comes near 600 s of no progress. The number is fine. +11. **The 2 s drain cap is safe.** The drain already caps at `COMMAND_QUEUE_SIZE` iterations + and flushes responses between commands (`main.cpp:419-421`); a wall-clock cap on top + changes nothing in the normal case. +12. **The queue-flush SPSC reasoning is right.** `commandQueueTail` is written only by the + consumer, and every call path the plan names (`serviceBleDisconnectCleanup`, the drain + loop, LAN dispatch, `abortToKnownState`) is on the loop task. `tail := head` snapshot is + the correct flush. `responseQueue` head/tail are both loop-task-only, so its flush is + trivially safe. +13. **The OTA exception is genuinely satisfied.** ESP32 `0x0051` is `esp_restart`; nRF DFU + jumps to the bootloader. Nothing to special-case. +14. **Phase 2's 60 s pipe-error reset does not break `pipe-write-protocol.md` §5.1** — see + L2. The doc's contract is about client-observable discard behaviour, which is unchanged. + Update §5.1 to mention the reset, but no protocol change is needed. + +--- + +## Summary of required corrections + +| # | Phase | Correction | +|---|-------|-----------| +| C1 | 6, 5 | Stamp progress only on state-machine advancement; never on command dispatch or notify. Key the idle timer the same way. | +| C2 | 0 | Replace the 10 s lock steal with a 60 s fail-closed take; add an owner field if a steal is ever genuinely needed. | +| C3 | 4 | Use `BLE_ERR_REM_USER_CONN_TERM` (0x13), not `BLE_ERR_CONN_LIMIT` (0x09). Check the return value. | +| C4 | 4 | Discriminate the refused conn handle in `onDisconnect`; move the `ownerStillUp` guard out of `#ifdef OPENDISPLAY_HAS_WIFI`. | +| H1 | 5 | Command-ring overflow logs and drops; it does not drop the link. Fix the off-by-one in the `main.h:365` comment. | +| H2 | 2 | Place `clearEncryptionSession()` after the owner guard, or land Phase 4's token first. | +| H3 | 6 | Keep the wall-clock watchdogs as a backstop until the progress arm is soak-proven. | +| H4 | 1, 2, 6 | `g_commandInFlight` becomes a depth counter; defer the nRF disconnect-time session clear to `loop()`. | +| M1 | 3 | Make the nonce window symmetric (±128 with a 256-entry ring, or ±64). | +| M2 | 4 | Prefer evicting an idle incumbent over refusing a reconnect. | +| M4 | 4 | Check `setAdvertisementData`/`start()` returns; fall back to connectable. | +| X1 | new | `Wire.setTimeOut()` + a nine-clock I2C recovery routine. | +| X2 | 0 | Set `epdRefreshInProgress` around both boot-refresh paths. | +| X3 | 0 | Wrap `fastepd_direct_refresh`, and implement `fastepd_wait_refresh` for real. | +| X5 | 6 | Add `transferActive()` to the `workInFlight` disjunction. | +| X7 | new | `advertisingHealthTick()` — detect a permanently dark radio. | + +## Build/portability check + +New `src/session_guard.cpp` compiles into **all eleven** envs. Guards needed: +`pServer` / `responseQueue` / `commandQueue` under `#ifdef TARGET_ESP32`; LAN calls under +`#ifdef OPENDISPLAY_HAS_WIFI` (**not** `TARGET_ESP32` — `esp32-N4` is ESP32 without WiFi, +`platformio.ini:284`). `lib_ignore = NimBLE-Arduino` on nRF (`platformio.ini:36`) means +`session_guard.cpp` must not include `ble_init.h`'s NimBLE surface unguarded. RAM cost is a +handful of scalars plus `PipeWriteState::error_since_ms` (+4 B) — fine even on `esp32-N4`, +which is DRAM-tight enough to need `PIPE_SMALL_DRAM_WINDOW` (`structs.h:45-48`). If M1's +256-entry `replay_window` is adopted (+1.5 KB `.bss`), verify `esp32-N4` links before +committing to it. diff --git a/docs/FINDINGS_LOOP_ARCHITECTURE_CONVERGENCE_2026-07-26.md b/docs/FINDINGS_LOOP_ARCHITECTURE_CONVERGENCE_2026-07-26.md new file mode 100644 index 0000000..70a0bbf --- /dev/null +++ b/docs/FINDINGS_LOOP_ARCHITECTURE_CONVERGENCE_2026-07-26.md @@ -0,0 +1,158 @@ +# Can the nRF52840 and ESP32 `loop()` / `idleDelay()` paths be converged? + +**Date:** 2026-07-26 · **Branch:** `debug/ble-hardening` (HEAD `2e2131b`) +**Scope:** this repo only. Three framework sources were opened for one decisive signature each — every such use is labelled **[LIB]**; nothing else was read from them. Sibling repos not consulted. + +## Recommendation + +**Verdict: partially feasible — and the feasible part is not the appealing part.** Full convergence (A) is not worth it: of the ~160-line ESP32 arm, ~130 lines are deep-sleep, WiFi/LAN, NimBLE advertising and queue-drain work with no nRF counterpart. Converging the *execution model* (C — nRF adopts the SPSC ring) is **feasible on RAM but counter-productive**: Bluefruit already copies each write payload and posts it to a dedicated FIFO "Callback" task with an *elastic* queue **[LIB]**, so an app ring adds a second copy and hop only to replace an unbounded-but-growing queue with a fixed 32-slot drop-on-full ring, and to move dispatch from a `TASK_PRIO_NORMAL` task onto the `TASK_PRIO_LOW` loop task that lives inside 100 ms `delay()` chunks. **Recommended: option B fused with D** — extract the target-agnostic supervisory work (`processLedFlash`/`epdSessionTick`/`buzzerService`, the two 15-min watchdogs, later the Phase 6 supervisor) into one `serviceSupervisoryTick()` called from both `loop()` arms *and* from `idleDelay()`. ~50 LOC in one file, closes the real defect (nRF has no transfer watchdog), gives the freeze-proofing plan one wiring point instead of two. Hard prerequisite: plan item **H4**'s `g_commandInFlight` depth counter must land first. + +## 0. Corrections to the stated facts + +Nine of twelve confirmed exactly. Confirmed: single top-level `#ifdef` ([main.cpp:352-354](../src/main.cpp) prologue, `#ifdef` at [:355](../src/main.cpp) and [:358](../src/main.cpp), `#else` [:518](../src/main.cpp), `#endif` [:530](../src/main.cpp)); 11-line nRF arm [:519-529](../src/main.cpp); `idleDelay` [:535-551](../src/main.cpp); both watchdogs inside the ESP32 arm ([:436-442](../src/main.cpp), [:443](../src/main.cpp)) while `checkPartialWriteTimeout()` itself ([display_service.cpp:578-587](../src/display_service.cpp)) is target-agnostic; no `sd_power_system_off` anywhere and `power_latch.cpp` stubs out on non-ESP32 ([power_latch.cpp:194-210](../src/power_latch.cpp)); ESP32 `onConnect` deferral ([esp32_ble_callbacks.h:52-55](../src/esp32_ble_callbacks.h)); `pwrmgmLock` comment ([display_service.cpp:397-408](../src/display_service.cpp)). + +Three refinements: + +- **R1 (load-bearing).** *"On nRF there is NO queue."* There **is** one — it belongs to the framework. `BLECharacteristic::_eventHandler` dispatches the write via `ada_callback(request->data, request->len, _wr_cb, …)` **[LIB]** (`Bluefruit52Lib/src/BLECharacteristic.cpp:537-541`), which `rtos_malloc`s a copy of the payload and posts it to a FreeRTOS queue drained by a dedicated `"Callback"` task **[LIB]** (`cores/nRF5/utility/AdaCallback.c:145-147`). The payload is already copied out of the SoftDevice buffer and the handler already runs off the BLE task. What is true: it does not run on the *loop* task, and this repo owns no queue of its own. +- **R2.** `disconnect_callback` is *also* dispatched through `ada_callback` **[LIB]** (`bluefruit.cpp:849`), as is `connect_callback` (`:829`). Connect/write/disconnect all run on the **same** task in **strict arrival order** — a serialization guarantee the ESP32 flag-deferral lacks. "Inline" is right relative to `loop()`, wrong relative to the BLE stack. +- **R3.** `idleDelay` services *before* it sleeps ([main.cpp:542-548](../src/main.cpp)), so anything added there inherits ≤100 ms service latency. + +## 1. Side-by-side responsibility table + +| # | Responsibility | ESP32 | nRF52840 | Divergence | +|---|---|---|---|---| +| 1 | `processLedFlash()` | loop, [:352](../src/main.cpp)/[:544](../src/main.cpp) | same | shared already | +| 2 | `epdSessionTick()` | loop, [:353](../src/main.cpp)/[:545](../src/main.cpp) | same | shared already | +| 3 | `buzzerService()` | loop, [:354](../src/main.cpp),[:483](../src/main.cpp),[:516](../src/main.cpp),[:546](../src/main.cpp) | loop, [:529](../src/main.cpp),[:546](../src/main.cpp) | shared already | +| 4 | buttons / touch | loop, [:481-482](../src/main.cpp),[:514-515](../src/main.cpp),[:542-543](../src/main.cpp) | loop, [:527-528](../src/main.cpp),[:542-543](../src/main.cpp) | shared already | +| 5 | BLE command dispatch | **loop task**, ring drain [:406-423](../src/main.cpp); producer on NimBLE host task [esp32_ble_callbacks.h:117-129](../src/esp32_ble_callbacks.h) | **Bluefruit Callback task**, payload copied by `ada_callback` **[LIB]**; registered [ble_init.cpp:157](../src/ble_init.cpp) | **platform** — NimBLE has no `ada_callback`; the ESP32 ring hand-rolls what Bluefruit ships | +| 6 | BLE response TX | queued → `flushResponseQueueToBle()` [:275-313](../src/main.cpp), 16/call | inline `notify()` + bounded retry [communication.cpp:334-355](../src/communication.cpp) | platform-adjacent | +| 7 | Disconnect teardown | flag → `serviceBleDisconnectCleanup()` [:321-348](../src/main.cpp),[:428](../src/main.cpp) | on Callback task [device_control.cpp:227-240](../src/device_control.cpp) | **incidental**, but nRF form is safe today via FIFO ordering (R2) | +| 8 | `updatemsdata()` on connect | flag → loop [esp32_ble_callbacks.h:55](../src/esp32_ble_callbacks.h),[main.cpp:429-432](../src/main.cpp) | **inline on Callback task** [device_control.cpp:219](../src/device_control.cpp) | **incidental — latent nRF defect (§2.1)** | +| 9 | `updatemsdata()` periodic | 60 s, idle branch only [:509-513](../src/main.cpp) | every `sleep_timeout_ms`, only if nonzero [:519-522](../src/main.cpp) | incidental | +| 10 | Advertising restart/interval | flag → `esp32_restart_ble_advertising()` [:433-435](../src/main.cpp),[ble_init.cpp:227-245](../src/ble_init.cpp) | `ble_nrf_advertising_tick()` [:526](../src/main.cpp),[:540](../src/main.cpp),[ble_init.cpp:59-76](../src/ble_init.cpp) | platform | +| 11 | Direct-write 15-min watchdog | loop [:436-442](../src/main.cpp) | **absent** | **incidental — real gap** | +| 12 | `checkPartialWriteTimeout()` | loop [:443](../src/main.cpp) | **absent** | **incidental — real gap** | +| 13 | WiFi/LAN + 10 s supervisor | loop [:444-465](../src/main.cpp) | n/a | platform | +| 14 | `pollActivity()` | loop [:218-265](../src/main.cpp), called [:356](../src/main.cpp) | n/a | platform | +| 15 | Post-wake window + `enterDeepSleep()` | [:360-398](../src/main.cpp),[:486-500](../src/main.cpp) | n/a | platform | +| 16 | `workInFlight` cadence | [:474-517](../src/main.cpp) | `idleDelay(sleep_timeout_ms)`/`(500)` [:519-525](../src/main.cpp) | platform in substance | +| 17 | Ring flush on disconnect | **absent both** | n/a | plan Phase 3 | + +**Net:** rows 13–16 plus the drain account for ~130 of the ~160 ESP32 lines. Genuinely incidental divergence (rows 8, 9, 11, 12) totals **about 15 lines**. That ratio is the answer. + +### 2.1 Concrete latent defect this exposes + +`updatemsdata()` ([display_service.cpp:1734-1820](../src/display_service.cpp)) polls I²C sensors, reads the battery ADC, then on nRF does `clearData`→`addFlags`→`addName`→`addData`→`stop()`→`start(0)` ([display_service.cpp:1767-1783](../src/display_service.cpp)) guarded by an unlocked file-static `prev_msd_payload_nrf[16]`. Called from the **Callback task** ([device_control.cpp:219](../src/device_control.cpp)) *and* the **loop task** ([main.cpp:521](../src/main.cpp)) concurrently, where the Callback task outranks loop **[LIB]** (`AdaCallback.c:147` `TASK_PRIO_NORMAL` vs `cores/nRF5/main.cpp:88` `TASK_PRIO_LOW`). This is exactly the hazard ESP32 avoided ([esp32_ble_callbacks.h:52-55](../src/esp32_ble_callbacks.h)). Two-line fix, independent of every option. + +## 3. Can nRF adopt the queue model? + +### 3.1 RAM — non-issue, measured + +A clean `pio run -e nrf52840custom` was performed (SUCCESS, 3.4 s): +`RAM: 18.0% (42772 / 237568 B)`, `Flash: 31.1% (251972 / 811008 B)`. `size -A`: `.text` 251 056, `.data` 908, `.bss` 41 864, **`.heap` 192 748**. Largest `.bss`: `pipeReorder` 8 316 B (nRF takes the full 33-slot window — `PIPE_SMALL_DRAM_WINDOW` is `esp32-N4`-only, [structs.h:44-56](../src/structs.h)), `chunkedWriteState` 4 116, `configScratch` 4 096, `Bluefruit` 1 932. + +Ring cost: `CommandQueueItem` = 260 B padded ([esp32_ble_callbacks.h:25-29](../src/esp32_ble_callbacks.h)) × 33 ([main.h:371](../src/main.h)) = **8 580 B**; `ResponseQueueItem` 260 × 10 = **2 600 B**; total **≈11.2 KB** → `.bss` ~53 KB, heap ~181 KB. **RAM does not constrain this decision.** Reproduce with `~/.platformio/penv/bin/pio run -e nrf52840custom` (bare `pio` is not on `PATH` here). + +### 3.2 The framework already does what the ring was invented for + +`ada_callback()` **[LIB]** (`AdaCallback.c:106-140`) mallocs a copy and `xQueueSend`s it (100 ms bounded, `CFG_CALLBACK_TIMEOUT`); on queue-full it **doubles the queue depth and retries** (`AdaCallback.c:76-99`). Today: *SoftDevice event → copy → elastic FIFO → dispatch on a NORMAL-priority task in arrival order, with connect/write/disconnect serialized.* Under C: *…→ second copy into a 33-slot ring → second hop → dispatch on a LOW-priority task usually inside `delay()`, with **drop-on-full** ([esp32_ble_callbacks.h:127-129](../src/esp32_ble_callbacks.h)), and disconnect no longer FIFO-ordered against in-flight writes.* Every property that changes, changes for the worse except one. + +### 3.3 Flow control + +The characteristic is `BLEWrite | BLEWriteWithoutResponse | BLENotify`; for a stack-located value the SoftDevice generates the ATT response itself, so inline processing already gives **no** ATT-level backpressure — deferring loses nothing. *(Assumption; verify on hardware by checking whether client write-with-response latency tracks firmware dispatch time.)* What the current model does give is implicit rate limiting via queue **growth**; a fixed ring converts that into `"Command queue full, dropping command"`. The pipe protocol absorbs drops (SACK zero-bit → retransmit; `docs/pipe-write-protocol.md` §5.2, plan `[H1]`), but usable ring depth is `COMMAND_QUEUE_SIZE-1 = 32` — exactly `PIPE_MAX_W`, zero headroom for the `0x0082` END (the `[H1]` off-by-one; the [main.h:365-370](../src/main.h) comment is wrong). **A queue makes drops more likely on nRF, not less.** + +### 3.4 Latency/throughput — the real cost + +The nRF steady state is `idleDelay(sleep_timeout_ms)`/`(500)` ([:519-525](../src/main.cpp)), chunking at 100 ms and servicing-then-sleeping ([:538-549](../src/main.cpp)). Draining from `loop()` alone delays each frame by the whole `idleDelay` (up to 65 s expressible). Draining from `idleDelay` bounds it at ~100 ms — a ceiling of roughly **78 KB/s** (32 × 244 B per 100 ms) plus a 100 ms floor on ACK latency the pipe SACK cadence ([display_service.cpp:2854](../src/display_service.cpp)) is not tuned for. Recovering today's throughput means rebuilding `idleDelay` into a real event loop — a change to the one function *both* targets share, risking 11 envs to fix an nRF-only problem. + +### 3.5 Response path + +nRF `sendResponse` notifies inline with a bounded 4×5 ms retry ([communication.cpp:342-349](../src/communication.cpp)) — ≤20 ms, on backpressure only. A response ring would **hurt** latency (≤100 ms), **help** nothing (ESP32 needs one because its dispatcher *is* the loop task), and raise a new question about `notify()` from two tasks. **Do not add a response ring to nRF under any option.** + +### 3.6 What breaks if dispatch leaves the Callback task + +Little — which is why C is *feasible* though unwise. Nothing reads the callback args (`(void)conn_hdl; (void)chr;`, [communication.cpp:625-627](../src/communication.cpp)). DFU uses the global accessor `Bluefruit.disconnect(Bluefruit.connHandle())` ([device_control.cpp:844-848](../src/device_control.cpp)), valid from any task; the bootloader jump ([:850-866](../src/device_control.cpp)) is arguably safer off the stack's own task. **But ordering is load-bearing:** disconnect is FIFO-ordered behind queued writes **[LIB]** (`bluefruit.cpp:849`); split them and a disconnect tears down `pipeState`/`partialCtx`/`directWriteActive` ([device_control.cpp:237-239](../src/device_control.cpp)) underneath frames still in the ring. So C must *also* defer disconnect. And the `transferActive()` touch gate ([touch_input.cpp:584-586](../src/touch_input.cpp), `#ifdef TARGET_ESP32`) becomes newly *necessary* on nRF. C is "port the entire ESP32 deferral discipline", not "add a ring". + +## 4. Options + +**A — full convergence.** ~200 LOC changed across `main.cpp/.h` + stubs in `wifi_service`/`ble_init`/`power_latch`. The ESP32 arm has three early `return`s ([:366](../src/main.cpp),[:389](../src/main.cpp),[:397](../src/main.cpp)) that skip the rest of the pass, and a `workInFlight` branch whose arms differ in cadence *and* in which services they call ([:480-517](../src/main.cpp)). Target-neutral form means either keeping the early returns (so "shared" is fiction) or restructuring deep sleep — the least-testable-without-hardware subsystem. **Risk HIGH. Reject.** + +**B — shared supervisory tick (recommended, fused with D).** New `static void serviceSupervisoryTick()` in `main.cpp`: `processLedFlash` + `epdSessionTick` + `buzzerService` + both wall-clock watchdogs (+ Phase 6 supervisor later). Called at the top of `loop()` and per chunk in `idleDelay()`. ~50 LOC net, one file, deletes ~6 duplicated lines. Closes the nRF watchdog gap *structurally*; gives Phases 6/7 **one** wiring point; makes the supervisor fire *during* a long `idleDelay` — which an nRF-arm-only addition cannot, since that arm doesn't run while blocked. **Risk LOW-MEDIUM**, entirely from H4: teardown on the loop task can race the Callback task, so gate the watchdog arm on `g_commandInFlight == 0` and take `pwrmgmLock`. Keep the tick `millis()`-only (it also runs in the ESP32 post-wake window, [:396](../src/main.cpp)). + +**C — nRF adopts the queue model.** ~150 LOC across `main.cpp/.h`, `ble_init.cpp`, `device_control.cpp`, `communication.cpp`, `touch_input.cpp`, `structs.h`. Benefit: eliminates every nRF cross-task hazard — `pwrmgmLock` could become an assert, H4 unnecessary, Phase 3 single-task on both targets, §2.1 race gone. Cost: §3.2–3.6, all throughput/timing-sensitive and **unverifiable without hardware**. **Risk HIGH. Reject.** + +**D — targeted additions only.** ~15 LOC in `main.cpp` + `device_control.cpp`. Same H4 caveat. Weakness: the additions sit in the nRF arm body, which doesn't run while `idleDelay` blocks (up to 65 s), and reintroduce the copy-paste that produced the gap. **Strictly dominated by B; fuse into B.** + +**E — shrink the ESP32 arm instead** (extract `serviceBleQueues`/`serviceWiFiLink`/`serviceSleepPolicy`/`servicePostWakeWindow`, ~120 LOC of pure motion, no behaviour delta). Readability only, and pure motion here makes the freeze-proofing diff unreviewable. **Do it after the plan ships.** + +## 5. Interaction with the freeze-proofing plan + +The plan already concedes the problem: *"nRF has NO transfer watchdog today — H3 is 'keep' on ESP32 but 'ADD' on nRF … Neither the original plan nor the adversarial review caught this."* B is the cheapest structural answer. + +| Plan item | Under B | Under C | Under D | +|---|---|---|---| +| **H4** depth counter | **Still required and becomes a hard prerequisite** (B moves teardown onto the nRF loop task) | Would become unnecessary — only after C's full deferral discipline, which is the risky part | Same as B | +| **Phase 3** `abortToKnownState` cross-task safety | Simpler: one call site, one rule ("only when depth == 0") | Simplest in principle, highest cost to reach | Two call sites; per-target reasoning persists | +| **Phase 5 H4** deferred nRF session clear | Unchanged; flag serviced from the tick, which now runs in both arms | Folds into the general deferral | Unchanged | +| **Phase 6** supervisor | **Clearly simpler** — the "must be wired into the nRF path" requirement is satisfied structurally, and coverage extends into `idleDelay` | Simpler still, at C's cost | Two bodies to keep in sync — the exact failure mode that produced the gap | +| **Phase 6 `[X5]`** | Unaffected | Unaffected | Unaffected | +| **Phase 7** BLE idle timeout | Slightly simpler (check belongs in the tick). Note the `[C1]` stamp stays in `imageDataWritten`, i.e. on the Callback task on nRF → must be `volatile uint32_t`, comparison must tolerate a concurrent write | Simpler (single task) | Two arms again | +| **Phases 1/2** | Orthogonal | Orthogonal | Orthogonal | + +**Sequencing:** (1) Phases 1 and 2 unchanged — highest value-per-risk. (2) **H4 on its own**, before anything drives teardown from the nRF loop task. (3) **Option B here**, as a pure refactor + two watchdog additions, in its own commit. (4) Phases 3/5/6/7 wire into `serviceSupervisoryTick()` instead of into two arms. Do **not** land B before H4, and do **not** fold B into a phase commit. + +## 6. Risk + +| Risk | Under B | Under C | +|---|---|---| +| Transfer throughput | Nil (dispatch untouched) | **High** — ~78 KB/s ceiling unless `idleDelay` is rebuilt | +| Command latency | Nil | ≤100 ms/frame + ≤100 ms/ACK | +| Teardown racing a live transfer | **Main risk** — mitigated by H4 gate + `pwrmgmLock` | Eliminated | +| Deep sleep | nRF has none; ESP32 exposure via `idleDelay` ([:396](../src/main.cpp)) — keep tick `millis()`-only | Same ESP32 exposure | +| DFU | Untouched | Bootloader jump moves to loop task — plausible, unproven | +| Power draw | Negligible | Unknown; more task switches | + +**Test coverage today: none.** `find . -name "*test*"` outside `.pio` yields only `tools/test_zlib_stream.c` (host-side zlib harness). CI (`.github/workflows/main.yaml:12-44`) is **build-only** across 11 envs. Green CI proves they link, nothing more. + +**Mandatory hardware validation for B:** (a) nRF stalled direct write → watchdog fires, panel rail drops; (b) same for a stalled `0x76` partial; (c) full Spectra push with 60 s refresh → tick does *not* tear down mid-refresh (`epdRefreshInProgress`/`pwrmgmLock` gating); (d) ESP32 battery unit → post-wake window and idle-hold timings unchanged. **For C additionally:** measured throughput before/after, sniffer trace for dropped `0x0081` at W=32, heap-pressure soak exercising the inline-fallback path, DFU-from-loop-task. + +**Cannot be validated without hardware:** all of the above, plus SoftDevice ATT auto-response behaviour, inline-fallback frequency under heap pressure, `notify()` from the loop task, and every panel-rail timing interaction. + +## 7. Historical evidence + +Both deliberate and drift, in sequence — **no evidence of a reverted convergence attempt**. + +The original nRF-only firmware already used *deferral*: `git show df6d088:src/main.cpp` shows `loop()` draining a `currentImage.ready` flag set by the BLE callback. The split was born with ESP32 support in `955f2d0` *("Add ESP32-C6 and ESP32-C3 support")*, whose first `#ifdef TARGET_ESP32` in `loop()` states the rationale outright: `// Process queued commands outside of callback context`. That is a platform statement — Bluedroid/NimBLE has no `ada_callback` equivalent; nRF didn't need a ring because Bluefruit already provides one. + +**Everything since is drift.** `git log -L 518,530:src/main.cpp` shows the nRF arm changed exactly **three** times ever: `7ffea91` (added `ble_nrf_advertising_tick()`), `aad0c6d` *("Drop per-iteration 'Loop end' debug log on nRF")*, `5ed21a9` *("feat: quarter-tone musical buzzer…")* (added `buzzerService()`). Over the same period the ESP32 arm absorbed deep sleep (`ee98b65`, `d974f9d`, `c377dec`), the watchdogs (`abcf95d` *"Add stuck-state watchdog for abandoned partial writes (panel rail)"*), WiFi/LAN (`2e2131b`), the NimBLE migration (`d4da951`) and the pipe drain (`a628d84`). **The nRF arm did not diverge; it stood still while the ESP32 arm grew** — which is why the missing watchdog is a gap, not a decision. + +One near-miss: the unmerged branch `feat/esp32-freertos-command-queue` (single commit `43b0799`, never merged): *"Swap the volatile-index SPSC command ring for a static FreeRTOS queue (`xQueueCreateStatic`), fixing cross-core memory-ordering races and the silent drop-on-overflow. … **ESP32-only; the processing pipeline (`imageDataWritten`) and the nRF/WiFi paths are unchanged.**"* The last time anyone touched this machinery the scope was drawn deliberately at the ESP32 boundary — corroboration for keeping the execution models separate. + +## 8. What would have to be true + +| # | Assumption | Check | If false | +|---|---|---|---| +| 1 | Bluefruit dispatches writes on a separate FIFO task with an elastic queue | `AdaCallback.c:76-99, 145-147` **[LIB]** (done); on hardware log `uxQueueMessagesWaiting`/free heap during a full-window push | C's cost/benefit flips; re-evaluate C | +| 2 | Callback task outranks loop (`NORMAL` vs `LOW`) | `AdaCallback.c:147`, `cores/nRF5/main.cpp:88` **[LIB]** (done); corroborated by [display_service.cpp:401-407](../src/display_service.cpp) | `pwrmgmLockTake`'s inversion mitigation is over-engineered but harmless | +| 3 | ~11 KB extra `.bss` affordable on nRF | Measured: 42 772/237 568 B, 192 748 B heap | Only matters for C | +| 4 | Watchdogs safe from the nRF loop task once `g_commandInFlight == 0` | Inspection says yes; **must be confirmed on hardware** with a stalled transfer + concurrent traffic | B degrades to "check and log only"; Phase 6 supervisor owns teardown | +| 5 | `millis()`-only additions to `idleDelay` don't perturb ESP32 deep sleep | Battery soak, compare wake-window/idle-hold before/after | Call the tick from `loop()` only (loses long-block coverage) | +| 6 | SoftDevice, not the app callback, generates the ATT write response | Hardware: client write-with-response latency vs firmware dispatch time | Further argument against C; no impact on B | +| 7 | No client depends on nRF's sub-ms dispatch latency | py-opendisplay timeouts; full-panel timing run | Only matters for C | + +## 9. Do not do this + +1. **Do not move nRF dispatch to the loop task without also deferring `disconnect_callback`.** They are FIFO-ordered today **[LIB]** (`bluefruit.cpp:849`); split them and a disconnect tears down `pipeState`/`partialCtx`/`directWriteActive` ([device_control.cpp:237-239](../src/device_control.cpp)) underneath queued frames — use-after-teardown on a live transfer, worse than any freeze this work targets. +2. **Do not add a response ring to nRF.** Buys nothing (§3.5), adds ≤100 ms to every pipe ACK, throttling the window. +3. **Do not copy `COMMAND_QUEUE_SIZE 33` to nRF as-is.** Usable depth is 32 ([esp32_ble_callbacks.h:122](../src/esp32_ble_callbacks.h)) = `PIPE_MAX_W`, no slot for the `0x0082` END — importing the `[H1]` off-by-one onto a target that lacks it. +4. **Do not "simplify" `idleDelay` into a plain `delay()`.** It is the only servicing of buttons/touch/LED/keep-alive/buzzer during long waits on both targets ([:538-548](../src/main.cpp)) and keeps the ESP32 post-wake window responsive ([:391-396](../src/main.cpp)). +5. **Do not put I²C, SPI or advertising work in the shared tick.** It runs every 100 ms inside `idleDelay` on both targets, including the ESP32 deep-sleep advertising window. `millis()` comparisons and flag checks only. +6. **Do not add the nRF watchdogs before H4.** `cleanupDirectWriteState(true)` from the loop task while the Callback task is inside `handlePipeWriteData` is a new freeze mechanism dressed as a fix. +7. **Do not un-`#ifdef` the `transferActive()` touch gate "for symmetry" under B** ([touch_input.cpp:584-586](../src/touch_input.cpp)). Under B the transfer still runs on the Callback task, so the gate isn't needed — enabling it would kill touch on nRF for every transfer with no benefit. +8. **Do not restructure `loop()` and change behaviour in one commit.** Zero automated tests + build-only CI = unreviewable, unbisectable. +9. **Do not treat green CI as validation.** It links; it does not run. + +## 10. Uncertainty + +Timing, throughput and power figures are inference from source plus arithmetic — no nRF hardware was exercised; the ~78 KB/s is a computed ceiling, not a measurement. RAM numbers **are** measured but reflect section partitioning only; FreeRTOS stacks and Bluefruit's dynamic allocations come out of the 192.7 KB heap and were not runtime-profiled. Bluefruit internals were inspected at exactly three points (write dispatch, callback queue, connect/disconnect dispatch); the **frequency** of the inline-fallback path that `[H4]` depends on is unknowable from source. I did not evaluate whether Bluefruit's queue growth is bounded in practice — it doubles from the heap, trading a dropped frame for heap pressure, a different failure mode deserving its own investigation if nRF ever shows heap exhaustion during large pushes. Finally, "nRF is safe today" rests on the Callback task's FIFO ordering; §2.1 documents one place that ordering does not protect. There may be others — the BLE-adjacent call graph was checked, not every shared global. diff --git a/docs/FINDINGS_PHASE1_PLAN_REVIEW_2026-07-26.md b/docs/FINDINGS_PHASE1_PLAN_REVIEW_2026-07-26.md new file mode 100644 index 0000000..0169586 --- /dev/null +++ b/docs/FINDINGS_PHASE1_PLAN_REVIEW_2026-07-26.md @@ -0,0 +1,431 @@ +# Adversarial Review — PLAN_PHASE1_NONCE_REPLAY_2026-07-26 + +**Date:** 2026-07-26 · **Reviewing:** [`PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md`](PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md) (branch `debug/ble-hardening`) +**Scope:** `src/encryption.cpp`, `src/encryption_state.h`, their callers, and the two clients that +drive them (`py-opendisplay`, the web client `ble-common.js`) + +> Companion to [`FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md`](FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md). +> Where the two disagree, this document is the later and more specific analysis — in particular +> it **retracts** part of that review's `[M1]` (see `M2` and `L1`). + +**Independently re-verified after the review was produced** (not taken on trust): `C1`'s +selective-repair transmit site and `max_retx` formula; `H1`'s `IntegrityCheckError` path and the +pipe loop's single `except`; `H2`'s shared nonce space in `encryptResponse`; `H3`'s +unconditional ring write; `L3`'s arithmetic; `L5`'s 11-leg CI matrix. All confirmed as +described. + +--- + +## Verdict + +**Phase 1 is safe to ship alone — no new freeze mode, no new remote DoS, no regression on either +target — but not as written.** Two edits are mandatory (`C1`, `M1`), one is strongly recommended +(`H1`), two need a paragraph each (`H2`, `H3`). + +D1–D4 are real (three exactly as described, one overstated), the check/commit split is the +correct shape, and the sliding-bitmap state machine is correct as specified. `−496 B`, +`+1,536 B`, `PIPE_MAX_W` 32/16, `MAX_PTO = 3`, the `protocol.h:384` citation and the "one counter +burned per transmission" premise all check out. Three things do not: `C1`, `H1`, `H2`. + +**Findings: 1 Critical, 3 High, 3 Medium, 7 Low.** + +--- + +## CRITICAL + +### C1 — `OD_NONCE_FORWARD_CAP = 64` is derived from a bound that does not hold + +**Plan element:** Decision A — *"worst-case unseen run = `PIPE_MAX_W + MAX_PTO` = 32 + 3 = 35 … +This is a **hard bound, not an estimate**"* — plus Step 6, which instructs writing that sentence +into `communication.cpp` as the invariant future changes are checked against. + +**What the derivation assumes:** *"After the window is exhausted it blocks for an ACK; on timeout +it resends exactly one chunk (`_send(window_base)`), never another window."* Accurate for the +**PTO** path ([device.py:2715-2726](../../py-opendisplay/src/opendisplay/device.py)) and for +**new** sends (`:2688-2694`, gated on `(next_to_send - window_base) < window`). It ignores the +third transmit site. + +**The site it ignores** — selective repair, which spends no window credit at all: + +```python +device.py:2789-2796 + for m in missing: # oldest first + ... + if do_retx: + await _send(m) + pending_retx[m] = 0 + retx_count += 1 +``` + +`missing` is every hole below `highest_recv` (`device.py:2771-2772`), up to `W-1` entries. Each +hole is re-sent again every `PIPE_RETX_ACK_SPACING = 2` ACKs (`commands.py:99`, +`device.py:2782-2788`). The only cap on the total is +`max_retx = max(3*window, ceil(n * 0.5))` — **96** for `W = 32`, and far larger for a +multi-thousand-chunk upload (`device.py:2672`, `commands.py:96`). + +**And the client deliberately hoards ACKs to spend on those rounds:** + +``` +device.py:781 Passes ``drain_stale=False`` so queued sliding-window ACKs are preserved. +device.py:786 await self._conn.write_command(self._encrypt_frame(data), response=response, drain_stale=False) +``` + +So `B` backlogged ACKs, processed while the device is not consuming anything (mid-`bbepWaitBusy`, +or with the 33-slot command ring full — [esp32_ble_callbacks.h:119-129](../src/esp32_ble_callbacks.h), +which drops on the NimBLE host task *before* decrypt), buy `⌈B/2⌉` repair rounds of up to `H` +holes each. Firmware supplies the backlog: it ACKs every `ack_every` accepted frames +([display_service.cpp:2854](../src/display_service.cpp)) **and** once per `ack_every` +out-of-order arrivals plus one immediately when a gap opens (`:2875-2892`). + +**The arithmetic.** With `W` in flight, `H` holes and `N = ack_every`, the device emits about +`(W−H)/N` ACKs before it stalls, and the client can spend them on `⌈(W−H)/(2N)⌉` repair rounds: + +| `W` | `N` | worst `H` | repair transmissions | + PTO probes | total gap | +|---|---|---|---|---|---| +| 32 | 8 (py default, `device.py:497`) | 16 | 16 | 2 | ~18 | +| 32 | 4 (HA default, `const.py:51`) | 20 | 40 | 2 | ~42 | +| 32 | **2** | 16 | 16 × 4 = **64** | 2 | **66** ✗ | +| 32 | **1** | 16 | 16 × 8 = 128, capped at `max_retx` = 96 | — | **~96** ✗ | +| 16 | 4 (web client, `ble-common.js:38-39`) | 8 | 8 | 2 | ~10 | + +`N = 1` and `N = 2` are not hypothetical: `blocks_per_ack` is a Home Assistant options-flow field +with `min=1, max=32` (`config_flow.py:104-113`), `py-opendisplay` documents it as "1..32" +(`device.py:525`), firmware clamps only at the *top* +([display_service.cpp:2723-2725](../src/display_service.cpp)), and +[main.cpp:269](../src/main.cpp) explicitly sizes the response-flush path for *"small negotiated +ack_every (N_eff 1-2)"*. + +**Why it matters.** Not a freeze — after the D1 fix an over-cap gap drops frames and keeps the +session. It matters because (a) the plan instructs writing "35 < 64" into the source as a +permanent invariant, and (b) it converts a *recoverable* transfer into a failed one exactly in +the lossy conditions Phase 1 exists to survive: at cap 128 the device re-syncs when it drains; at +cap 64 it rejects and the client burns its remaining `max_retx` budget rediscovering that by +timeout. + +**Correction:** (1) set `OD_NONCE_FORWARD_CAP = 128` — under the bitmap the cap costs nothing, +and the DoS argument for keeping it tight does not survive `M2`; (2) re-derive from the client's +**retransmit budget** `max_retx = max(3·W, n/2)`, stating plainly that firmware cannot bound it +from its own constants; (3) write the *mechanism* into the Step 6 comment, not a number that a +`blocks_per_ack` change in another repo silently invalidates. + +--- + +## HIGH + +### H1 — Decision E's "the client recovers on its own" is false + +`decryptCommand` returning false produces an unencrypted 3-byte NACK for **every** rejection +reason: + +```c +communication.cpp:698-703 + if (!decryptCommand(...)) { + od_log_error("ERROR: Decryption failed"); + uint8_t response[] = {RESP_ACK, (uint8_t)(command & 0xFF), RESP_NACK}; + sendResponseUnencrypted(response, sizeof(response)); +``` + +`RESP_NACK = 0xFF` ([opendisplay_protocol.h:698](../include/opendisplay_protocol.h)). The +client's `_read` intercepts that shape before any pipe-frame classification: + +``` +device.py:833-838 + if len(raw) == 3 and raw[2] == 0xFF: + raise IntegrityCheckError(...) +``` + +and the pipe send loop's only `except` is `BLETimeoutError` (`device.py:2716`). +`IntegrityCheckError` propagates straight out of `_stream_pipe_chunks`; the upload fails on the +**first** out-of-window frame. There is no retransmit machinery to recover with — the frame the +client would have repaired is the one whose NACK aborted it. + +Phase 1's field benefit is real but narrower than claimed: the device stays clean and ready for +the *next* connection instead of answering `0xFE` to everything. The in-progress transfer still +dies. + +**Correction:** on `NONCE_OUT_OF_WINDOW` / `NONCE_REPLAY` for `0x0081`, send **nothing** (let +PTO/SACK treat it as the plain frame loss it is) or send the normal pipe ACK. Keep `RESP_NACK` +for tag failures. ~5 lines in `communication.cpp:698-703` behind a reason code out of +`nonceCheck`; it converts Phase 1 from "the device survives" into "the transfer survives". +Decision E's recorded wire change remains right for non-pipe opcodes. + +### H2 — Device and client share one nonce space: CCM keystream reuse (shipping defect, unaddressed) + +```c +encryption.cpp:158-174 +void getCurrentNonce(uint8_t* nonce) { + memcpy(nonce, encryptionSession.session_id, 8); + uint64_t counter = encryptionSession.nonce_counter; // starts at 0 (:210, :655) + for (int i = 0; i < 8; i++) nonce[8 + i] = (counter >> (56 - i * 8)) & 0xFF; +} +``` + +```python +crypto.py:92-113 +def get_nonce(session_id: bytes, counter: int) -> bytes: + return session_id + counter.to_bytes(8, "big") +... + ccm_nonce = nonce_full[3:] # 13 bytes +``` + +`encryptResponse` ([encryption.cpp:740-743](../src/encryption.cpp)) and `decryptCommand` +(`:704-705`) both take `nonce_full[3:16]`. Same `session_key`, same `session_id`, **no direction +separator**, both counters reset to 0 at session start (`encryption.cpp:210`/`:655`; +`device.py:735`; `ble-common.js:1259`). Response #*k* and command #*k* therefore encrypt under an +identical (key, nonce). CCM is CTR underneath: the keystream depends only on key and nonce, not +the AD (which does differ), so `C_resp ⊕ C_cmd = P_resp ⊕ P_cmd`. Responses are short and highly +predictable (`{RESP_ACK, cmd, status}`, pipe ACKs), so a passive eavesdropper recovers the +leading plaintext bytes of the matching command. No authenticity break, but a textbook +nonce-reuse confidentiality failure. + +**Correction:** the fix is a direction bit — a wire change of the same cost class as Decision E, +and correctly out of Phase 1 scope. What *is* in scope: **record it** alongside Decision E so one +future wire revision fixes both. Doing nothing and not writing it down is the only unacceptable +outcome, because Phase 1 is the change that makes a future reader believe this layer has been +audited. + +### H3 — D3 understated: the exemption lets an attacker flush the entire replay ring + +The core claim is verified ([encryption.cpp:136](../src/encryption.cpp)). What the plan misses is +that the accept path writes the ring unconditionally, *including* on the exempted `diff == 0` +re-accept: + +```c +encryption.cpp:152-154 + static uint8_t replay_window_index = 0; + encryptionSession.replay_window[replay_window_index] = nonce_counter; + replay_window_index = (replay_window_index + 1) % 64; +``` + +Replaying the highest-seen frame **64 times** overwrites all 64 slots with that one value, +evicting every genuine entry, while `last_seen_counter` never moves (`:149-151` is +`>`-conditional) so the ±32 check at `:131` still admits `[L−32, L]`. **Every one of the last 32 +genuine commands then becomes replayable** — config writes, power-off, buzzer, LED. + +Reachable on ESP32: `CONFIG_BT_NIMBLE_MAX_CONNECTIONS` is **3** (`sdkconfig.h:613`; the `-D…=1` +override is inert), the write callback does not discriminate conn handles +([esp32_ble_callbacks.h:119-129](../src/esp32_ble_callbacks.h)), and `isAuthenticated()` +(`:195-199`) is a global flag with no peer binding — a second central can write captured frames +into a live session. On nRF `Bluefruit.begin(1, 0)` ([ble_init.cpp:152](../src/ble_init.cpp)) +caps at one link. + +**Correction:** no design change (the bitmap closes it). Restate D3's consequence and add a +Step 5 hardware test: replay the final frame 64× then replay an *older* in-window counter — must +be `NONCE_REPLAY`. + +--- + +## MEDIUM + +### M1 — Step 2 and Decision D prescribe different deltas, and Step 2's is UB on attacker-controlled input + +Step 2: `diff = (int64_t)counter - (int64_t)last_seen_counter`. Decision D: *"compute the delta as +`(int64_t)(counter - last_seen)` rather than subtracting two casted `int64_t`s."* Not the same +expression; Step 2's is wrong. The 8 counter bytes are plaintext and parsed **before** tag +verification ([encryption.cpp:119-121](../src/encryption.cpp), called from `:691` ahead of +`aes_ccm_decrypt` at `:714`), so an unauthenticated attacker controls the delta. +`(int64_t)nonce_counter` for `≥ 2^63` is an out-of-range conversion and the subtraction overflows +— UB that the plan's own `-fsanitize=undefined` gate will flag against the expression Step 2 +mandates. Today's `:130` has exactly this construct. + +Decision D's form is not sufficient either: the Step 2 table uses `-diff` twice, and `-diff` is +UB when `diff == INT64_MIN` (reachable with `counter = last_seen + 2^63`). + +**Correction:** drop signed arithmetic entirely: + +```c +const uint64_t fwd = counter - last_seen; /* wraps; 0 when equal */ +const uint64_t back = last_seen - counter; /* wraps; fwd + back == 0 mod 2^64 */ +if (fwd == 0) return bit_test(bm, 0) ? NONCE_REPLAY : NONCE_OK; +if (fwd <= OD_NONCE_FORWARD_CAP) return NONCE_OK; +if (back < OD_NONCE_BACKWARD_BITS) return bit_test(bm, back) ? NONCE_REPLAY : NONCE_OK; +return NONCE_OUT_OF_WINDOW; +``` + +Make Step 2's table match so `nonce_window.h` and the plan cannot diverge. + +### M2 — "Tight is correct here" rests on a jam-forward DoS that cannot happen + +Decision A inherits `[M1]` from the parent review. Under Phase 1's own design it does not hold: + +1. **A replay cannot push `last_seen_counter` past the client's high-water mark.** After the D2 + fix only a CCM-verified frame commits (Step 3), so the only counters an attacker can commit are + ones the client actually transmitted. No *future* client frame is ever below the window. +2. **Repairs carry fresh, higher counters.** `_encrypt_frame` increments on every transmission + including repairs (`device.py:747-760`, `:759`) — the plan cites this itself. The "stranded" + frames do not exist. + +The only frames a jam can strand are ones already in flight at lower counters, and those land in +the 127-wide **backward** window, where they are accepted as unseen. Net damage: zero. + +**Correction:** delete the "Why not more" paragraph; it is the only argument that produced 64 +instead of 128. Note that `[M1]`'s concern was an artifact of the value-ring design and does not +survive commit-after-verify + bitmap. + +### M3 — The `resetNonceState()` fold merges two blocks that are not equivalent + +| Field | `clearEncryptionSession` (`:205-217`) | `handleAuthenticate` (`:654-662`) | +|---|---|---| +| `nonce_counter` | `= 0` (`:210`) | `= 0` (`:655`) | +| `last_seen_counter` | `= 0` (`:211`) | `= 0` (`:656`) | +| `replay_window` | `memset` (`:217`) | `memset` (`:660`) | +| `integrity_failures` | `= 0` (`:212`) | `= 0` (`:657`) | +| `authenticated` | `= false` (`:209`) | `= true` (`:654`) | +| `session_start_time`/`last_activity` | `= 0` (`:213-214`) | `= currentTime` (`:658-659`) | +| keys/nonces/`ccm_ctx` | wiped (`:203-208`) | populated | +| `auth_attempts`, `server_nonce_time` | `= 0` (`:215-216`) | `server_nonce_time = 0` only (`:662`) | + +Only four fields are common; the rest are opposite. A fold worded as "bitmap + +`last_seen_counter`" silently drops `nonce_counter = 0` the first time someone tidies the +surrounding lines — and a device that keeps its outbound counter across a re-auth while the client +restarts at 0 walks into `H2`'s keystream reuse with *itself*. + +**Correction:** define `resetNonceState()` = +`{ nonce_counter = 0; last_seen_counter = 0; memset(replay_bitmap); integrity_failures = 0; }`, +naming all four fields. Leave every other field where it is. + +--- + +## LOW + +**L1 — D4 is not a live bug.** The facts are right (`replay_window_index` is a function static at +`:152`, reset by neither `:217` nor `:660`); the consequence is not. Both reset sites `memset` the +ring to **all zeros**, so writing from an arbitrary offset into a uniformly-empty 64-slot ring +with a +1 index produces exactly the same strict-FIFO eviction order as starting from 0. No +counter's accept/reject differs. The one real artifact is the `0`-as-empty sentinel: counter 0 can +never be accepted through the backward branch — masked today by D3's exemption, i.e. **D3 is +load-bearing for D4's representation**. Demote D4 to "latent"; keep the fix. + +**L2 — `nonceCommit()` placement is under-specified.** The success arm has an early return between +`:717` and `integrity_failures = 0`: + +```c +encryption.cpp:717-725 + if (success) { + uint8_t payload_length = decrypted_with_length[0]; + if (payload_length > encrypted_len - 1) { ...; return false; } // authentic frame + ... + encryptionSession.integrity_failures = 0; +``` + +If the commit lands after `:722`, an authentic-but-malformed frame is left replayable — and +today's code *does* commit it (`:149-153` is unconditional), so this is a behaviour change. Say +"first statement of the success arm, `:718`". + +**L3 — Decision B's storage table contradicts its own formula.** `2W × 8 B` = 16 B per unit ✓ for +the 32-row (512 B). Rows 2–3 are exactly 2× too large: 127 × 16 = **2,032 B** not 4,064; +255 × 16 = **4,080 B** not 8,160. Conclusion unaffected. + +**L4 — `PIPE_MAX_W + MAX_PTO` over-counts by one.** `MAX_PTO = 3` yields **two** probe sends: the +client increments then raises at the threshold before sending (`device.py:2721-2726`). 35 → 34 +(18 on `esp32-N4`). Conservative direction; moot under `C1`. + +**L5 — CI step runs 11×.** `.github/workflows/main.yaml` is one `build` job with an 11-entry +`matrix.environment` (`:10-23`). Use a separate top-level `host-tests` job. Confirmed safe: +`tools/test_nonce_window.cpp` is invisible to every firmware build — +`build_src_filter = +<*> -` (`platformio.ini:34`) is relative to the default +`src_dir` and no env adds `tools/`. + +**L6 — Line-reference drift.** The ring drop is `esp32_ble_callbacks.h:127-128` (`:126` is the +RELEASE store on the *success* path), not `:126-127`; the NACK is `communication.cpp:698-703`, not +`:700-701`. Everything else spot-checked is exact. + +**L7 — `NONCE_BAD_SESSION` silently stops being tamper evidence.** Today a `session_id` mismatch +counts (`:122-128` → `:691-696`); the plan routes it to "untouched". Probably right, but it is a +decision, not a consequence of D1. Also `:123-127` logs both full session IDs at `od_log_error` — +with counting removed an attacker can drive that line indefinitely. State the change; +demote/rate-limit the log as Step 4 already does for the out-of-window log. + +--- + +## Verified CORRECT — do not churn on these + +1. **D1 is real and is the wedge.** `:691-696` increments `integrity_failures` on *any* + `verifyNonceReplay` failure including plain packet loss, clearing at 3, after which + `communication.cpp:665-670` answers everything `RESP_AUTH_REQUIRED`. +2. **D2 is real.** `last_seen_counter` (`:149-151`) and the ring write (`:153`) both land before + `aes_ccm_decrypt` at `:714`. Step 3's "called from exactly one place" is achievable — + `decryptCommand` is the sole caller. +3. **D3 is real and exploitable** (`:136`). See `H3` — worse than the plan says. +4. **The bitmap state machine is correct as specified.** Worked by hand: fresh session + (`last_seen=0`, bitmap 0) accepts counter 0 exactly once via `fwd==0` + clear bit 0, needing no + `has_seen_counter`; forward commit by `d` moves old bit *i* (counter `L−i`) to bit `i+d`, which + under `L'=L+d` denotes `L−i` ✓ for d = 1, 63, 64, 65, 127; `d ≥ 128` clears wholesale and every + discarded counter is then ≥128 behind so it is rejected on width, never mis-reported unseen; + backward bit indices 1…127 valid with `back ≥ 128` rejected, so the backward window is exactly + `OD_NONCE_BACKWARD_BITS − 1 = 127` as stated; a forward cap < 128 means a legal slide never + needs a wholesale clear, so the two sides genuinely decouple, and the plan's claim that a cap + *wider* than the backward window would still be harmless is also true. Only the arithmetic form + (`M1`) and the cap value (`C1`) are wrong. +5. **"The ring is not buggy" — correct conclusion, off-by-one proof.** Attempts to construct a + sequence evicting an accepted counter `d` from the 64-slot ring while `d` is still inside ±32 + all fail. The tight count is `2W − 1 = 63`, not `2W`: all counters acceptable while `d` stays + in-window lie in `[L₀−32, d+32]`, and at least one member is provably already seen (the + `last_seen_counter` value preceding `d`, or — in the fresh-session corner — counter 0, blocked + by the zeroed ring's sentinel). 63 writes leave the index one short of `d`'s slot. So `D=64` is + sound **with exactly one slot of margin**, and the parent plan's 256/128 likewise. `D ≥ 2W` is + conservative and its conclusion holds; the real argument for the bitmap — non-obvious + combinatorics maintained by hand across two files — is strengthened by how tight the margin + actually is. +6. **Decision C is clean.** `verifyNonceReplay` has exactly three occurrences outside its + definition: `encryption.h:17`, `main.h:276`, and the sole call at `encryption.cpp:691`. Nothing + in `src/`, `tools/`, or the nRF build path references it. (`Firmware_NRF54` has its own private + copy at `opendisplay_pipe.c:427-455` — separate repo, unaffected, but it carries all four + defects and should be scheduled.) +7. **Decision D's factoring is sound and build-safe** — see `L5`. +8. **Sizing arithmetic.** 512 B → 16 B = **−496 B** ✓. 256-entry ring = 2,048 B → **+1,536 B** ✓. + No `platformio.ini` change; the `esp32-N4` link-headroom question is genuinely moot. +9. **Client premises.** One counter burned per transmission including repairs + (`device.py:747-760`) ✓; window blocks for an ACK and PTO resends exactly one chunk + (`:2688-2694`, `:2715-2726`) ✓; `MAX_PTO = 3` at `commands.py:93` ✓; `PIPE_MAX_W` 32 / + 16-on-`esp32-N4` (`structs.h:45-53`) ✓. Missing from the premise set: the SACK repair site + (`C1`). +10. **The `communication.cpp:772-775` invariant survives.** Commit still happens for every frame + that decrypts, including duplicates `handlePipeWriteData` discards. Only the name and ordering + clause need rewriting, as Step 6 says. +11. **Scope boundaries are right.** Leaving the 30 s auth-challenge window alone is correct (wire + contract at `include/opendisplay_protocol.h:384`, cannot wedge anything). Deferring + `session_timeout_seconds` and the link-drop guard to Phase 5 is correct, and the plan is + admirably explicit that Phase 1 closes only the nonce arm. +12. **No new failure mode on either target.** nRF has no command ring — `imageDataWritten` runs + inline on the Bluefruit callback task ([ble_init.cpp:157](../src/ble_init.cpp)), + single-threaded and in order — so its only gap source is air loss, which the link layer + repairs; a wider forward cap is inert there. On ESP32 nothing in Phase 1 touches the ring, the + loop watchdogs, or advertising. The LAN TLS path bypasses `decryptCommand` entirely + ([communication.cpp:663](../src/communication.cpp), origin-gated on `ORIGIN_LAN_TLS`) and is + untouched by every Phase 1 change; the plain-LAN path shares the one `encryptionSession` + exactly as today. `handleAuthenticate`'s re-auth path (`:583-585` → `clearEncryptionSession()` + → `:654-662`) resets both counters and the ring on both sides, unchanged in shape. + +--- + +## Summary of findings + +| # | Sev | Class | Finding | Correction | +|---|-----|-------|---------|-----------| +| C1 | Critical | plan | Forward cap 64 derived from a non-bound; SACK repair (`device.py:2792`) + preserved ACK backlog (`:781`) reach ~66 at `ack_every=2`, ~96 at `ack_every=1`, both user-selectable (`config_flow.py:104-113`) | Cap **128**; re-derive from `max_retx`; rewrite the Step 6 comment as a mechanism | +| H1 | High | plan | Decision E's "client recovers on its own" is false — 3-byte `0xFF` NACK raises `IntegrityCheckError` (`device.py:834`) uncaught by the pipe loop | Send no NACK (or a pipe ACK) for nonce rejections on `0x0081`; firmware-only | +| H2 | High | shipping | Device/client counters share one nonce space → CCM keystream reuse (`encryption.cpp:158-174` vs `crypto.py:92-113`) | Out of scope, but **record it** next to Decision E | +| H3 | High | shipping | D3 understated: exempted re-accept writes the ring (`:152-154`), so 64 replays flush it and unlock the last 32 counters | Restate D3; add the ring-flush hardware test | +| M1 | Medium | plan | Step 2 vs Decision D disagree; both, plus `-diff`, are UB on attacker-controlled counters | Two wrapping `uint64_t` deltas; no signed arithmetic | +| M2 | Medium | plan | "Tight is correct" rests on a jam-forward DoS that cannot occur post-D2 | Delete "Why not more"; it is the only argument against C1 | +| M3 | Medium | plan | `resetNonceState()` folds blocks sharing only four fields; drops `nonce_counter = 0` | Define the helper by naming all four fields | +| L1 | Low | plan | D4 is latent, not live | Demote the claim; keep the fix | +| L2 | Low | plan | `nonceCommit()` vs the `payload_length` early return at `:719-722` | "First statement of the success arm, `:718`" | +| L3 | Low | plan | Storage table rows 2–3 are 2× its own formula | 2,032 B and 4,080 B | +| L4 | Low | plan | `MAX_PTO=3` yields 2 probe sends | 34 / 18 (moot under C1) | +| L5 | Low | plan | CI step inside an 11-leg matrix | Separate `host-tests` job | +| L6 | Low | plan | Two line refs drift | Fix in place | +| L7 | Low | plan | `NONCE_BAD_SESSION` policy change unremarked; log attacker-drivable | State it; demote/rate-limit | + +## Is Phase 1 safe to implement as written? + +**Safe: yes.** No new freeze mode, no new remote DoS, no regression on either target. Every change +is confined to a path whose current behaviour is strictly worse. It is genuinely independent of +Phases 2–6 and genuinely deliverable alone. + +**As written: no.** Mandatory before implementation: `OD_NONCE_FORWARD_CAP = 128` with the +derivation replaced (`C1` + `M2`), and the unsigned delta form (`M1`) so the code and the UBSan +gate agree. One more (`H1`) decides whether Phase 1 saves the *transfer* or only the *device*; it +is five lines and should be in scope. `H2` and `H3` need a paragraph each, not code. With those, +Phase 1 is the right first change and should ship ahead of the rest of the program. diff --git a/docs/FINDINGS_PHASE3_PLAN_REVIEW_2026-07-26.md b/docs/FINDINGS_PHASE3_PLAN_REVIEW_2026-07-26.md new file mode 100644 index 0000000..901cf68 --- /dev/null +++ b/docs/FINDINGS_PHASE3_PLAN_REVIEW_2026-07-26.md @@ -0,0 +1,232 @@ +# Adversarial review — `PLAN_PHASE3_SESSION_GUARD_2026-07-26.md` + +Reviewed 2026-07-26 against `debug/ble-hardening` HEAD `ee43e19`. Every file:line below was read. + +## Verdict + +**Not safe to implement as written.** Research quality is high — citations are almost all accurate, the ESP32 single-task claim survives exhaustive checking, and D7's hazard analysis is largely sound. But six defects are load-bearing enough that following the plan literally produces a non-compiling tree or a teardown that violates the invariant D7 exists to protect. + +--- + +## Critical + +### `[C1]` `main.h` is a definitions header — every "declared in `main.h`" instruction is unbuildable ✅ **FIXED 2026-07-26** — plan §3a added (header-placement table); every call site corrected + +**Plan:** §4.1 "declared in `main.h`"; §7 "`session_guard.cpp` calls them through the `main.h` declarations" and "nRF stubs in `main.h`"; §8.4; §9.2 `nrfDisconnectCleanupPending`; D6c `linkIsUp()`. + +**Source:** `src/main.h` has no include guard and **defines** globals — `main.h:91` `BBEPDISP bbep;`, `:165` `bool directWriteActive = false;`, `:283` `chunked_write_state_t chunkedWriteState = {...};`, `:284` `globalConfig`, `:289` `EncryptionSession encryptionSession`, `:374-391` `responseQueue`/`commandQueue`/`pServer`/callback objects. + +``` +$ grep -rn '#include "main.h"' src/ +src/main.cpp:1:#include "main.h" +``` + +**Why wrong:** `communication.cpp`, `device_control.cpp` and a new `session_guard.cpp` cannot include it — second definition of every global ("multiple definition of `bbep`"), and on nRF it drags in `` plus NimBLE-aliased `BLE*` types the plan itself forbids in shared headers. Concretely: `communication.cpp` does not include `main.h` (it declares `extern chunked_write_state_t chunkedWriteState;` itself at `communication.cpp:83`), so §4.1's `resetChunkedWriteState()` would be declared where neither its own TU nor its caller can see it. §7's `static inline` nRF stubs are invisible to `session_guard.cpp`. §9.2's flag needs to be visible to both `device_control.cpp` (writer) and `main.cpp` (reader). + +**Correction:** `resetChunkedWriteState()` → `communication.h`. `flushCommandQueue`/`flushResponseQueue`/`linkIsUp`/`serviceLinkDrop`/`serviceDeferredPanelOff`/`nrfDisconnectCleanupPending` → `session_guard.h`, with ESP32 and nRF bodies both out-of-line in `main.cpp`. `session_guard.cpp` may include `structs.h`, `display_service.h`, `communication.h`, `encryption.h`, `od_log.h` — never `main.h`. + +### `[C2]` Step 4 cuts the panel rail before step 6 can defer it — the whole `epdStreamInProgress` design is bypassed + +**Plan:** §8.3 step 4 runs `cleanupDirectWriteState(true)` + `cleanupPartialWriteOnDisconnect()`; step 6 then declares *"HARD INVARIANT: … an in-flight controller stream is never cut mid-write (D7 hazard 1)"* and defers behind `!epdRefreshInProgress && !epdStreamInProgress`. + +**Source:** +```c +// display_service.cpp:2028-2031 (cleanupDirectWriteState) + if (pwrmgmState == PWR_ACTIVE) { + if (refreshDisplay) epdSessionForceOff(); + else epdSessionRelease(true); + } +``` +```c +// display_service.cpp, cleanup_partial_write_state() + bool teardown = partialCtx.active && pwrmgmState == PWR_ACTIVE; + memset(&partialCtx, 0, sizeof(partialCtx)); + if (teardown) epdSessionForceOff(); +``` +`epdSessionForceOff()` (`:512-516`) → `epdSessionForceOffLocked()` (`:418-434`) → `bbepSleep` + `delay(50)` + `pwrmgm(false)` → `SPI.end()` + rail LOW (`main.cpp:759-770`). + +**Why wrong:** During any wedged transfer the panel is `PWR_ACTIVE` by construction (`epdSessionAcquire` sets it at `:443`/`:467`). Step 4 therefore *always* cuts the rail mid-stream, several statements before step 6 evaluates `epdStreamInProgress`. Step 6 then finds `PWR_OFF`, `epdSessionForceOffLocked` early-returns at `:419`, and `epdForceOffPending` is never needed. D7's "close it structurally, at the panel" accommodation protects a path that never runs — including the exact nRF double-handler case it was written for. + +**Correction:** Move the deferral *below* `epdSessionForceOff()`: either (a) guard inside it — `if (epdRefreshInProgress || epdStreamInProgress) { epdForceOffPending = true; return; }` — one place, covers `cleanupDirectWriteState`, `cleanup_partial_write_state`, `sendPipeNack`, `epdSessionTick`; or (b) give the two cleanup functions a bookkeeping-only mode and leave all panel power to step 6. Either way §8.3's printed ordering is wrong. + +### `[C3]` "Two choke points cover ALL controller writes" is false + +**Plan:** D7 — set/clear around `pipeConsumePayload()` and "the legacy 0x0071 data handler", *"so one flag covers pipe, partial, compressed, legacy, FastEPD and E1004."* + +**Enumerated uncovered controller writes reachable from a command handler:** + +| Path | Site | Reached from | +|---|---|---| +| `partial_prepare_panel_ram()` → `bbepFill(&bbep, BBEP_WHITE, PLANE_1/PLANE_0)` — two **whole-plane** SPI writes | `display_service.cpp:3271-3272` | `handlePartialWriteStart` (0x0076) `:2252`; `handlePipeWriteStart` partial arm `:2792` | +| `partial_consume_bytes()` on the **inline initial payload** of a 0x0076 START | `:2258` | `handlePartialWriteStart` | +| `directWriteActivatePanel()` → `bbepSetAddrWindow` + `bbepStartWrite`; `e1004_begin_plane()` issues `bbepWriteData` (`:314-337`) | `:2092-2100` | `handleDirectWriteStart` (0x0070), `handlePipeWriteStart` full arm `:2807` | +| `zlib_stream_to_partial_write(nullptr,0,true)` final flush writes residual bytes | `:2339` | `handleDirectWriteEnd` (0x0072/0x0082) | +| `partial_write_stream_bytes()` → `partial_set_addr_window` + `bbepStartWrite` + `bbepWriteData` | `:3215-3223` | reachable from `:2258`, outside both choke points | + +`bbepFill` on a 1200×1600 panel is hundreds of KB of SPI. This is not a corner case — it is the entire 0x0076 family and the `PIPE_FLAG_PARTIAL` bring-up. + +**Correction:** Stop chasing call sites. One opcode-keyed pair in `imageDataWritten`'s switch covering `CMD_DIRECT_WRITE_{START,DATA,END}`, `CMD_PARTIAL_WRITE_START`, `CMD_PIPE_WRITE_{START,DATA,END}`. Provably exhaustive, no early-return exposure — which also answers the plan's unaddressed question about `pipeConsumePayload`'s `return true` at `:2601` and three `return false` sites (`:2607`, `:2612`) that a naive wrapper would leak the flag on. + +### `[C4]` The teardown powers down a WARM panel on every disconnect — D1b is not a "strict superset" + +**Plan:** §1/D1 *"a strict superset of what those sites do today"*; §9.1 *"All are either no-ops or strictly correct on a link that just went away."* + +**Source — the invariant is documented twice:** +```c +// main.cpp:339-342 +// ACTIVE-only-teardown invariant: a WARM (post-successful-refresh) panel +// SURVIVES disconnect and keeps its keep-alive window … +``` +```c +// device_control.cpp:232-236 +// … a reconnect within the window pays only a warm re-acquire. +``` +`epdSessionForceOffLocked` early-returns **only** on `PWR_OFF` (`:419`); on `PWR_WARM` it runs the full teardown. + +**Why wrong:** Both existing teardowns achieve ACTIVE-only semantics precisely *by never calling `epdSessionForceOff()` directly*. `abortToKnownState` step 6 calls it unconditionally. So the healthy path — push image, refresh succeeds (→ `PWR_WARM` with armed deadline, `:504-505`), disconnect — now powers the panel down, and the next push pays the ~900 ms cold rail bring-up (`main.cpp:721`) plus `bbepInitIO`/`bbepWakeUp`/init-sequence instead of a warm re-acquire. Fires on **every** disconnect. + +**Correction:** +```c + if (pwrmgmState == PWR_ACTIVE) { // ACTIVE-only-teardown invariant + if (!epdRefreshInProgress && !epdStreamInProgress) epdSessionForceOff(); + else epdForceOffPending = true; + } +``` +Add a §12 case: connect, push, disconnect, reconnect inside `screen_timeout_seconds` → log must show `acquire: WARM re-acquire`, not `COLD bring-up`. + +### `[C5]` The §8.3 log line does not compile + +`display_service.cpp:556` `static PartialStreamContext partialCtx = {};` and `:575` `static PipeWriteState pipeState = {};` are file-static; `display_service.h` exposes only `transferActive()` (`:70`), `epdRefreshInProgress` (`:77`) and the three cleanup entry points. The step-1 log line reads `pipeState.active` and `partialCtx.active`. + +**Correction:** add `bool pipeWriteActive(void)` / `bool partialWriteActive(void)` next to `transferActive()`. (`directWriteActive` and `chunkedWriteState` are fine via `extern`, as `communication.cpp:83` / `esp32_ble_callbacks.h:43` already do.) + +### `[C6]` `commandDrainAbortPending` latches outside a drain → one command dispatched twice ✅ **FIXED 2026-07-26** — plan §5 now clears the flag at the top of the drain block; regression test added to §12 + +**Plan:** §7 sets the flag unconditionally in `flushCommandQueue()`; §5 consumes it with `flag=false; break;` without the tail store. + +**Source:** D1b calls `abortToKnownState` from `serviceBleDisconnectCleanup()`, which runs at `main.cpp:370` (deep-sleep-wake branch, **before** the drain, then `return`s) and `:428` (**after** the drain). The plan itself says so in §12. + +**Why wrong:** With a non-empty ring at that moment, the flag is set and nothing consumes it this pass. Next pass the drain (1) dispatches `commandQueue[tail]`, (2) sees the flag, clears it, `break`s **without storing the tail**, (3) next pass dispatches the same slot again. For `CMD_CONFIG_WRITE`, `CMD_POWER_OFF`, `CMD_DEEP_SLEEP`, `CMD_REBOOT` that is not benign. A *new* bug shipped in the same commit as the `[M5]` fix. + +**Correction:** `commandDrainAbortPending = false;` at the top of the drain block before the `while`, or scope the flag to an active drain via a `commandDrainActive` flag. Secondary: when called from inside the drain, `dropped` over-counts by one (tail not yet advanced) — cosmetic, worth a comment. + +--- + +## High + +**`[H1]` The deferred scrub can zero a *fresh* session's nonce.** `handleAuthenticate` is dispatched **before** the auth gate (`communication.cpp:649-652`; gate at `:663-669`), and step 1 writes exactly what the scrub erases (`encryption.cpp:586-587`: `secure_random(pending_server_nonce,16)`). On nRF: handler A clears at depth≥1 → `sessionScrubPending`; A returns; a new `CMD_AUTHENTICATE` step 1 arrives on the callback task; `loop()` samples depth 0 and memsets `pending_server_nonce` → step 2's CMAC fails → `AUTH_STATUS_ERROR` on reconnect. D7's *"`isAuthenticated()` is already false, so no new command can use the key"* does not close this, because authenticate bypasses that gate by design. **Fix:** generation counter, or `if (sessionScrubPending && g_commandInFlight==0 && !authenticated && server_nonce_time==0)`. Also state the answer D7 only asks: `ccm_session_free` is never deferred (ESP32-only, `encryption.cpp:202`, and `sessionScrubIsSafeNow()` returns `true` there). + +**`[H2]` `touchForceResumeAll()` skips the real resume work.** `touchResumeAfterEpdRefresh()` (`touch_input.cpp:417-432+`) is not just a decrement — at zero it runs `invalidateOpenDisplayWire()`, `delay(GT911_POST_RESET_SETTLE_MS)`, and per-controller re-init + INT re-attach. Zeroing the counter re-enables polling (`:584`) against a controller never re-initialised after `pwrmgm(false)`'s `Wire.end()` (`main.cpp:764-766`). §12's criterion *"touch responds again (`s_epd_refresh_suspend == 0`)"* checks the very proxy that will read zero while touch is dead. **Fix:** `if (s_epd_refresh_suspend) { s_epd_refresh_suspend = 1; touchResumeAfterEpdRefresh(); }` — note this makes the helper blocking, which must be stated; and test a real touch event. + +**`[H3]` nRF's `loop()` cannot service the new deferrals promptly.** The nRF arm (`main.cpp:518-530`) opens with `idleDelay(globalConfig.power_option.sleep_timeout_ms)`, which blocks for the full duration in 100 ms chunks servicing only buttons/touch/LED/`epdSessionTick`/buzzer (`:535-551`). `serviceBleDisconnectCleanup()` is inside `#ifdef TARGET_ESP32` (`:191`–`:349`) and called only at `:370`/`:428` — there is no nRF analogue to sit "next to". Teardown latency regresses from immediate to `sleep_timeout_ms`, with the rail up mid-transfer, touch suspended, and key material + `epdForceOffPending` unserviced. **Fix:** put the service calls in `loop()`'s shared prologue (`:352-354`) **and** in `idleDelay()`'s body — the precedent `epdSessionTick()` already sets at `:545`. + +**`[H4]` The `g_commandInFlight == 0` caller check is a sampled TOCTOU.** §9.2 claims it *"guarantees no `imageDataWritten` is on the stack"*. On nRF the counter is written by the Bluefruit Callback task and read by `loop()` with no mutual exclusion; a write callback firing one instruction later re-enters. D7 says the right thing three sections later (*"defence in depth rather than the only defence"*) — §9.2 contradicts it, and that sentence is what justifies deleting Phase 5's `nrfSessionClearPending`. **Fix:** reword; let Phase 5 re-decide. + +**`[H5]` Phase 2 assigns `panelStateUnknown` to Phase 3; Phase 3 never wires it.** Phase 2 states: *"Consumed: → Phase 3. `abortToKnownState()` reports it and skips `epdSessionForceOff()` when set (retrying a lock that just timed out costs another 60 s inside the abort path)."* Phase 3's §8.3 log line omits it and step 6 has no test. After Phase 2, `epdSessionForceOff()` (`:513`) takes a lock with a 60 s deadline — so the recovery path can block `loop()` for 60 s on every disconnect. Also: Phase 2's **D-A and D-B are listed as blocking and unresolved**, and D-B's alternative is literally *"defer the whole flag to Phase 3"*. **Fix:** wire it into the log + step 6; settle Phase 2 D-A/D-B first. + +**`[H6]` A deferred abort can fire after a reconnect.** nRF re-advertises autonomously (`ble_nrf_advertising_tick`, `main.cpp:526` and inside `idleDelay` at `:540`); on ESP32 the deep-sleep-wake branch does cleanup at `:370` then restart at `:371-373` then `return`s. §9.1 waves `esp32-N4` through on the grounds that no LAN path raises the flag spuriously — correct, but it does not address a *genuine* disconnect serviced after a reconnect, and `esp32-N4` has no `ownerStillUp` guard at all (`main.cpp:328-338` is inside `#ifdef OPENDISPLAY_HAS_WIFI`). D1b widens the blast radius to session clear, chunked state, buzzer, LED and panel. **Fix:** connection generation, or an `!linkIsUp()` test at the service point (reuses D6c's new predicate). + +**`[H7]` Stopping buzzer/LED on every disconnect is a client-observable change.** "Beep/flash and disconnect" is the natural pattern for 0x0077/0x0073 — neither has a completion notification, and the parent plan records LED flash as deliberately unbounded and accepted. §12's own criterion says *"any behavioural difference visible to the client is a constraint violation, not a Phase 3 feature."* §2 lists this change in neither the in-bounds nor out-of-bounds table. **Fix:** restrict to `dropLink=true`, or get an explicit decision recorded in §2. + +--- + +## Medium + +**`[M1]`** "`cleanupDirectWriteState` no-ops when `!directWriteActive`" (§8.3 Idempotence) is false — `display_service.cpp:2010-2039` has no such guard; it zeroes eleven globals, force-offs on `PWR_ACTIVE`, calls `e1004_end_plane()`, resumes touch. That is why the ESP32 site guards the *call* (`main.cpp:343`) and nRF does not (`device_control.cpp:237`). The claim is what `[C2]` and `[C4]` both depend on being false. Re-derive step 4's ordering rationale: for a partial transfer the *first* call is what powers the panel down, and `cleanup_partial_write_state`'s own predicate then evaluates false — same end state, opposite reason to the one given. + +**`[M2]`** §8.3's "1. LOG FIRST" contradicts the D7 guard snippet showing the atomic test-and-set first. Guard must be first. Separately, `__atomic_store_n(&inAbort,0,RELEASE)` appears once at the end; `[H5]` adds an early return and Phase 2 makes panel calls failable — one skipped release latches `inAbort=1` and **permanently disables recovery**. Mandate single-exit `goto done` or RAII. + +**`[M3]`** §8.1's header surface omits 11 symbols used elsewhere in the plan: `odLinkDropRequest`, `g_linkDropPending`, `linkReleaseIfHeld`, `sessionScrubPending`, `sessionScrubIsSafeNow`/`sessionScrubNow`, `nrfDisconnectCleanupPending`, `serviceDeferredPanelOff`, `serviceLinkDrop`, `linkIsUp`, `epdStreamInProgress`, `panelStateUnknown`. + +**`[M4]`** Phase 1 deletes `replay_window[64]` for a bitmap (net −480 B) and rewrites `clearEncryptionSession()`; Phase 3's D7 split reproduces the pre-Phase-1 field list verbatim → guaranteed conflict in the phase's most safety-critical function. §10's *"gate for Phase 1's `replay_window[256]`"* is stale. Rebase the split; state which half the bitmap reset lands in. + +**`[M5]`** Nothing resets `sessionOrigin` (`display_service.cpp:2114`, set only at `:2129`/`:2170`/`:2638`). After aborting a LAN transfer it stays LAN, so the ESP32 guard's own input (`main.cpp:329`: `transferSessionOrigin() != 0`) is stale — a later BLE-only disconnect is classified LAN-owned and, with a LAN client up, the **BLE teardown is silently skipped**. Squarely in the "known state" remit. + +--- + +## Low + +- `[L1]` `pending` removal saves 2 B/slot (alignment: 260→258), so −86 B not −43 B; direction holds. §10 omits `nrfDisconnectCleanupPending` and counts `epdStreamInProgress` against `session_guard` when D7 puts it in `display_service.cpp`. +- `[L2]` `flushResponseQueue()` (discards) vs existing `flushResponseQueueToBle()` (`main.cpp:275`, *sends*) — one keystroke apart, opposite effects, same file. Rename. +- `[L3]` §12's `grep -n 'pending' src/` always matches `bleDisconnectCleanupPending`, `msdUpdatePending`, `bleRestartAdvertisingPending`, `buttonEventPending` and every new Phase 3 flag. Use `grep -rn '\.pending' src/`. +- `[L4]` §12's `grep -nE '^\+.*(RESP_|CMD_)[A-Z_]+ *='` looks for assignments; opcodes are `#define`s in the vendored header. The two `git diff --stat` header checks are the real guard and are correct. +- `[L5]` §9.1 cites `main.cpp:447` for the WiFi-lost tick calling `disconnectWiFiServer()` — `:447` is `handleWiFiServer()`; the call is at `:456` (and `wifi_service.cpp:873`). +- `[L6]` `od_log_*` carries `format(printf,2,3)` (`od_log.h:30`); codebase convention is explicit `(unsigned)` casts (`main.cpp:439`, `:497`). §7's `%u` with `uint8_t` should match. +- `[L7]` §8.3.1's "next to `serviceBleDisconnectCleanup()`" is an orphan — that function is ESP32-only. See `[H3]`. +- `[L8]` §4.1's note is right (`communication.cpp:574-576` clears 3 of 5 fields) but understated: `:550` and `:558` clear only `active`, so the consolidation *changes their behaviour*. That is a behaviour change, not a pure refactor. + +--- + +## Gaps missed entirely + +- `[X1]` `abortToKnownState()` returns `void` and cannot report failure. After Phase 2 every panel op is failable and the lock can time out; §12's *"panel rail down"* / *"all state flags false"* criteria assume success. Give it a `bool` or status flag for Phase 6. +- `[X2]` No zlib streamer reset. `od_zlib_stream_reset` runs only at START (`:2102`, `:2253`, `:2793`); after an abort the inflater keeps its state. Benign today (START always resets) but a "known state" gap. +- `[X3]` Partial transfers never suspend touch — `directWriteTouchSuspended` is set only on the full-frame paths (`:2136-2137`, `:2800-2801`), and the partial bring-up at `:2785-2794` deliberately skips it. `[M3]`'s analysis reasons only about full-frame; say this explicitly, because `[H2]`'s fix makes the helper do real I2C work. +- `[X4]` `epdPlanesPrepared`/`epdSessionInitWasPartial` (`:365`, `:369`) are not reset; `epdSessionForceOffLocked` clears the former at `:433`, so the deferred-force-off path leaves it stale until `serviceDeferredPanelOff()` completes. +- `[X5]` §2's flush justification via SACK retransmit (`pipe-write-protocol.md` §5.2 — verified, `:373-380`) applies only to `0x0081` DATA. A flushed `0x0082` END, `0x0041`, or `0x0050` has no retransmit path. Same shape as today's ring-full drop, so the constraint argument survives — but "no new behaviour" needs the qualifier now that the flush is deliberate. + +--- + +## Verified correct — do not re-check + +1. **ESP32 has no cross-task command-handler execution.** Exhaustive: `imageDataWritten` is reached from exactly three places — `main.cpp:415` (loop drain), `wifi_service.cpp:978` (LAN dispatch inside `handleWiFiServer()`, called from `loop()` at `main.cpp:447`), and `ble_init.cpp:157` (**nRF/Bluefruit only**). `onWrite` (`esp32_ble_callbacks.h:81-135`) only memcpys and stores the head; `onConnect`/`onDisconnect` (`:46-70`) are flag-only. No `xTaskCreate`/`esp_timer_create`/`xTimerCreate` in `src/` on ESP32 (`ble_init.cpp:104`'s `TimerHandle_t` is nRF). Both WiFi event handlers (`wifi_service.cpp:574`, `:643`) only set flags — the file carries an explicit "EVENT-CONTEXT RULE" comment at `:625` and everything is drained by `serviceWifiEventFollowUp()`. Button/touch ISRs (`device_control.cpp:679`, `:697`; `touch_input.cpp:175`) set bitmasks only. **D4's "document, no assert" and D7's "ESP32 callers need no depth check" are both correct.** +2. **The `[M5]` drain race is real and the fix placement is right.** `main.cpp:409-421`: `tail` cached at `:409`, dispatch at `:415`, `__atomic_store_n(...tail+1..., RELEASE)` at `:417` would overwrite a `tail := head` snapshot. Between `:415` and `:416`, breaking without the store, is correct. (`[C6]` is about the flag's lifetime, not the placement.) +3. **`pending` has no readers** — assignments only, at exactly the five cited sites: `esp32_ble_callbacks.h:125`, `communication.cpp:119`, `main.cpp:291`, `:309`, `:416`. +4. **`COMMAND_QUEUE_SIZE 33` is defined twice** — `main.h:371` and `esp32_ble_callbacks.h:19` (`#ifndef`); `main.h` defines at `:371` and includes the callbacks header at `:378`, so it wins. D5's Phase 7 warning is valid. +5. **The `main.h:365-370` capacity comment is wrong as `[H1]` says** — producer refuses at `nextHead == tail` (`esp32_ble_callbacks.h:121-122`), usable capacity 32. D5's replacement text is accurate. +6. **`EncryptionSession` has no origin field** (`encryption_state.h:11-30`). `g_commandOrigin` is per-dispatch (`communication.cpp:37`, set/restored at `wifi_service.cpp:976-979`); `sessionOrigin` tracks the transfer (`display_service.cpp:2114`). D6c's "genuinely unanswerable in Phase 3" is correct, and erring toward *not* clearing is the right direction. +7. **`wifiLanClientConnected()` behaves as claimed** — `wifi_service.cpp:355`. +8. **`isAuthenticated()` precedes `decryptCommand` on every decrypting path** — `communication.cpp:664` gates before `:698`. LAN-TLS bypasses the CCM envelope by design and never touches `session_key`, so clearing the session does not stop LAN-TLS execution — correct and pre-existing, worth one sentence in D6c. +9. **The invalidate/scrub field split is accurate** — `encryption.cpp:201-219`: racy half is `ccm_session_free` (`:202`) + five memsets (`:204-207`, `:217`); the eight scalar stores (`:208-216`) are coherent for a concurrent reader. The *premise* holds; `[H1]` is a defect in the *consequence*. +10. **`esp32-N4` is ESP32 without WiFi and without any LAN flag-setter** — `platformio.ini:284` defines only `TARGET_ESP32` + `PIPE_SMALL_DRAM_WINDOW`; `wifi_service.cpp:3` wraps the whole file in `#ifdef OPENDISPLAY_HAS_WIFI`. +11. **No self-deadlock on `pwrmgmLock`, no use-after-free in the teardown.** All four take/give pairs are function-local (`:439`/`:488`, `:496`/`:509`, `:513`/`:515`, `:520`/`:526`); the lock is never held across a return into handler code. `pipeState`, `pipeReorder`, `partialCtx`, `chunkedWriteState` are all static storage. D7's strongest ungating argument stands. +12. **All other spot-checked citations are accurate**: `display_service.cpp:2502-2504` (`transferActive`), `:2008`/`:2035-2038`, `communication.cpp:113`, `esp32_ble_callbacks.h:128`, `buzzer_control.cpp:147`, `device_control.cpp:341`, `structs.h:86`, `main.h:371`, `platformio.ini:284`. (`touch_input.cpp` counter is at `:64`; the plan cites `:115-119`, which is `touchSuspendForEpdRefresh` — close enough to be useful.) +13. **Wire-protocol constraint: Phase 3 as scoped does not violate it.** No canonical-header edits; D3 removes the only genuine candidate; both ring structs are firmware-local. `[H7]` is the one *client-observable* change and it is a behaviour question, not a header question. +14. **`pipe-write-protocol.md` §5.2 exists and says what §2 cites it for** (`:373-380`). + +--- + +## Phase interaction risks + +| Phase | Risk | Action | +|---|---|---| +| **1** | Phase 1 deletes `replay_window[64]` → bitmap and rewrites `clearEncryptionSession()`; Phase 3's D7 split targets the pre-Phase-1 field list. §10's "+1.5 KB `replay_window[256]`" is stale (Phase 1 is now −480 B). | `[M4]` — rebase before implementing. | +| **1** | D6c's gap argument (stale session bounded by out-of-window rejection + Phase 1's `counter_diff == 0` fix) is sound — verified at `encryption.cpp:136`. | Order Phase 1 → Phase 3 confirmed. | +| **2** | `pwrmgmLockTake` → bool + 60 s deadline makes `epdSessionForceOff()` failable **and blocking**; Phase 3 calls it from the loop task on every disconnect and ignores both. Phase 2 explicitly assigns `panelStateUnknown` to Phase 3; Phase 3 never wires it. | `[H5]`, `[X1]`. | +| **2** | Phase 2's **D-A** and **D-B** are blocking and unresolved; D-B's alternative is "defer the flag to Phase 3". | Settle both before Phase 3 starts; record in §1. | +| **2** | `[X2]` (boot-refresh `epdRefreshInProgress`) and `[X3]` (real `fastepd_wait_refresh`) are prerequisites for §8.3.1's "no timeout on this deferral". Boot suspends/resumes touch symmetrically (`:539`↔`:1640`, retry arm `:1627`) but sets no `epdRefreshInProgress`. | Keep the stated ordering. | +| **4** | §9.1's "keep `ownerStillUp` in front" is correct and verified (`main.cpp:328-338`, `wifi_service.cpp:812`, `main.cpp:456`) — but `[M5]`'s stale `sessionOrigin` corrupts the guard's own input, a bug Phase 4 inherits. | Reset `sessionOrigin` now. | +| **4** | D6c correctly defers owner-scoped `linkIsUp()` to Phase 4; `[H6]`'s reconnect race is also naturally fixed by the token, but not until then. | Add the interim `!linkIsUp()` service-point check. | +| **5** | §9.3 declares `nrfSessionClearPending` redundant on the strength of `[H4]`'s false guarantee. | Downgrade the claim; let Phase 5 re-decide. | +| **5** | D7 rightly puts Phase 5's in-function guard in the invalidate half; `[H1]` adds a generation-check requirement Phase 5's guard does not provide. | Land the generation check in Phase 3. | +| **5** | Phase 3 delivers the BLE-disconnect session clear one phase early with no backstop; §9.3 states the mitigation honestly and it is sound for Phase 3's own two callers. | Keep the "hand-check any new caller" warning. | +| **6** | D2's dead `g_lastProgressMs` is intentional and correctly documented; the six stamp sites in §8.2 match `[C1]`. | No action. | +| **6** | D7's "never gate" header comment is right; Phase 6's nRF supervisor must carry the (weakened, `[H4]`) depth term itself. | Already in D7 condition 2. | +| **7** | `[C6]`'s fix must not collide with Phase 7's `commandQueueOverflowAbort` policy; D5's two-files warning is accurate. | No action beyond `[C6]`. | + +--- + +## Required corrections + +| # | Sev | Correction | Section | +|---|---|---|---| +| ~~C1~~ | ~~Crit~~ | ✅ **DONE** — plan §3a header-placement table; all sites corrected. | §3a, §4.1, §7, §8.4, §9.2, §11, D6c | +| C2 | Crit | Move the refresh/stream deferral inside `epdSessionForceOff()`, or stop step 4 touching panel power. | §8.3 steps 4+6, D7 | +| C3 | Crit | Replace "two choke points" with one opcode-keyed pair in `imageDataWritten`. | D7, §11 step 9a | +| C4 | Crit | Gate step 6 on `pwrmgmState == PWR_ACTIVE`; add a WARM-survives-disconnect test. | §8.3 step 6, §9.1, D1 | +| C5 | Crit | Add `pipeWriteActive()`/`partialWriteActive()` accessors. | §8.3 step 1 | +| ~~C6~~ | ~~Crit~~ | ✅ **DONE** — cleared at top of drain block; alternative recorded; §12 test added. | §5, §7, §12 | +| H1 | High | Generation/handshake check on the deferred scrub; state that `ccm_session_free` is never deferred. | D7 hazard 2 | +| H2 | High | Route `touchForceResumeAll()` through `touchResumeAfterEpdRefresh()`; test a real touch event. | §4.2, §12 | +| H3 | High | Service new flags from `loop()`'s shared prologue **and** `idleDelay()`. | §8.3.1, §9.2 | +| H4 | High | Reword §9.2; re-check the `nrfSessionClearPending` deletion. | §9.2, §9.3 | +| H5 | High | Wire `panelStateUnknown`; settle Phase 2 D-A/D-B; give the abort a failure signal. | §8.3, §1 | +| H6 | High | Gate the deferred teardown on `!linkIsUp()` / a connection generation. | §9.1, §9.2 | +| H7 | High | Restrict buzzer/LED stop to `dropLink=true`, or record an explicit decision in §2. | §9.1, §12 | +| M1 | Med | Correct the `cleanupDirectWriteState` idempotence claim; re-derive step 4's rationale. | §8.3 | +| M2 | Med | Guard first, log second; single-exit so `inAbort` cannot latch. | §8.3, D7 | +| M3 | Med | Complete §8.1's header surface (11 symbols). | §8.1 | +| M4 | Med | Rebase the scrub split onto Phase 1's bitmap; fix the stale `replay_window[256]` ref. | D7, §10 | +| M5 | Med | Reset `sessionOrigin` in the teardown. | §8.3 step 4 | +| L1–L8 | Low | RAM arithmetic; rename `flushResponseQueue`; fix both §12 greps; `:447`→`:456`; `(unsigned)` casts; orphan cross-ref; note the `:550`/`:558` behaviour change. | §10, §7, §12, §9.1, §4.1 | +| X1–X5 | Gap | Abort failure signalling; zlib reset; partial-path touch note; `epdPlanesPrepared` window; qualify the flush's client-neutrality. | new | diff --git a/docs/IT8951_BBEPAPER_INTEGRATION_PLAN.md b/docs/IT8951_BBEPAPER_INTEGRATION_PLAN.md new file mode 100644 index 0000000..11a5134 --- /dev/null +++ b/docs/IT8951_BBEPAPER_INTEGRATION_PLAN.md @@ -0,0 +1,471 @@ +# IT8951 / ED103TC2 → bb_epaper Integration Plan + +Decision-support document. **Report only** — no code was modified to produce it. + +Scope: fold the Seeed_GFX-based IT8951 TCON path (10.3" **ED103TC2 1872×1404**, panel_ic +`OD_PANEL_IC_ED103TC2_1872X1404` = 3000 / `..._4GRAY` = 3001) into the primary **bb_epaper** +driver, so the vendored Seeed_GFX / TFT_eSPI fork can be dropped from the ESP32-S3 build. + +All line numbers are as of this investigation: +- bb_epaper checkout: `/.pio/libdeps/esp32-s3-N16R8/bb_epaper/` (identical across esp32-s3 envs) +- Seeed_GFX: `/home/davelee/opendisplay/Firmware/lib/Seeed_GFX/` +- Firmware glue: `/home/davelee/opendisplay/Firmware/src/` + +--- + +## A. Executive summary + +**Feasible, and a natural fit — recommended.** OpenDisplay uses Seeed_GFX as nothing more than +(a) an IT8951 SPI transport and (b) a raw framebuffer it `memcpy`s pre-dithered pixels into +(`src/display_seeed_gfx.cpp`). It touches **zero** TFT_eSPI drawing/text/sprite/font/touch code. +The IT8951 command surface OpenDisplay actually exercises is tiny: full-frame packed-pixel load + +one `DPY_AREA` GC16 refresh + init(VCOM)/sleep/wake — a subset of the ~30 `tcon*` functions in +`Tcon.cpp`. bb_epaper already carries bitbank2's own dormant IT8951 scaffolding (chip enum, +register `#define`s, SPI primitives, an init table, a commented panel row, an `#ifdef FUTURE` +dispatch stub — all from commit `631e3c0`), so completing it is a clean upstreamable PR rather than +a fork. **Rough effort:** ~250–350 lines of new/ported C in bb_epaper across ~6 functions + 2 +panel-table rows, plus deleting/collapsing the ~15 `#ifdef OPENDISPLAY_SEEED_GFX` sites in +`display_service.cpp` and retiring `display_seeed_gfx.{cpp,h}` down to a thin bb_epaper shim. +**Biggest risks:** (1) the existing bb_epaper IT8951 SPI stubs do **not** poll the **HRDY** +handshake and have **no read path** at all — both mandatory for real silicon; (2) per-unit **VCOM** +calibration (Seeed hardcodes `1400`/−1.40 V, bb_epaper's M5Paper stub uses `2300`/−2.30 V); (3) +packed-pixel **nibble/word order + X-mirroring** must be reproduced byte-for-byte. All three are +containable but demand a **physical ED103TC2 panel** to validate — there is no way to prove +correctness from source alone. + +--- + +## B. Seeed_GFX interface surface — full catalog (e-paper-relevant) + +### B.1 `EPaper` class — `lib/Seeed_GFX/Extensions/EPaper.{h,cpp}` +`EPaper : public TFT_eSprite` (owns a 1-bpp/4-bpp sprite framebuffer `_img8`). + +| Method | File:line | Purpose | +|---|---|---| +| `EPaper()` ctor | EPaper.cpp:1 | `setColorDepth(1)`, `createSprite(w,h,1)` — allocates `_img8` | +| `begin(uint8_t wake=0)` | EPaper.cpp:8 | `init()` (RST toggle + `hostTconInit`) then `EPD_WAKEUP()`; `wake!=0` → `initFromSleep()` | +| `update()` | EPaper.cpp:41 | Full-frame push+refresh: `wake→SET_WINDOW→PUSH_NEW_COLORS→UPDATE→sleep` (1bpp or gray branch) | +| `update(x,y,w,h,data)` | EPaper.cpp:159 | Sub-region push via `pushImage` — **unused by OpenDisplay** | +| `updataPartial(x,y,w,h)` | EPaper.cpp:71 | Aligned partial window (16-px), `tconDisplayArea1bpp` — **unused** | +| `initGrayMode(uint8_t)` | EPaper.cpp:186 | Switch `_img8` to 4-bpp (grayLevel 16); recreates sprite | +| `deinitGrayMode()` | EPaper.cpp:208 | Switch `_img8` back to 1-bpp | +| `sleep()` | EPaper.cpp:224 | `EPD_SLEEP()` → `tconSleep()` (guarded by `_sleep`) | +| `wake()` | EPaper.cpp:232 | `EPD_SET_TEMP` + `EPD_WAKEUP`/`_GRAY` → `tconWake()` (guarded) | +| `drawBufferPixel / setTemp / getTemp / setHumi / getHumi` | EPaper.cpp:36,250-266 | **unused by OpenDisplay** | +| `getPointer()` (inherited `TFT_eSprite`) | Sprite.cpp:118 | Returns `_img8` raw framebuffer pointer | + +### B.2 `EPD_*` macros — the EPaper→Tcon glue — `TFT_Drivers/ED103TC2_Defines.h` +These are the actual bridge that turns `EPaper` calls into `tcon*` commands: + +| Macro | File:line | Expands to | +|---|---|---| +| `EPD_SET_WINDOW(x1,y1,x2,y2)` | :134 | `setTconWindowsData(x1,y1,x2,y2)` | +| `EPD_PUSH_NEW_COLORS(w,h,c)` | :145 | `tconLoad1bppImage(c,…,w,h,false)` | +| `EPD_PUSH_NEW_GRAY_COLORS(w,h,c)` | :156 | `tconLoadImage(c,…,w,h,false)` (4bpp) | +| `EPD_PUSH_OLD_COLORS(...)` | :173 | **no-op** | +| `EPD_UPDATE()` | :70 | `tconDisplayArea1bpp(…,0x02,0x00,0xff)` — GC16, BG=0 FG=255 | +| `EPD_UPDATE_GRAY()` | :77 | `tconDisplayArea(…,0x02)` — GC16 | +| `EPD_UPDATE_PARTIAL()` | :64 | `tconDisplayArea1bpp(…,0x01,…)` — DU — **unused** | +| `EPD_WAKEUP()` | :118 | `tconWake()` + `setTconTemp` | +| `EPD_SLEEP()` | :84 | `tconSleep()` | +| `EPD_SET_TEMP(t)` | :178 | `setTconTemp(t)` | +| `EPD_INIT()` / `OD_EPD_RST_TOGGLE()` | :92-116 | RST pulse (runtime pins) | +| init body `ED103TC2_Init.h` | :46-47 | RST pulse + `hostTconInit()` | +| wake body `ED103TC2_Init_Wake.h` | :33 | `hostTconInitFast()` | + +### B.3 `Tcon` methods (added to `TFT_eSPI`) — `Extensions/Tcon.{h,cpp}` +| Method | Tcon.cpp:line | IT8951 command(s) issued | +|---|---|---| +| `tconWaitForReady()` | :22 | Poll HRDY (busy pin) until HIGH, w/ timeout | +| `tconSendWord / tconReceiveWord` | :48,53 | `spi.transfer16` (word granular) | +| `tconWriteCmdCode(cmd)` | :60 | preamble `0x6000` + cmd word (HRDY-gated) | +| `tconWirteData(d)` | :82 | preamble `0x0000` + data word | +| `tconWirteNData(buf,n)` | :100 | preamble `0x0000` + burst via `pushPixels[DMA]` (16 KB chunks) | +| `tconReadData()` | :144 | preamble `0x1000` + dummy + read word | +| `tconReadNData(buf,n)` | :164 | preamble `0x1000` + dummy + n-word burst read | +| `tconSendCmdArg(cmd,args,n)` | :188 | cmd + n data words | +| `tconReadReg / tconWriteReg` | :201,213 | `REG_RD`(0x10)/`REG_WR`(0x11) + addr(+val) | +| `tconLoadImgStart / …AreaStart / …End` | :223,245,261 | `LD_IMG`(0x20)/`LD_IMG_AREA`(0x21)/`LD_IMG_END`(0x22) | +| `tconSetImgBufBaseAddr(addr)` | :266 | write `LISAR`+2 / `LISAR` | +| `tconSetImgRotation(r)` | :237 | `LD_IMG` w/ rotation — **unused** | +| `tconHostAreaPackedPixelWrite(ld,area)` | :276 | set base addr → `LD_IMG_AREA` → mirror/pack rows → burst → `LD_IMG_END` | +| `tconDisplayArea(x,y,w,h,mode)` | :331 | `DPY_AREA`(0x34) + 5 args | +| `tconDisplayArea1bpp(...,bg,fg)` | :346 | X-mirror; set `UP1SR+2` bit2; set `BGVR`; `DPY_AREA`; wait; restore | +| `tconLoad1bppImage(buf,x,y,w,h,flip)` | :369 | X-mirror; load as 8bpp w/ width/8 → `tconHostAreaPackedPixelWrite` | +| `tconLoadImage(buf,x,y,w,h,flip)` | :393 | 4bpp → `tconHostAreaPackedPixelWrite` | +| `getTconInfo(buf)` | :416 | `GET_DEV_INFO`(0x0302) burst read → `I80TCONDevInfo` | +| `hostTconInit()` | :437 | `setTconVcom(1400)` → `getTconInfo` → enable `I80CPCR` packed mode | +| `hostTconInitFast()` | :453 | `getTconInfo` only (no VCOM, no I80CPCR) | +| `setTconWindowsData(x1,y1,x2,y2)` | :466 | store `_imgAreaInfo` (no bus traffic) | +| `getTconTemp / setTconTemp` | :474,482 | cmd `0x0040` | +| `getTconVcom / setTconVcom` | :491,498 | cmd `0x0039` (arg 0x02 = write) | +| `tconSleep / tconWake / tconStandby` | :506,511,516 | `SLEEP`(0x03)/`SYS_RUN`(0x01)/`STANDBY`(0x02) | +| `tconWaitForDisplayReady()` | :521 | poll `LUTAFSR` reg until 0 | + +Everything else in `lib/Seeed_GFX/` (`Sprite.cpp` 82 KB, `Smooth_font.cpp`, `Button.cpp`, +`Touch.cpp`, all `TFT_Drivers/*` for TFT LCDs, the whole `TFT_eSPI` core) is TFT-LCD/graphics +machinery **entirely unused** by OpenDisplay except that `EPaper` inherits `TFT_eSprite` only to +get a malloc'd framebuffer + `getPointer()`. + +--- + +## C. Portion USED by OpenDisplay Firmware ("must move to bb_epaper") + +Only `src/display_seeed_gfx.cpp` links to Seeed_GFX. Its complete dependency set and the call +chains that reach real IT8951 traffic: + +| `display_seeed_gfx.cpp` | → `EPaper` method | → `EPD_*` macro | → `tcon*` reached | IT8951 op | +|---|---|---|---|---| +| `seeed_gfx_epaper_begin` :104 | `initGrayMode(16)` / `deinitGrayMode` :110-113 | — | (sprite realloc) | switch 1/4-bpp buffer | +| ″ | `begin(0)` :115 | `init` body + `EPD_WAKEUP` | RST + `hostTconInit` + `tconWake`+`setTconTemp` | reset, VCOM, GetDevInfo, I80CPCR, wake | +| `seeed_gfx_direct_write_reset` :145 | `begin(0)` (cold/first) or `wake()` :155-157 | `EPD_WAKEUP`/`EPD_SET_TEMP` | `tconWake`,`setTconTemp` | wake / full init | +| `seeed_gfx_direct_write_chunk` :166 | `getPointer()` :168 + `memcpy` | — | — | fill framebuffer | +| `seeed_gfx_boot_write_row` :133 | `getPointer()` :134 + `memcpy` | — | — | fill one row | +| `seeed_gfx_direct_refresh` :179 | `update()` ×1–2 :180-183 | `SET_WINDOW`→`PUSH_NEW[_GRAY]_COLORS`→`UPDATE[_GRAY]` | `setTconWindowsData`, `tconLoad1bppImage`/`tconLoadImage`, `tconDisplayArea1bpp`/`tconDisplayArea`, `tconWaitForDisplayReady` | window, packed load, GC16 refresh, wait | +| `seeed_gfx_full_update` :119 | `update()` | (same as above) | (same) | (same) | +| `seeed_gfx_direct_sleep` / `_sleep_after_refresh` :186,129 | `sleep()` | `EPD_SLEEP` | `tconSleep` | sleep | + +**Distinct IT8951 primitives OpenDisplay truly needs** (the port target): +`tconWaitForReady` (HRDY), `getTconInfo` (GetDevInfo), `setTconVcom`, `hostTconInit` +(VCOM+GetDevInfo+I80CPCR), reg read/write (for 1bpp mode + `LUTAFSR` polling), +`tconHostAreaPackedPixelWrite` (full-frame, both 8bpp-as-1bpp and 4bpp), +`tconDisplayArea` (GC16 gray) and `tconDisplayArea1bpp` (GC16 1bpp w/ BGVR), +`tconWaitForDisplayReady`, `tconWake`, `tconSleep`, `setTconTemp`. Plus RST toggle. **~14 ops.** + +Waveform selection is a single mode index (`0x02` = GC16) passed to `DPY_AREA`; the waveforms +themselves live in the TCON's own flash and self-load — **no LUT bytes to port.** The `refresh_mode` +distinction in `seeed_gfx_direct_refresh` (:181) is merely "call GC16 once vs twice", not a +different waveform. + +--- + +## D. Portion NOT used by Firmware (can be dropped) + +Everything in B not in C. Confirmed unreachable from `display_seeed_gfx.cpp`: + +- **All TFT_eSPI graphics/text/sprite/font/touch:** the entire `TFT_eSPI` core, `Sprite.cpp` + (except the `malloc`+`getPointer` mechanics), `Smooth_font.cpp`, `Button.cpp`, `Touch.cpp`, + every `TFT_Drivers/*` LCD driver. OpenDisplay never calls a drawing/text primitive. +- **Partial update:** `EPaper::updataPartial` (EPaper.cpp:71), `EPD_UPDATE_PARTIAL` + (ED103TC2_Defines.h:64), `EPD_WAKEUP_PARTIAL`. OpenDisplay always does a full-frame + `setTconWindowsData(0,0,w-1,h-1)` + full push. +- **Sub-region push:** `EPaper::update(x,y,w,h,data)` (EPaper.cpp:159), `pushImage`. +- **Rotation:** `tconSetImgRotation` (Tcon.cpp:237), rotate args — always `ROTATE_0`. +- **Read-back beyond what init/refresh need:** `tconReadNData`/`tconReadData` are needed **only** + for `getTconInfo` and reg reads; the general read API and `MEM_BST_*` burst-memory commands are + unused. +- **1bpp BGVR — REQUIRED (confirmed shipping 2026-07-24):** panel_ic **3000** + (`OD_PANEL_IC_ED103TC2_1872X1404`, non-`_4GRAY`) **does use** the 1bpp BGVR path: + `seeed_gfx_panel_is_4gray()` is false → `deinitGrayMode()` → `EPaper::update()` `_grayLevel==0` + branch → `EPD_PUSH_NEW_COLORS` + `EPD_UPDATE` → `tconLoad1bppImage` + `tconDisplayArea1bpp` + (which sets `UP1SR+2` bit2 and `BGVR`). **The product ships 1bpp**, so this is NOT droppable and + NOT in section D — it is in scope (kept here only to record the resolved decision). Its + consequence propagates: the `UP1SR+2` register **read-modify-write** makes the HRDY-gated **read + path mandatory** (it is not merely a GetDevInfo convenience). See E.2, G, and H. +- **Temp/humidity helpers:** `setTemp/getTemp/setHumi/getHumi` (only `setTconTemp` on wake is used; + the callback API is unused). +- **`hostTconInitFast`** (Tcon.cpp:453 / Init_Wake.h): the OpenDisplay wake path calls + `EPaper::wake()`→`tconWake()` **not** `initFromSleep()`, and `seeed_gfx_direct_write_reset` forces + a full `begin()` after any rail cut (`seeed_gfx_mark_hw_deinitialized`, cpp:190). So the + "fast re-init after light sleep" path is effectively bypassed — port only the full `hostTconInit`. + +--- + +## E. What must move to bb_epaper + +### E.1 ED103TC2 panel definition(s) +Two `EPD_PANEL` rows (`bb_ep.inl` `panelDefs[]` @3738) + two `EP_PANEL_*` enum values +(`bb_epaper.h` @159, before `EP_PANEL_COUNT` @271; the old `EP47` enum value is **absent** — only a +commented `panelDefs` row survives @3779): + +| Field | 1bpp (ic 3000) | 4gray (ic 3001) | +|---|---|---| +| width / height | 1872 / 1404 | 1872 / 1404 | +| x_offset | 0 | 0 | +| pInitFull | `it8951_ed103_init` (RST→VCOM 1400→GetDevInfo→I80CPCR) | same | +| pInitFast/Part | NULL | NULL | +| flags | 0 (B/W) | `BBEP_16GRAY` (0x0040) → 4-bpp buffer, `bbepAllocBuffer` @bb_ep_gfx.inl:1955 | +| chip_type | `BBEP_CHIP_IT8951` | `BBEP_CHIP_IT8951` | +| pColorLookup | `u8Colors_2clr` | `u8Colors_4gray` | + +Analog/mode constants for both: **VCOM = 1400** (−1.40 V, from `hostTconInit` Tcon.cpp:440), +GC16 waveform = `DPY_AREA` mode **0x02**, pixel formats `IT8951_8BPP`(=3, used for 1bpp +transport) / `IT8951_4BPP`(=2), endian `IT8951_LDIMG_L_ENDIAN`, gray levels 2 or 16. +Note bb_epaper's `epd47_it8951_init` @1396 encodes VCOM **2300** (M5Paper) — **do not reuse**; +ED103TC2 needs 1400. + +### E.2 IT8951 operations to port (source → target) + +| New bb_epaper function | Ports from `Tcon.cpp` | Behavior | +|---|---|---| +| HRDY-gated cmd/data/read prims | `tconWriteCmdCode`:60,`tconWirteData`:82,`tconReadData`:144,`tconReadNData`:164 | Add HRDY wait around the **existing** stubs `bbepWriteIT8951Cmd/Data/CmdArgs` (arduino_io.inl:108-144) and add a **new read** primitive (stubs have none) | +| `bbepIT8951WaitHRDY` | `tconWaitForReady`:22 | Poll busy pin until HIGH w/ timeout (reuse `opnd_seeed_tcon_busy_timeout` semantics) | +| `bbepIT8951ReadReg/WriteReg` | `tconReadReg`:201,`tconWriteReg`:213 | `REG_RD`/`REG_WR` + addr(+val) | +| `bbepIT8951GetDevInfo` | `getTconInfo`:416 | `GET_DEV_INFO` burst read → panel W/H + img-buf base addr | +| `bbepIT8951SetVcom` | `setTconVcom`:498 | cmd 0x0039 arg 0x02 + vcom | +| `bbepIT8951Init` (host init) | `hostTconInit`:437 | RST → set VCOM 1400 → GetDevInfo → `I80CPCR=1` | +| `bbepIT8951LoadFull` (packed pixel) | `tconHostAreaPackedPixelWrite`:276 + `tconLoad1bppImage`:369 / `tconLoadImage`:393 | set `LISAR` base → `LD_IMG_AREA` → per-row X-mirror + word-pack → burst → `LD_IMG_END`. 1bpp: 8bpp transport, width/8, X-mirror; 4bpp: width as-is | +| `bbepIT8951Display` (GC16) | `tconDisplayArea`:331 / `tconDisplayArea1bpp`:346 | gray: `DPY_AREA` mode 2. 1bpp: X-mirror + set `UP1SR+2` bit2 + `BGVR`=(0<<8\|255) + `DPY_AREA` + wait + restore | +| `bbepIT8951WaitDisplay` | `tconWaitForDisplayReady`:521 | poll `LUTAFSR` until 0 | +| `bbepIT8951Sleep/Wake` | `tconSleep`:506/`tconWake`:511 | cmd `SLEEP`/`SYS_RUN` (+ `setTconTemp` on wake, Tcon.cpp:482) | + +All register `#define`s already exist in `bb_epaper.h` @329-409 (`IT8951_*`); command opcodes too. +`I80TCONDevInfo` struct must be added (currently only in `Tcon.h`:24) — put it near the IT8951 +`#define`s in `bb_epaper.h` or in `structs.h` (per repo rule, structs do not go in the protocol +header, but this is bb_epaper's own header, not the vendored protocol header, so it is fine there). + +--- + +## F. Detailed integration plan + +### F.1 Panel-table rows (bb_ep.inl:3738, bb_epaper.h enum) +Add `EP_ED103TC2_1872x1404` and `EP_ED103TC2_1872x1404_4GRAY` to the `EP_PANEL_*` enum +(bb_epaper.h, before `EP_PANEL_COUNT`:271), and the two `panelDefs[]` rows from E.1. Uncomment / +replace the dormant M5Paper row at bb_ep.inl:3779 or add fresh rows. Write `it8951_ed103_init` as a +byte sequence that `bbepSendCMDSequence` (bb_ep.inl:4202) can interpret — **but note** its opcode +model is single-byte SSD/UC commands; IT8951 needs 16-bit opcodes + HRDY, so the IT8951 init is +better done as a dedicated C function (`bbepIT8951Init`) invoked from the lifecycle branch rather +than shoe-horned into the `pInitFull` byte-table format. Set `pInitFull=NULL` and branch on +`chip_type==BBEP_CHIP_IT8951` in the lifecycle functions instead. + +### F.2 `BBEP_CHIP_IT8951` branches to add (by function, bb_ep.inl) + +| Function | Line | IT8951 branch behavior | +|---|---|---| +| `bbepWaitBusy` | 3957 | `busy_idle = HIGH` for IT8951 (HRDY ready = HIGH). Currently only UC81xx=HIGH else LOW (:3965) | +| `bbepWakeUp` | 3992 | RST pulse already generic; after reset, for IT8951 call `bbepIT8951Init` (VCOM+GetDevInfo+I80CPCR) — otherwise TCON unconfigured | +| `bbepSetAddrWindow` | 4006 | Complete the `#ifdef FUTURE` stub (:4016-4027): for full-frame OpenDisplay this collapses to storing the area; real `LD_IMG_AREA` happens inside the packed-write. Simplest: make it a no-op for IT8951 (window is implicit in the load) | +| `bbepWritePlane` | 5083 | Branch before the UC/SSD split (:5120): call `bbepIT8951LoadFull(pBBEP, plane)` (1bpp vs 4bpp per `BBEP_16GRAY`) and return | +| `bbepRefresh` | 4365 | Branch at top: for IT8951, `bbepIT8951Display` (GC16 mode 2, 1bpp-BGVR or gray) + `bbepIT8951WaitDisplay`; skip the UC/SSD `DISP_CTRL2`/`DRF` logic | +| `bbepSleep` | 4109 | Branch: IT8951 → `bbepIT8951Sleep` (cmd 0x03); `is_awake=0` | +| `bbepStartWrite` | 4140 | Not needed for IT8951 (load is monolithic); guard so it is a no-op | + +That is **6–7 functions** getting a small IT8951 branch, plus the ~8 new IT8951 functions from E.2, +plus HRDY/read added to the 3 existing SPI stubs. + +### F.3 Firmware-side changes (`src/`) +Replace the `EPaper`-based shim in `display_seeed_gfx.cpp` with bb_epaper calls, keeping the **same +public function names** so `display_service.cpp` needs no change beyond eventually collapsing its +`#ifdef`s: + +| `display_seeed_gfx.cpp` fn | New bb_epaper implementation | +|---|---| +| `seeed_gfx_epaper_begin` :104 | `bbepSetPanelType(&bbep, EP_ED103TC2_… )`; `bbepInitIO(...)`; `bbepAllocBuffer` (or `setBuffer` to a PSRAM buffer); `bbepWakeUp` (→ runs `bbepIT8951Init`) | +| `getPointer()` :134,160,168 | `bbep.ucScreen` / `BBEPAPER::getBuffer()` (bb_epaper.cpp:421) | +| `seeed_gfx_direct_write_reset` :145 | first-boot/rail-cut → full `bbepWakeUp`+init; else nothing; `memset(ucScreen,0xFF,fb_byte_size())` | +| `seeed_gfx_direct_write_chunk` :166 | `memcpy` into `ucScreen + offset` (unchanged) | +| `seeed_gfx_direct_refresh` :179 | `bbepWritePlane(&bbep, PLANE_0, ...)` then `bbepRefresh(&bbep, REFRESH_FULL)` (×2 if mode 0) | +| `seeed_gfx_direct_sleep` :186 | `bbepSleep(&bbep, 1)` | +| `seeed_gfx_mark_hw_deinitialized` :190 | keep the plain-RAM flag; on next reset force full init | + +Once the shim is pure bb_epaper, the ~15 `#ifdef OPENDISPLAY_SEEED_GFX` sites in +`display_service.cpp` (grep list: lines 19, 368, 418, 724, 753, 1579, 1677, 1964, 2093, 2147, 2332, +2452, 2644, 2826, 3170) **collapse**: the ED103TC2 path becomes just another +`seeed_driver_used()`-style panel_ic check that routes through the same `bbep` object the other +panels already use. Sites that today call `seeed_gfx_direct_*` can call the normal +`bbepWritePlane`/`bbepRefresh`/`bbepSleep` used by e.g. the E1004 path (already present at +:449-536). `seeed_driver_used()` (:723) can be renamed/merged into the generic panel dispatch. +`-DOPENDISPLAY_SEEED_GFX` and the `lib/Seeed_GFX/` tree are then deletable. + +### F.4 Framebuffer ownership & sizing +Today `EPaper` (via `TFT_eSprite::createSprite`) mallocs `_img8`; `getPointer()` returns it. In +bb_epaper, `ucScreen` is the framebuffer, allocated by `bbepAllocBuffer` (bb_ep_gfx.inl:1951), +which routes **>98 000 bytes to `ps_malloc` (PSRAM)** automatically (:1965-1969). Sizes for +1872×1404 = **2 628 288 px**: + +- **1bpp:** stride `(1872+7)/8 = 234` B/row × 1404 = **328 536 B ≈ 320.8 KiB (~329 KB)** → + `bbepAllocBuffer` picks PSRAM (>98 000). ✔ +- **4bpp (16-gray):** stride `1872/2 = 936` B/row × 1404 = **1 314 144 B ≈ 1.253 MiB (~1.31 MB)** + → PSRAM, and `BBEP_16GRAY` makes `bbepAllocBuffer` size it as `(w>>1)*h` (:1955-1956). ✔ + +**PSRAM confirmed** on every ED103TC2 build env: `platformio.ini` has `-DBOARD_HAS_PSRAM` + +`board_build.psram_type=qspi_opi` for `esp32-s3-N16R8` (:47), `N8R8` (:72), `N32R8` (:124). The S3 +carries 8 MB OPI PSRAM; a 1.31 MB framebuffer is comfortable. Packed-pixel transfer sizes match the +buffer sizes exactly (traced through `tconHostAreaPackedPixelWrite`: 1bpp → 117 words/row × 1404 = +328 536 B; 4bpp → 468 words/row × 1404 = 1 314 144 B). + +### F.5 Upstream-vs-fork strategy +bb_epaper is vendored **upstream** (bitbank2). The IT8951 stub (chip enum, `IT8951_*` `#define`s, +`bbepWriteIT8951*`, `epd47_it8951_init`, the commented panel row, the `#ifdef FUTURE` dispatch) is +**bitbank2's own unfinished work** in a single 2024-12-16 commit `631e3c0`. Completing it is a +**natural upstream PR**, not a fork: add the HRDY/read primitives, the lifecycle branches, the +GetDevInfo/VCOM/packed-write/DPY_AREA functions, and generic IT8951 panel rows (M5Paper 540×960 +**and** ED103TC2 1872×1404). Keep OpenDisplay-specific runtime-pin plumbing behind the existing +`OPENDISPLAY_SEEED_GFX_RUNTIME_PINS`-style guard so upstream stays board-agnostic. Until merged, +pin the vendored copy (as already done for `BBEP_T133A01` / E1004, platformio.ini:143). + +--- + +## G. Feasibility with minimal architectural change + +**It fits bb_epaper's table-driven model with a *parallel dispatch branch*, not a rewrite.** +bb_epaper's lifecycle is `init-table → bbepWritePlane (push local buffer) → bbepRefresh`. The +IT8951 maps onto this cleanly **at the seams**, because it too has (a) host-side framebuffer +(`ucScreen`), (b) a "push to controller frame memory" step (`bbepWritePlane`→packed-pixel load), +(c) a "refresh with a mode" step (`bbepRefresh`→`DPY_AREA` GC16). The difference is *inside* each +step: instead of a byte-command table + RAM window, IT8951 uses I80 command packets + HRDY + its +own frame memory. So each lifecycle function gets **one `if (chip_type==BBEP_CHIP_IT8951)` branch +that early-returns after doing the IT8951 equivalent** — the exact pattern already used for +`UC81xx` vs `SSD16xx` throughout (`bbepWaitBusy`:3965, `bbepSetAddrWindow`:4031, `bbepSleep`:4112, +`bbepStartWrite`:4145, `bbepWritePlane`:5120). + +**Quantified:** +- Functions gaining an IT8951 branch: **6–7** (`bbepWaitBusy`, `bbepWakeUp`, `bbepSetAddrWindow`, + `bbepWritePlane`, `bbepRefresh`, `bbepSleep`, +`bbepStartWrite` no-op). +- New/ported IT8951 functions: **~8** (HRDY wait, read primitive, reg rd/wr, GetDevInfo, SetVcom + + host init, packed-pixel full-frame load, DPY_AREA display incl 1bpp-BGVR, wait-display). +- New/ported lines: **~250–350** C (the `tcon*` originals total ~520 lines but a third is + unused: reads-beyond-init, rotation, partial, MEM_BST, temp/humi callbacks). +- Untouched: the entire SSD16xx/UC81xx code path, all 60+ existing panel rows, `bbepWriteImage*`, + `bbepMakeLUTs`, the graphics layer — **no regression surface** for existing panels. + +**Single biggest architectural friction: the SPI transport granularity + missing HRDY/read.** +bb_epaper's IT8951 stubs (`bbepWriteIT8951Cmd/Data/CmdArgs`, arduino_io.inl:108-144) fire +`SPI.transferBytes` **without any HRDY handshake and provide no read path**, whereas IT8951 +mandates HRDY-gating before the preamble and before data, and GetDevInfo/reg-reads/`LUTAFSR` +polling are read bursts (Seeed polls HRDY per word, Tcon.cpp:22,151-184). **Contain it** by adding a +single `bbepIT8951WaitHRDY(pBBEP)` used inside a small set of transport helpers (cmd/data/read), +mirroring `tconWaitForReady`'s timeout guard (which OpenDisplay already surfaces via +`opnd_seeed_tcon_busy_timeout_*`, display_seeed_gfx.cpp:40-48) so a dead panel degrades gracefully +instead of hanging. For the bulk pixel push, HRDY is polled **once** before the burst (as Seeed +does, Tcon.cpp:107), so throughput is unaffected. + +--- + +## H. Risk register + +| Risk | Sev | Detail | Mitigation | +|---|---|---|---| +| **VCOM correctness** | High | IT8951 VCOM is a per-unit analog calibration (often on an FPC sticker). Seeed hardcodes **1400** (−1.40 V, Tcon.cpp:440); bb_epaper's M5Paper stub uses **2300** (bb_ep.inl:1399). Wrong VCOM → washed-out/ghosted/over-driven image. | Use 1400 for ED103TC2 (matches shipping Seeed path). Make VCOM a panel-table/config field, not a literal. Validate against a physical panel; consider reading factory VCOM if the TCON stores it. | +| **HRDY timing** | High | Existing stubs omit HRDY; IT8951 will corrupt/hang without it. Read bursts poll HRDY per word. | Add `bbepIT8951WaitHRDY` with the existing timeout+flag mechanism; poll before preamble/data and per read word; poll once before pixel burst. | +| **Packed-pixel nibble/word order + X-mirror** | High | `tconHostAreaPackedPixelWrite` reverses X per row (`width-1-i`, Tcon.cpp:307) and `tconLoad1bppImage`/`tconDisplayArea1bpp` additionally X-mirror the origin (`panelW-1-usX-usW+1`, :371,348). 4bpp nibble order is left-pixel=high-nibble (display_seeed_gfx.cpp:3-5). Any mismatch → mirrored or scrambled output. | Port the mirror/pack loop **verbatim**; keep the same `IT8951_LDIMG_L_ENDIAN` + `bswap` handling as the existing `bbepWriteIT8951CmdArgs` (arduino_io.inl:141). Diff first render pixel-for-pixel against the current Seeed build. | +| **1bpp BGVR register dance** | **High** (was Med) | **1bpp confirmed shipping (2026-07-24)** → this path is on the critical path, not optional. ic 3000 needs `UP1SR+2` bit2 set/restore + `BGVR` color table around `DPY_AREA` (Tcon.cpp:350-363), which requires **reg read-modify-write** → makes the HRDY-gated **read path mandatory** (no longer just for GetDevInfo). The existing bb_epaper stubs have **no read primitive at all** — this is net-new code, and the single largest correctness dependency. | Port `tconDisplayArea1bpp` exactly; implement + bench-verify the read/HRDY primitive **first**, before any refresh path. Diff first 1bpp render pixel-for-pixel vs. the current Seeed build. | +| **Buffer sizing / PSRAM** | Low | 329 KB (1bpp) / 1.31 MB (4bpp) must land in PSRAM. | `bbepAllocBuffer` auto-routes >98 KB to `ps_malloc` (bb_ep_gfx.inl:1965); all ED103TC2 envs set `BOARD_HAS_PSRAM` (platformio.ini:47,72,124). Verify alloc success; fall back to internal-DRAM is impossible at 1.3 MB → handle NULL. | +| **Hardware-validation dependency** | High | Correctness (VCOM, HRDY, mirroring, GC16 waveform index) cannot be proven from source; needs a real **ED103TC2 1872×1404** panel on an S3. | Gate the migration behind a bench bring-up: keep the Seeed path selectable until the bb_epaper path is validated on hardware side-by-side. | +| **Upstream acceptance** | Low | PR must stay board-neutral. | Keep runtime-pin/timeout glue behind guards; contribute generic M5Paper + ED103TC2 rows; until merged, pin the vendored bb_epaper (as done for E1004). | +| **Refresh-wait semantics** | Low | Today `seeed_gfx_wait_refresh` just `delay(300)` (display_seeed_gfx.cpp:123). bb_epaper uses `LUTAFSR` polling. | Port `tconWaitForDisplayReady`; drop the blind delay for a real poll (strict improvement). | + +--- + +## I. Open questions (need a human / hardware) + +1. **VCOM per unit:** Is −1.40 V correct for the specific ED103TC2/E1004 panels OpenDisplay ships, + or does each unit carry an individual VCOM (sticker/OTP) that must be provisioned? Should VCOM + become a `DisplayConfig`/factory field rather than a compile-time literal? +2. **1bpp vs 4gray in the field:** ~~Does any shipped product use panel_ic 3000 (1bpp)?~~ + **RESOLVED 2026-07-24 — 1bpp IS shipping.** The BGVR/`UP1SR` 1bpp path and its HRDY-gated read + dependency are therefore **in scope and mandatory** — the port cannot be shrunk by dropping them. + The 4bpp/4gray (ic 3001) path may or may not also ship, but does not reduce scope either way. +3. **HRDY pin identity:** Confirm the IT8951 HRDY line is wired to the same GPIO OpenDisplay maps as + `busy_pin` / `opnd_seeed_runtime_busy` (default 13), and that ready = logic HIGH on this board. +4. **GC16-only, or is A2/DU wanted?** OpenDisplay's current path only ever issues GC16 (mode 0x02). + Is a fast (A2) mode desired for partial/low-latency updates, or is GC16-only acceptable + (matches today's behavior)? +5. **SPI clock:** Seeed drives the TCON via `TFT_eSPI` at its configured SPI freq; the bb_epaper + E1004 path uses 8 MHz (`bbepInitIO(...,8000000)`, display_service.cpp:188). Is 8 MHz safe for the + IT8951 GetDevInfo read burst on this wiring, or does it need a slower read clock? +6. **DMA:** Seeed's `tconWirteNData` optionally uses `pushPixelsDMA`. Should the bb_epaper port use + ESP32 SPI DMA for the ~1.3 MB burst, or is `SPI.transferBytes` (as the stub uses) sufficient? +7. **Dual-controller?** The E1004 path handles a dual-CS split panel (`iCS2Pin`, + display_service.cpp:187,215). Is any ED103TC2 variant dual-controller, or always single-CS? + +--- + +## Appendix: complete bb_epaper function inventory (annotated for the IT8951 port) + +bb_epaper has two layers: the **`BBEPAPER::` C++ class** (public API that +`display_service.cpp` calls) and the **`bbep*` C functions** (implementation the class +delegates to). Function counts and line refs are from the vendored checkout +(`.pio/libdeps//bb_epaper/`, upstream commit `c651b2a`). + +**Legend for the port impact column:** +- **TOUCH** — existing function gets a new `BBEP_CHIP_IT8951` branch. +- **ADD** — net-new IT8951 helper (no existing equivalent). +- **USE** — called unchanged by the OpenDisplay IT8951 path. +- **—** — irrelevant to OpenDisplay (drawing/text/loaders never invoked; firmware + `memcpy`s pre-dithered pixels into the buffer directly). + +### A. `BBEPAPER::` public class API (71 methods, `bb_epaper.cpp`) + +| Group | Methods | OpenDisplay/IT8951 | +|---|---|---| +| Lifecycle / panel | `begin` `initIO` `setPanelType` `testPanelType` `createVirtual` `getChip` `getFlags` `setFlags` `capabilities` `getLastError` | USE (`begin`,`initIO`,`setPanelType`) | +| Refresh / power | `refresh` `sleep` `wake` `wait` `isBusy` `startWrite` `setPasses` `hasFastRefresh` `hasPartialRefresh` `getRefreshTime` `dataTime` `opTime` `getCache` | USE (`refresh`,`sleep`,`wake`,`wait`) | +| Buffer / plane | `allocBuffer` `freeBuffer` `setBuffer` `getBuffer` `setPlane` `getPlane` `backupPlane` `writePlane` `writeRegion` `setAddrWindow` | USE (`allocBuffer`/`getBuffer` for the framebuffer + `writePlane`) | +| Geometry | `width` `height` `setRotation` `getRotation` `setCS` | USE (`width`,`height`,`setCS`) | +| Raw I/O | `writeCmd` `writeData` | USE (transitive) | +| Drawing (GFX) | `drawPixel` `drawLine` `drawRect`/`fillRect` `drawCircle`/`fillCircle` `drawEllipse`/`fillEllipse` `drawRoundRect`/`fillRoundRect` `fillScreen` `drawSprite` `stretchAndSmooth` | — | +| Text | `drawString` `print` `println` `write` `setCursor` `getCursorX`/`getCursorY` `setFont` `setFreeFont` `setItalic` `setTextColor` `setTextWrap` `getStringBox` | — | +| Image loaders | `loadBMP` `loadG` | — | +| Misc | `setDitherPattern` | — | + +### B. Internal `bbep*` C functions + +**B.1 Panel / lifecycle (`bb_ep.inl`)** + +| Function | Line | Port impact | Note | +|---|---|---|---| +| `bbepSetPanelType` | 3828 | TOUCH (data) | add 2 ED103TC2 rows to `panelDefs[]` + 2 enum values | +| `bbepTestPanelType` | 4233 | — | | +| `bbepCreateVirtual` | 3902 | — | | +| `bbepSetDitherPattern` | 3880 | — | firmware pre-dithers | +| `bbepLightSleep` | 3938 | — | | +| `bbepWaitBusy` | 3957 | **TOUCH** | IT8951 needs HRDY-level poll, not the UC/SSD busy-pin ternary | +| `bbepIsBusy` | 3980 | **TOUCH** | same HRDY semantics | +| `bbepWakeUp` | 3992 | **TOUCH** | IT8951 `SYS_RUN` + `setTconTemp`, vs generic RST pulse | +| `bbepSetAddrWindow` | 4006 | **TOUCH** | IT8951 `LD_IMG_AREA` branch exists but is `#ifdef FUTURE` (bb_ep.inl:4017) — enable + finish | +| `bbepSleep` | 4109 | **TOUCH** | IT8951 `SLEEP` command | +| `bbepStartWrite` | 4140 | **TOUCH** | IT8951 packed-pixel preamble differs | +| `bbepMakeLUTs` | 4170 | **TOUCH (skip)** | IT8951 has no host LUTs — waveforms in TCON flash; branch is a no-op | +| `bbepSendCMDSequence` | 4202 | **TOUCH** | chip-agnostic byte-code walker today; IT8951 init needs read-back (GetDevInfo/VCOM) the flat table can't express → **the one genuinely new dispatch point** | +| `bbepFill` | 4253 | TOUCH (opt) | only if IT8951 fast-clear wanted; firmware overwrites full frame anyway | +| `bbepRefresh` | 4365 | **TOUCH** | IT8951 `DPY_AREA` GC16 (mode 0x02) + `LUTAFSR` wait | +| `bbepSetRotation` | 4444 | — | always ROTATE_0 | + +**B.2 Plane / image write (`bb_ep.inl`)** + +| Function | Line | Port impact | +|---|---|---| +| `bbepWritePlane` | 5083 | **TOUCH** — IT8951 full-frame packed-pixel `LD_IMG` burst | +| `bbepWriteRegion` | 5061 | — (partial region; OpenDisplay is full-frame) | +| `bbepWriteImage` / `bbepWriteHalf` / `bbepWriteImage1to4bpp` / `bbepWriteImage2bpp` / `bbepWriteImage4bpp` / `bbepWriteImage4bppDual` / `bbepWriteImage4bppSpecial` | 4464–4976 | — (SSD/UC plane encoders) | + +**B.3 Low-level SPI I/O (per-platform; OpenDisplay uses `arduino_io.inl`)** + +| Function | Port impact | Note | +|---|---|---| +| `bbepInitIO` `bbepSetCS2` `bbepWriteCmd` `bbepWriteData` `bbepCMD2` | USE | one definition each per platform (`arduino_io.inl`/`mem_io.inl`/`esphome_io.inl`/`rpi_io.inl`) | +| `bbepWriteIT8951Cmd` `bbepWriteIT8951Data` `bbepWriteIT8951CmdArgs` | **TOUCH** | existing stubs (arduino_io.inl:108–144), **no HRDY, no read path** — add HRDY wait | +| *(new)* `bbepReadIT8951Data` / `bbepReadIT8951Reg` | **ADD** | net-new read primitive — mandatory for GetDevInfo **and** the 1bpp `UP1SR` RMW | + +**B.4 New IT8951 helpers to ADD (ported from Seeed `Tcon.cpp` — see §E.2)** + +`bbepIT8951WaitHRDY` · `bbepIT8951ReadReg`/`WriteReg` · `bbepIT8951GetDevInfo` · +`bbepIT8951SetVcom` · `bbepIT8951Init` · `bbepIT8951LoadFull` · `bbepIT8951Display` · +`bbepIT8951WaitDisplay` · `bbepIT8951Sleep`/`Wake` → all **ADD**. + +**B.5 Buffer (`bb_ep_gfx.inl`)** + +| Function | Line | Port impact | +|---|---|---| +| `bbepAllocBuffer` | 1951 | USE — routes 329 KB (1bpp) / 1.31 MB (4bpp) to `ps_malloc` (PSRAM) | + +**B.6 IRRELEVANT to OpenDisplay (never called — firmware writes pixels directly)** + +- **Pixel setters (`bb_ep_gfx.inl`):** `bbepSetPixel{2,3,4,16}Clr` · `bbepSetPixel4Gray` · `bbepSetPixel2ClrDither` · all `bbepSetPixelFast*` variants (`Fast2Clr/3Clr/4Clr/4ClrV2/16Clr/4Gray`). +- **Graphics primitives:** `bbepDrawLine` · `bbepEllipse` · `bbepRectangle` · `bbepRoundRect` · `bbepDrawSprite`. +- **Text / fonts:** `bbepWriteString` · `bbepWriteStringCustom` · `bbepSetCursor` · `bbepSetTextWrap` · `bbepGetStringBox` · `bbepUnicodeString` · `bbepUnicodeTo1252` · `bbepStretchAndSmooth`. +- **Image loaders:** `bbepLoadG5` · `bbepLoadG5_2Bit` · `bbepLoadBMP` · `bbepLoadBMP3`. + +### C. Tally + +| Bucket | Count | +|---|---| +| Existing `bbep*` functions that get an IT8951 branch (**TOUCH**) | 11 (`bbepWaitBusy`, `bbepIsBusy`, `bbepWakeUp`, `bbepSetAddrWindow`, `bbepSleep`, `bbepStartWrite`, `bbepMakeLUTs`(skip), `bbepSendCMDSequence`, `bbepRefresh`, `bbepWritePlane`, + IT8951 SPI stubs) | +| New IT8951 helpers (**ADD**) | ~10 (incl. the read primitive) | +| Existing functions used unchanged (**USE**) | class API + `bbepInitIO`/`bbepAllocBuffer`/SPI prims | +| Irrelevant, untouched (**—**) | ~40 (all GFX/text/pixel/loaders) + all SSD/UC plane encoders | + +Net: the IT8951 work concentrates in ~11 TOUCH + ~10 ADD functions; the ~40-function GFX/text +surface and every existing panel stay untouched — confirming §G's "minimal architectural change" +verdict, with `bbepSendCMDSequence` (init read-back) as the single new dispatch point. diff --git a/docs/PLAN_BLE_IDLE_SESSION_DISCONNECT_2026-07-29.md b/docs/PLAN_BLE_IDLE_SESSION_DISCONNECT_2026-07-29.md new file mode 100644 index 0000000..72b1cc8 --- /dev/null +++ b/docs/PLAN_BLE_IDLE_SESSION_DISCONNECT_2026-07-29.md @@ -0,0 +1,686 @@ +# Plan — idle-session watchdog: disconnect a BLE client that holds the link doing nothing + +**Date:** 2026-07-29 +**Branch:** `fix/nrf-no-adv-while-connected` (or a fresh `feat/ble-idle-session-kick` off it) +**Scope:** one new transport method, one new loop()-serviced watchdog, one tunable +(`OD_BLE_IDLE_SESSION_TIMEOUT_MS`, default 120 s). Five files, ~46 inserted lines, +no existing line modified. No protocol change, no config-packet change, no +cross-repo work. + +### Revision history + +**Draft 1** proposed the watchdog with `pollActivity()`-style RX/TX queue-head +tracking. **Draft 2** removed that tracking and raised the timeout 60 s → 120 s, +arguing the extra margin made it unnecessary. **Draft 3** reinstated the tracking +after an adversarial review (Codex, 2026-07-29) showed Draft 2 would disconnect +working clients. **Draft 4** replaced queue-head diffing with a direct activity stamp at the BLE +callback and notify sites, and fixed the idle definition to exactly "no command +received, no response sent, queues empty" (§2) — dropping `transferActive()` and +`epdRefreshInProgress`, the latter unobservable from `loop()` in any case. +**Draft 5 — this one — stamps on RX only** (§3e): every response is generated inside +a command handler, so a TX stamp is redundant except for the delayed post-refresh +ACK, where firing early is defensible and now documented. Checking that surfaced a +hole present in every earlier draft — a connected client that never enables its CCCD +leaves a response queued forever and was permanently immune to the kick, fixed by +the `&& ble.notifyReady()` qualifier in §3b. + +The review findings that shaped Drafts 3–4, both verified against the code: + +- *Draft 2's watchdog would kick clients that are actively working.* Its stamp + condition only reads whether the queues are **still** non-empty at the end of a + pass, and by then they never are — `serviceBleRx()` flushes TX after every + command ([main.cpp:534](../src/main.cpp#L534)) and `serviceBleTx()` runs again at + [:671](../src/main.cpp#L671), both before the watchdog's position at + [:697](../src/main.cpp#L697). `bleRxQueuePending()`/`bleTxQueuePending()` are + plain head≠tail tests ([command_queue.cpp:151-153](../src/command_queue.cpp#L151-L153), + [:186-188](../src/command_queue.cpp#L186-L188)), so a command that arrives *and* + drains inside one pass leaves no trace. A client sending a command every 30 s + would have been disconnected 120 s after connecting. Head-change tracking is not + an optimisation; it is the only thing that observes BLE traffic at all. +- *The 60 s refresh bound the simplification rested on does not exist.* This repo's + own audit shows `waitforrefresh()`'s real wall-clock bound is **~126 s**, because + `bbepIsBusy()` adds `delay(10) + delay(1)` inside each of the 6000 iterations + ([FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md §B1](FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md), + loop at [display_service.cpp:811-825](../src/display_service.cpp#L811-L825)). + Draft 2 cited the `timeout = 60` argument and treated it as seconds of wall + clock. §B2 of the same audit adds up to ~54 s of notify-semaphore waiting in a + single drain pass, independently of any refresh. + +Three further review findings are adopted as amendments (§3a, §3d, §6.1); one is +recorded as an accepted breakage (§3c). Findings that did **not** survive checking +are listed in §7. + +--- + +## 1. Purpose + +**An idle client holding the connection locks every other caller out of the +device.** That is what this change exists to fix; everything else in this section +is either evidence for it or a side benefit. + +The device serves one BLE client at a time and stops advertising for the duration, +so a peer that connects and then does nothing is not merely wasting its own +session — it is denying the device to Home Assistant, to the CLI, and to any other +host, for as long as it chooses to stay attached. Nothing currently bounds that. +The three timers that look like they might, do not: + +| Existing timer | What it actually does | Why it does not cover this | +|---|---|---| +| `TRANSFER_WATCHDOG_MS` = 15 min ([display_service.cpp:582](../src/display_service.cpp#L582), run from [main.cpp:697](../src/main.cpp#L697)) | Tears down a stuck DIRECT/PIPE/PARTIAL transfer | Frees transfer state only. The link survives, and it never fires for a client that connected and sent nothing. | +| `securityConfig.session_timeout_seconds` ([encryption.cpp:221-232](../src/encryption.cpp#L221-L232)) | Clears the encryption session | Clears crypto state only; no disconnect. Also `0` = never. | +| `OD_LAN_READ_TIMEOUT_S` = 30 s ([opendisplay_protocol.h:984](../include/opendisplay_protocol.h#L984), enforced at [wifi_service.cpp:949-956](../src/wifi_service.cpp#L949-L956)) | Drops an idle **LAN** client | LAN transport only. BLE has no equivalent. | + +So a peer that connects and then goes quiet — a crashed host, a BlueZ connection +the supervising process abandoned, a scanner app left open on a phone, an +`animate.py` run someone Ctrl-Z'd — holds the device hostage until *its* side gives +up, which may be never. + +### 1a. The lockout, mechanically + +Advertising stops for the whole connection, on both targets, by design and now by +explicit guard: nRF at [ble_transport_nrf.cpp:267](../src/ble_transport_nrf.cpp#L267) +(one peripheral role slot from `Bluefruit.begin(1, 0)`, so `sd_ble_gap_adv_start()` +returns `NRF_ERROR_CONN_COUNT`), ESP32 at +[ble_transport_esp32.cpp:283](../src/ble_transport_esp32.cpp#L283). Both make +`setManufacturerData()` return false while connected. + +A second caller therefore cannot even find the device, let alone connect: it is not +advertising, so a scan does not list it and a connect-by-address has nothing to +answer it. From the other host's point of view the tag is simply gone. For HA that +surfaces as delivery failures against a device that is powered, in range and +healthy — and its per-operation `async with OpenDisplayDevice(...)` pattern +(`delivery.py:319-340`) gives it no way to queue behind the squatter; each attempt +just fails. + +Two further consequences of the same suppression, for the whole time the idle peer +stays attached: `updatemsdata()` cannot publish, so battery/temperature/button/touch +state in the advertisement is frozen at whatever it was when the session began, and +any advertising-interval boost armed by a button press cannot be restored (the +mechanism documented at length in +[PLAN_NRF_NO_ADV_WHILE_CONNECTED_2026-07-29.md](PLAN_NRF_NO_ADV_WHILE_CONNECTED_2026-07-29.md)). + +### 1b. Side benefit: on ESP32 battery targets it also pins the tag awake + +`pollActivity()` stamps `lastActivityMs` on **every pass while `connCount > 0`** +([main.cpp:366-368](../src/main.cpp#L366-L368)) — a live link is treated as +activity in itself, deliberately. `platformIdle()`'s deep-sleep branch requires a +quiet window since that stamp ([main.cpp:599-607](../src/main.cpp#L599-L607)), and +`workInFlight` includes `ble.isConnected()` ([main.cpp:742](../src/main.cpp#L742)), +so the loop takes the `delay(1)` arm forever. + +An idle connected client therefore prevents deep sleep for as long as it stays +connected. On a battery tag that is the difference between microamps and +milliamps — the most expensive thing an unauthenticated peer can currently do to +the device without sending a byte. + +This is a **secondary** motive, and it is worth being explicit about the ordering: +if the lockout in 1a did not exist, the power cost alone would not justify +disconnecting a client that might have a reason to be there. It is the exclusivity +that makes an idle session everyone else's problem. Consequences for the design: + +- The kick must **restore advertising**, not merely free the radio — which it does + via the existing `serviceBleAdvertisingRestart()` path (§3a), on the target where + the stack does not re-arm by itself. +- The timeout is best read as *"how long may one caller lock everyone else out?"*, + not *"how long may a client be lazy?"* — see §3c. +- A future alternative that fixes 1a without disconnecting anyone (advertising + while connected, so a second caller can queue) would supersede the whole + approach; §7.4 records why that is not available today. + +--- + +## 2. What "idle" must mean + +**Definition (decided 2026-07-29): a session is idle when no command has been +received, no response has been sent, and both queues are empty.** Nothing else. + +Activity is observed **directly at the BLE receive callback**, not inferred from +per-pass queue-head snapshots (§3e explains why that is both simpler and strictly +more accurate): + +| Clause | Observed at | Term | +|---|---|---| +| no command received | `bleRxQueuePush()` ([command_queue.cpp:50](../src/command_queue.cpp#L50)) — the single RX ingress, called only from the two stack callbacks ([nrf:155](../src/ble_transport_nrf.cpp#L155), [esp32:147](../src/ble_transport_esp32.cpp#L147)) | `millis() - bleLastRxMs()` | +| no response sent | **not stamped** — every response is generated inside a command handler, so it is already implied by the RX stamp. See §3e "why TX is not stamped" for the one exception and why it is accepted | — | +| queues empty | `bleRxQueuePending() \|\| (bleTxQueuePending() && ble.notifyReady())` | see §3e "the unsubscribed-client hole" | +| (not a session) | `ble.isConnected()` | as-is | + +**Transfer and refresh state are deliberately NOT terms.** + +- `epdRefreshInProgress` would be dead code. Both assignment pairs bracket + straight-line blocking work with no return to `loop()` + ([display_service.cpp:2452-2473](../src/display_service.cpp#L2452-L2473), + [:3346-3356](../src/display_service.cpp#L3346-L3356)), so a loop()-level check + can never observe it true. Its intended coverage — the blocking dead zone, now + known to reach ~126 s — is already handled: the command that triggered the + refresh advanced `rxHead` in the pass that blocks, and its response advanced + `txHead`, so the pass that resumes stamps regardless of duration. +- `transferActive()` is dropped as a *policy* choice, and it changes behaviour + versus earlier drafts: **a mid-transfer session whose client has gone silent for + the full window is now kicked**, rather than waiting out the 15-minute transfer + watchdog. That is the intent of an idle timer — a stalled transfer holds the + radio, the panel rail and (on ESP32) wakefulness, and it cannot progress without + the client. Teardown is not new code: the disconnect raises + `s_disconnectCleanupPending`, and `serviceBleDisconnectCleanup()` + ([main.cpp:388-423](../src/main.cpp#L388-L423)) already handles exactly this case + for a peer-initiated drop — `cleanupDirectWriteState(true)`, + `cleanupPartialWriteOnDisconnect()`, `resetPipeWriteState()`, behind the + `ownerStillUp` guard. The 15-minute watchdog remains as the backstop for a + transfer abandoned by a client that *stays* connected and chatty. + +What this buys: the kick still cannot interrupt a command mid-dispatch (it runs +between passes, never inside a handler), and it can no longer be indefinitely +suppressed by stuck state — which is what `transferActive()` as a busy term would +have done, since a wedged `pipeState.active` would have pinned the stamp forever. + +--- + +## 3. Design + +### 3a. `BleTransport::disconnect()` — new seam method + +Declared beside the other lifecycle calls in +[ble_transport.h](../src/ble_transport.h), documented as **asynchronous**: the +stack's disconnect callback fires later, so `takeDisconnectedEvent()` and the +existing `serviceBleDisconnectCleanup()` / `serviceBleAdvertisingRestart()` path +own teardown and the advertising re-arm. A caller must never pair this with +inline cleanup. + +**Signature: `bool disconnect()`**, not `void` (review amendment). Both stacks +report failure and the earlier `void` seam would have swallowed it: NimBLE returns +false when `ble_gap_terminate()` rejects the request (`NimBLEServer.h:66`, +`NimBLEServer.cpp:315-332`) and Bluefruit propagates the SoftDevice status +(`bluefruit.cpp:625-635` → `BLEConnection.cpp:206`). Contract: **true** = request +accepted *or* already disconnected (nothing to do), **false** = the stack refused +and the link is still up. On false the watchdog retries in ~1 s instead of logging a +disconnect that never happened and then sitting out another full window. + +- **nRF:** `Bluefruit.disconnect(s_connHandle)` — `bluefruit.h:171` → + `BLEConnection::disconnect()` → `sd_ble_gap_disconnect(hdl, + BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION)` (`BLEConnection.cpp:206`). + Guard on the file-static `s_connHandle` rather than `Bluefruit.connHandle()`: + the disconnect callback invalidates it ([ble_transport_nrf.cpp:134](../src/ble_transport_nrf.cpp#L134)), + so a stale handle already means "gone". No advertising calls here — + `restartOnDisconnect(true)` ([:210](../src/ble_transport_nrf.cpp#L210)) re-arms + the radio, which is what `restartsAdvertisingOnDisconnect()` reports. +- **ESP32:** `s_server->disconnect(s_connHandle)` — + `NimBLEServer.h:66`, default reason `BLE_ERR_REM_USER_CONN_TERM` (0x13), + the same "remote user terminated" code the nRF path sends. Null-check + `s_server` and `BLE_HS_CONN_HANDLE_NONE`. + +There is already one raw call of this shape — `enterDFUMode()` at +[device_control.cpp:855-859](../src/device_control.cpp#L855-L859) reaches past the +seam into Bluefruit. Deliberately **not** converted here: it first sets +`restartOnDisconnect(false)` and then tears the SoftDevice down, so it wants +different semantics. Noted as a follow-up, not scope. + +### 3b. `checkIdleSessionTimeout()` — the watchdog + +A file-static in [main.cpp](../src/main.cpp), placed with the other +loop()-serviced BLE helpers and called from `loop()` immediately after +`checkTransferTimeouts()` ([main.cpp:697](../src/main.cpp#L697)). Shape: + +```c +static void checkIdleSessionTimeout() { + if (OD_BLE_IDLE_SESSION_TIMEOUT_MS == 0) return; // compile-time disable + if (!ble.isConnected()) return; + // "queues empty" -- the second clause of the §2 definition, and NOT implied by + // the stamp: a frame queued and never drained leaves a stale stamp with work + // still outstanding. The notifyReady() qualifier is load-bearing -- see §3e. + if (bleRxQueuePending() || (bleTxQueuePending() && ble.notifyReady())) return; + + const uint32_t idleMs = millis() - bleLastRxMs(); + if (idleMs < OD_BLE_IDLE_SESSION_TIMEOUT_MS) return; + + static uint32_t nextAttemptMs = 0; + if (nextAttemptMs != 0 && (int32_t)(millis() - nextAttemptMs) < 0) return; + od_log_info("Idle BLE session %u ms (limit %u ms) - disconnecting client", + (unsigned)idleMs, (unsigned)OD_BLE_IDLE_SESSION_TIMEOUT_MS); + if (!ble.disconnect()) { + od_log_warn("Idle-session disconnect rejected by the stack - retrying in 1 s"); + nextAttemptMs = millis() + 1000u; + return; + } + nextAttemptMs = 0; + bleMarkRxActivity(); // arm a fresh window; the disconnect event lands shortly +} +``` + +**Why the stamp, and not a per-pass queue-head comparison** (which is what Draft 3 +used — see §3e for the full argument): by the time this runs at +[main.cpp:697](../src/main.cpp#L697), `serviceBleRx()` has drained every queued +command and flushed TX after each one ([:534](../src/main.cpp#L534)), and +`serviceBleTx()` has run again ([:671](../src/main.cpp#L671)). Both `*Pending()` +predicates are head≠tail tests ([command_queue.cpp:151-153](../src/command_queue.cpp#L151-L153), +[:186-188](../src/command_queue.cpp#L186-L188)), so on a pass that just serviced a +burst they read false. Anything that samples *state at a pass boundary* is therefore +blind to ordinary traffic; only observing the arrival and the notify themselves is +reliable. Draft 2 sampled residual queue depth and would have kicked working +clients; Draft 3's head-diffing fixed that but still reconstructed the event from +snapshots. This observes the event. + +Restamping on a successful kick gives the one-kick-per-window property, since +`Bluefruit.disconnect()` / `NimBLEServer::disconnect()` only *request* termination +and `isConnected()` can stay true for several passes. + +The stamp also makes the blocking dead zone a non-issue now that +its real bound is ~126 s rather than 60 s +([FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md §B1](FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md)): +the command that *triggered* the refresh was stamped on arrival, and its response +stamped again on notify, so the pass that resumes reads a fresh stamp regardless of +how long the dead zone lasted. The 120 s default therefore does not have to exceed +the blocking bound at all — see §3c — and §2 drops `epdRefreshInProgress` as +unobservable. + +Why that position in the pass: RX/TX have already been serviced and the +disconnect/advertising flags already consumed, so the queue tests read post-service +state and the kick cannot pre-empt a frame that arrived this pass. It is also +**before** `platformIdle()`, so on ESP32 the pass that kicks is followed by a pass +whose `pollActivity()` sees the connection-count edge, re-arms the full idle hold, +and then sleeps normally — no special-casing needed. + +**Not** added to `idleDelay()`'s early-return set: nothing here changes +asynchronously off the loop task, and the nRF park is 1000 ms +([main.h:335](../src/main.h#L335)), so a kick lands at most one park late. + +### 3c. The tunable + +`OD_BLE_IDLE_SESSION_TIMEOUT_MS`, `#ifndef`-guarded in +[main.h](../src/main.h) beside `OD_NRF_IDLE_WAIT_MS`, overridable per environment +from `platformio.ini`, `0` = disabled. + +**Default: 120000 (120 s).** Read it as the **lockout budget** — the longest a +single caller may keep every other host from reaching the device (§1) — bounded +below by what a legitimate client can need. With §3e's activity stamp the value no +longer has to cover the blocking-refresh dead zone at all (the triggering command +stamped on arrival, before the loop blocked), so the floor rests purely on host +behaviour: + +- **It must clear every host-side inter-command timeout, and now that is the whole + safety argument** — with transfer and refresh state out of the busy set (§2), + nothing else holds the stamp during device-side work. `TIMEOUT_ACK` 5 s, + `TIMEOUT_FIRST_CHUNK` 10 s (`py-opendisplay device.py:447-449`), + `TIMEOUT_PIPE_START` 30 s (`commands.py:92`), and the 90 s refresh/END-ack pair + (`device.py:458-460`) all sit inside 120 s. The 90 s pair is the binding one: a + host waiting out a slow refresh sends nothing, and only the queue-head advance + from its own END command keeps it safe. 120 s over a 90 s host timeout is 33% + margin — thin enough that this number should not be lowered without re-checking + those constants, and it is why `TIMEOUT_REFRESH` is worth watching if + `waitforrefresh()`'s real ~126 s bound is ever actually hit (§7.1). +- **The ceiling is the one worth arguing about, given the purpose.** 120 s is the + worst case a blocked second caller waits, and for HA that is one or two failed + `drawcustom` deliveries before the device reappears — recoverable, since the next + scheduled update succeeds. If field reports show real callers being starved, the + fix is to *lower* this (60 s is safe with §3e's stamp in place; the earlier + objection to 60 s was only ever about the dead zone), not to redesign. That + asymmetry — safe to lower, needs care to raise — is why the conservative value + ships as the default. +- 4× `OD_LAN_READ_TIMEOUT_S` (30 s). More than the other transport, deliberately: + BLE reconnect costs more — advertising + re-acquisition at a 160–1000 ms interval + ([ble_transport_nrf.cpp:48-49](../src/ble_transport_nrf.cpp#L48-L49)) plus a + fresh connection, versus a TCP handshake. +- The production consumers connect per-operation: the HA integration wraps each + delivery in `async with OpenDisplayDevice(...)` + (`custom_components/opendisplay/delivery.py:319-340`, `services.py:467-479`), the + `opendisplay` CLI has no long-lived subcommand, and `tools/od-device-cli.py` is + one-shot argparse with no REPL. +- **Correction (review):** Draft 2 generalised that into "no consumer holds a BLE + link idle", which is false. `py-opendisplay examples/animate.py` opens one + `OpenDisplayDevice` context and loops indefinitely + (`examples/animate.py:156-215`), sleeping `--interval` between frames with no + upper bound on that value (`:257-263`, default 1000 ms), and it pre-processes + every frame *inside* the open connection (`:156-168`). See §3c-note. + +**§3c-note — accepted breakage.** `animate.py --interval` above 120000, or a +pre-processing pass over a large frame set that takes longer than 120 s, will now +be disconnected mid-run, and the surrounding `async with` does not reconnect. This +is accepted rather than designed around: + +- At the default 1 s interval, and at any interval a person would use for an + animation, the head tracking in §3b stamps on every frame — the script is + unaffected. +- The pre-processing window is the sharper edge, since it happens after connect + with zero BLE traffic. It is bounded by host CPU, not by the device. +- The alternative — an application keepalive or lease opcode — is a protocol + change, which is out of proportion to one example script. + +Consequence for scope: the earlier "no cross-repo work" claim is now **"no +cross-repo work required to ship; one follow-up recommended"** — move +`animate.py`'s `prepare_image()` loop above the `async with`, and/or have it +reconnect on `BleakError`. Filed against `py-opendisplay`, not a blocker here. + +**Alternative considered and rejected for now:** driving it from a provisioned +config field (e.g. one of `PowerOption.reserved[4]`, +[opendisplay_structs.h:505](../include/opendisplay_structs.h#L505)). That is the +right long-term home if the value ever needs to differ per deployment, but it +costs a canonical `opendisplay-protocol` edit + `--push` to four firmware repos + +a `tools/od-device-cli.py` `BLOCKS` update, for a knob no field report has asked +to vary. Deferred, and cheap to add later precisely because the macro is the only +reader. + +### 3d. Chunked-config cleanup on disconnect (review amendment) + +A chunked config write keeps state across BLE commands in `chunkedWriteState` +([config_parser.h:33-47](../src/config_parser.h#L33-L47)), set active by +`CMD_CONFIG_WRITE` ([communication.cpp:405-438](../src/communication.cpp#L405-L438)) +and cleared only by the final chunk or an error +([:462-501](../src/communication.cpp#L462-L501)). `serviceBleDisconnectCleanup()` +does **not** reset it ([main.cpp:388-423](../src/main.cpp#L388-L423)), and +`handleWriteConfigChunk()`'s entry gate is `chunkedWriteState.active` alone with no +binding to the session that opened it ([:462-467](../src/communication.cpp#L462-L467)). + +So an abandoned config write leaves a live buffer that the *next* client can append +to. **This is pre-existing** — it is equally reachable from a peer-initiated +disconnect or a link-loss today, so the watchdog does not create it. But the +watchdog does create a new, automatic way to reach it, so this plan fixes it: + +```c +// in serviceBleDisconnectCleanup(), beside resetPipeWriteState() +chunkedWriteState.active = false; +chunkedWriteState.receivedSize = 0; +chunkedWriteState.receivedChunks = 0; +``` + +Deliberately **not** added to §3b's busy set, on review's own reasoning: an +abandoned chunked write would then suppress the watchdog forever, which is the +opposite of what it is for. The state is reset *by* the disconnect, not protected +*from* it. It sits behind the same `ownerStillUp` guard as the transfer cleanups, +so a LAN-owned session is unaffected. + +### 3e. The RX activity stamp — observing the receive callback directly + +One choke point, in `command_queue.cpp`, already shared by both targets: + +```c +// command_queue.cpp +static volatile uint32_t s_lastRxMs = 0; + +void bleMarkRxActivity(void) { s_lastRxMs = millis(); } +uint32_t bleLastRxMs(void) { return s_lastRxMs; } +``` + +- **RX:** `bleMarkRxActivity()` at the top of `bleRxQueuePush()` + ([:50](../src/command_queue.cpp#L50)), which both stack callbacks call and nothing + else does ([nrf:155](../src/ble_transport_nrf.cpp#L155), + [esp32:147](../src/ble_transport_esp32.cpp#L147)). +- **Connect:** in `serviceBleEvents()`'s existing `takeConnectedEvent()` branch + ([main.cpp:462-468](../src/main.cpp#L462-L468)), so the first window is measured + from the connection rather than from a previous session's last frame. + +**Why TX is not stamped.** Every response this firmware emits is produced inside a +command handler — `sendResponse()` has no caller outside the dispatch path, in +`communication.cpp`, `device_control.cpp`, `buzzer_control.cpp` or +`display_service.cpp`; there are no device-initiated notifications (button and touch +state reach the host through the advertisement, not GATT). So a TX stamp would +almost always be re-stamping microseconds after the RX stamp that caused it, and +"no response sent" is implied by "no command received". + +The exception is a **delayed** response: the post-refresh ACK/NACK at +[display_service.cpp:2489](../src/display_service.cpp#L2489) and +[:2494](../src/display_service.cpp#L2494) is sent when the refresh finishes, which +the ~126 s `waitforrefresh()` bound +([FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md §B1](FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md)) +allows to be long after the END command that triggered it. With RX-only stamping, a +refresh that outlasts the 120 s window means the client is kicked on the pass right +after it receives its ACK. + +**That is accepted, and on the stated purpose (§1) it is arguably correct.** The +host's own `TIMEOUT_REFRESH` is 90 s (`py-opendisplay device.py:460`), so a device +that took over 120 s has already blown the host's deadline — that operation is lost +either way, and the client is by then a stale holder of a device another caller is +locked out of. It reconnects if it is still interested. Normal refreshes (~16 s on +Spectra 6-colour) are nowhere near the bound; what changes is only that the window +is measured from the END command rather than from its ACK, i.e. the kick can come up +to one refresh-duration earlier than it otherwise would. + +**The unsubscribed-client hole** (found while checking this, and present in every +draft before it). `serviceBleTx()` holds responses queued when a client is connected +but has not enabled its CCCD ([command_queue.cpp:206-207](../src/command_queue.cpp#L206-L207)), +and `notifyReady()` is `connected && notifyEnabled()` +([ble_transport_nrf.cpp:241-243](../src/ble_transport_nrf.cpp#L241-L243), +[esp32:261](../src/ble_transport_esp32.cpp#L261)). So a client that writes one +command and never subscribes leaves `bleTxQueuePending()` true **forever** — an +unqualified "queues empty" clause would make exactly the squatter this feature +targets permanently immune to it. Hence the `&& ble.notifyReady()` qualifier in +§3b: a queued response counts as work in flight only when it can actually drain. + +**Why this beats the queue-head snapshot it replaces.** Three reasons, in order of +weight: + +1. **It observes the event, not a residue of it.** No dependency on where in the + loop pass the check sits, or on what has already drained. That coupling is + precisely what made Draft 2 wrong, and Draft 3's head-diffing only worked around. +2. **It catches traffic the head never records.** `bleRxQueuePush()` rejects empty, + oversized and ring-full frames — it owns every drop reason + ([nrf:151-155](../src/ble_transport_nrf.cpp#L151-L155)) — and a rejected frame + advances no head. Stamping *before* validation means a client hammering a full + ring reads as busy, which it is; the head comparison would have read it as idle + and kicked a client that was mid-burst. +3. **No aliasing and no shadow state.** The RX head is modulo `COMMAND_QUEUE_SIZE` + (34), so a full wrap inside one pass reads as "unchanged" — `pollActivity()` + documents that as an accepted risk + ([main.cpp:337-341](../src/main.cpp#L337-L341)). A monotonic timestamp has no + such failure mode, and drops the `prev*Head` statics. + +**Threading.** The store runs on the stack callback task (nRF SoftDevice event task, +NimBLE host task) — the only writer, now that TX is not stamped — and the load on +the loop task. `volatile uint32_t`, naturally +aligned, is a single load/store on both Cortex-M4 and the Xtensa/RISC-V ESP32 cores, +so there is no tearing and no lock is needed — and a plain flag store is exactly +what the copy-and-flag callback contract permits +([ble_transport_nrf.cpp:118-124](../src/ble_transport_nrf.cpp#L118-L124)). `millis()` +is callable from both contexts: each is a FreeRTOS task, not an ISR. The RX stamp +lands *before* `imageWriteLogQuietFrame()` and the rest of the push body, so it +costs one store on the hot path of a pipe burst. + +**Wrap:** `millis() - stamp` is unsigned subtraction, correct across the 49.7-day +rollover, matching every other `millis()` deadline in the firmware. The +`nextAttemptMs` retry uses a signed-difference compare for the same reason. + +--- + +## 4. Commits and total diff + +One code commit, plus this document. + +1. **`feat(ble): disconnect idle BLE sessions after OD_BLE_IDLE_SESSION_TIMEOUT_MS`** +2. **`docs(ble): idle-session watchdog plan`** — this file. + +Seven files: + +| File | Change | ~lines | +|---|---|---| +| [ble_transport.h](../src/ble_transport.h) | `bool disconnect();` + its contract comment | 8 | +| [ble_transport_nrf.cpp](../src/ble_transport_nrf.cpp) | `Bluefruit.disconnect(s_connHandle)` + handle guard, returns status | 9 | +| [ble_transport_esp32.cpp](../src/ble_transport_esp32.cpp) | `s_server->disconnect(s_connHandle)` + null/handle guards, returns status | 9 | +| [command_queue.h](../src/command_queue.h) | `bleMarkRxActivity()` / `bleLastRxMs()` declarations | 4 | +| [command_queue.cpp](../src/command_queue.cpp) | the stamp + one call site in `bleRxQueuePush()` (§3e) | 7 | +| [main.h](../src/main.h) | `OD_BLE_IDLE_SESSION_TIMEOUT_MS` `#ifndef` block | 6 | +| [main.cpp](../src/main.cpp) | `checkIdleSessionTimeout()` (§3b) + call site + connect-branch stamp + §3d reset | 26 | + +~69 inserted lines. Every hunk is an insertion except two one-line additions into +existing bodies (`bleRxQueuePush`, the connect branch) and the three-line §3d reset, +so nothing that avoids the new watchdog changes behaviour. `serviceBleTx()` is now +untouched — one fewer hot-path edit than Draft 4, since RX-only stamping removes the +per-notify store during a pipe burst. + +Draft 2 claimed ~46 lines by dropping activity tracking altogether; that saving was +not value, it was the defect. Draft 3 restored it as ~6 lines of queue-head diffing +in `main.cpp`; Draft 4 moves it to ~12 lines split across `command_queue.*`, buying +the three correctness properties in §3e for two files and ~8 lines. + +**Why the three transport files are not avoidable.** Calling +`Bluefruit.disconnect()` / `s_server->disconnect()` straight from `main.cpp` would +save two files, but `main.cpp` names no stack type today: keeping Bluefruit and +NimBLE types out of application code is the stated contract of +[ble_transport.h](../src/ble_transport.h) and of +[PLAN_BLE_TRANSPORT_ABSTRACTION_2026-07-27.md](PLAN_BLE_TRANSPORT_ABSTRACTION_2026-07-27.md). +It would also need a `#ifdef TARGET_NRF` / `#else` pair at the call site, so the +"saving" is ~4 lines of seam traded for ~6 lines of target guard plus a broken +invariant. + +--- + +## 5. Test plan + +Build: `pio run` (all eleven CI environments) — the change touches both transport +implementations and shared `main.cpp`, so a single-env build proves nothing. + +Bench, nRF (`nrf52840custom-debug`, RTT log): + +1. **Kick fires.** Connect with nRF Connect / `bleak`, subscribe, send nothing. + Expect the `Idle BLE session … disconnecting client` line at ~120 s, then + `=== BLE CLIENT DISCONNECTED (nRF) ===` with reason 0x16 (local host + terminated) on the peer side, `Disconnect reason:` logged by + `serviceBleEvents()`, and advertising visible again from a scanner within a + second or two. +2. **MSD unfreezes.** Confirm the post-disconnect `s_msdUpdatePending` republish + ([main.cpp:510](../src/main.cpp#L510)) lands — the advertisement's loop counter + advances again after the kick. +3. **A stalled upload IS kicked** (behaviour change from earlier drafts — §2). + Start a pipe upload, kill the host process mid-stream so the link stays up with + no further frames. Expect the kick ~120 s after the last frame, then the normal + deferred teardown: `resetPipeWriteState()` / + `cleanupDirectWriteState(true)` / `cleanupPartialWriteOnDisconnect()` via + `serviceBleDisconnectCleanup()`, and the panel rail released rather than held to + the 15-minute watchdog. Check the log shows cleanup *after* the disconnect + event, not inside the kick. + Then the inverse: a **healthy** upload of a large image over a slow link must + not be kicked — every accepted frame advances `rxHead` and every ACK advances + `txHead`, so a transfer that is progressing at all is never idle. +4. **No kick around a refresh.** Push a full-frame image to a Spectra panel + (~16 s blocking refresh) with the host idle before and after. Expect no kick + during the refresh, and the next kick **120 s after the END command arrived** — + i.e. ~104 s after the refresh returns, not 120 s after it. That is the documented + consequence of RX-only stamping (§3e), not a defect; what would be a defect is a + kick *during* the refresh or before the ACK reaches the host. +5. **The regression Draft 2 would have shipped** — the single most important test. + Connect and send one cheap command (e.g. `CMD_FIRMWARE_VERSION`) every 30 s for + 10 minutes, doing nothing else. Expect **zero** kicks. Draft 2's watchdog kicks + at ~120 s here, because each command drains inside its pass and leaves both + `*Pending()` predicates false. +6. **One kick per session**, not one per pass: exactly one log line per idle + session. Also check the `disconnect rejected by the stack` path is silent in + normal operation. +11. **§3e, the unsubscribed squatter — the case that was immune in every earlier + draft.** Connect *without* enabling notifications, write one command (so a + response is queued and cannot drain), then idle. Expect the kick at ~120 s. With + an unqualified `bleTxQueuePending()` term this client is never kicked at all, + which is the precise opposite of the purpose in §1. +10. **§3e, the case snapshots missed.** Saturate the RX ring (a burst deeper than + `COMMAND_QUEUE_SIZE`, or oversized frames) so `bleRxQueuePush()` starts + rejecting, and hold that for over 120 s. Expect **no** kick: rejected frames + still stamp. Draft 3's head comparison would have read this as idle — the heads + do not advance on a drop — and disconnected a client mid-burst. + +Bench, ESP32 battery target (`power_mode == 1`, `deep_sleep_time_seconds > 0`): + +7. **Deep sleep resumes, on the full timeline.** Connect, idle. Expect the kick at + ~120 s, then sleep after the *post-disconnect* quiet hold — the disconnect edge + re-stamps `lastActivityMs` via the connection-count change + ([main.cpp:342-368](../src/main.cpp#L342-L368)), so sleep is + `sleep_timeout_ms` (or the 10 s `DEFAULT_IDLE_HOLD_MS`, + [main.h:308](../src/main.h#L308)) later, and only once any `min_wake_time_seconds` + hold has expired ([main.cpp:308-320](../src/main.cpp#L308-L320)). Verify with a + non-default `sleep_timeout_ms` too, and record total awake time as + *120 s + hold*, not 120 s — that is the number the power budget needs. + +Negative tests: + +8. Build one env with `-DOD_BLE_IDLE_SESSION_TIMEOUT_MS=0`; confirm the watchdog + compiles out to a no-op and an idle session is never kicked. +9. **§3d:** start a chunked config write, send chunk 1 of 3, let the kick fire. + Reconnect and send a chunk — expect a NACK (state was reset), not silent + appending to the departed client's buffer. + +--- + +## 6. Residuals and known gaps (not fixed here) + +1. **The encryption session survives the kick — deliberately deferred, with the + reason stated.** Nothing clears it on BLE disconnect: + `clearEncryptionSession()` is called only on config reload + ([communication.cpp:66](../src/communication.cpp#L66)), re-auth, integrity + failure, and its own age timeout. That contradicts the wire documentation for + `session_timeout_seconds` — *"0 = no timeout (persists until disconnect)"* + ([opendisplay_structs.h:916](../include/opendisplay_structs.h#L916)) — and the + host clears its side on any disconnect (`py-opendisplay device.py:688-740`), so + after a kick the two disagree about session lifetime until the next + authentication replaces the state. + + Review argued this belongs in this change because the watchdog manufactures + disconnects. It is still deferred, for a reason the review itself identified: + `encryptionSession` is a **single global shared with the LAN transport** + (`wifi_service.cpp:798`, `:874` clear it), so clearing it on a BLE disconnect + needs a transport-ownership decision — the same class of decision that + `serviceBleDisconnectCleanup()`'s `ownerStillUp` guard exists to make for + transfers. That is a security-relevant change with its own test matrix and it + should not ride along inside a power/discoverability fix. It is a **named + follow-up, not an unknown**: clear the session when the disconnect event is + consumed *if* no LAN session is live. +2. **Reason code asymmetry.** nRF sends 0x13 via + `BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION`, ESP32 sends NimBLE's default + `BLE_ERR_REM_USER_CONN_TERM` (also 0x13). Same code, reached two different + ways; if a future NimBLE release changes its default this diverges silently. +3. **The event-flag coalescing weakness** documented at + [ble_transport.h:74-77](../src/ble_transport.h#L74-L77) applies to the + disconnect this raises exactly as it does to a peer-initiated one. Neither + worsened nor fixed. +4. **No host-visible warning before the kick.** The client learns only from the + disconnect. A pre-kick notification would need a protocol opcode; out of scope. +5. **`enterDFUMode()` still bypasses the transport seam** + ([device_control.cpp:857](../src/device_control.cpp#L857)) — see 3a. + +--- + +## 7. Review findings not adopted, and why + +From the adversarial review (Codex, 2026-07-29). Recorded so they are not +re-litigated, and so a reader can see what was checked rather than assumed. + +1. **"Fix `waitforrefresh()` to use a `millis()` deadline, and reconcile + py-opendisplay's 90 s refresh timeout with the real ~126 s bound."** Both are + real defects and both are already tracked elsewhere — + [FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md §B1](FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md) + notes an unmerged `waitForPanelIdle()` on `debug/freeze-fix-phase2`. Neither is a + prerequisite here: with §3b's head tracking the watchdog is insensitive to how + long the dead zone is, so this change neither depends on nor worsens them. +2. **"Verdict: redesign."** Downgraded to *implement with amendments*. Of the seven + findings, one was fatal to Draft 2's watchdog (adopted, §3b), one corrected a + load-bearing number (adopted, §3b/§3c), three are small additive amendments + (§3a `bool`, §3d chunked config, §5 test 7), one is an accepted breakage in an + example script (§3c-note), and one — encryption ownership — is a named follow-up + with a stated reason (§6.1). The transport seam, the watchdog's position in the + pass, and the busy set as amended all survived. That is a revision, not a + redesign. +3. **Claims the review checked and *confirmed*, so they stay as written:** calling + either stack's disconnect from the loop task is safe (link tuning already runs + there, [main.cpp:461-468](../src/main.cpp#L461-L468), and both disconnect + callbacks are copy-and-flag only); MSD publication is suppressed while connected + on both targets; ESP32 needs the application to restart advertising and nRF does + not; an idle connected client does prevent ESP32 deep sleep indefinitely; every + `waitforrefresh()` call site passes `60` (the error was reading that as seconds); + nothing clears the encryption session on BLE disconnect. +4. **"Is there a cheaper mechanism — advertise while connected, or lean on the GAP + supervision timeout, instead of disconnecting?"** Both were considered and + neither is available: + - *Advertising while connected* splits into two things. Keeping the + advertisement **fresh** (non-connectable) would unfreeze the MSD but does + nothing for the lockout in §1a — a second caller still cannot connect, which is + the actual purpose. Letting a second caller **connect** needs more than a + radio-config change: the transfer and session state is global and + single-client throughout — `pipeState` / `pipeReorder` + ([display_service.cpp:576-577](../src/display_service.cpp#L576-L577)), + `partialCtx`, `directWriteActive`, `chunkedWriteState` + ([config_parser.h:33-47](../src/config_parser.h#L33-L47)), one + `encryptionSession`, and a single `s_connHandle` per transport. Multi-link + support is a re-architecture, not an alternative to a 20-line watchdog. + - *Supervision timeout* is the wrong instrument: it detects a link that has + **failed**, not one that is alive and idle. A healthy peer answers every + connection event, so the supervision timer never expires no matter how long it + squats. Nothing in this firmware even sets connection parameters. +5. **Non-issues it ruled out for me:** NFC has no dispatcher entry in this firmware + (`communication.cpp:632-636`, falls through as unknown), and buzzer playback is + capped at 30 s (`buzzer_control.cpp:15-17`) — so neither can span the window with + the busy set false. Draft 2 had listed both as open questions. diff --git a/docs/PLAN_FREEZE_PROOFING_2026-07-26.md b/docs/PLAN_FREEZE_PROOFING_2026-07-26.md new file mode 100644 index 0000000..e04425c --- /dev/null +++ b/docs/PLAN_FREEZE_PROOFING_2026-07-26.md @@ -0,0 +1,296 @@ +# Freeze-Proofing the OpenDisplay Firmware (branch: debug/ble-hardening) + +> **Revised 2026-07-26** after adversarial review. The review found 4 Critical defects — two of +> which meant the headline deliverable did not work. All corrections are folded in below and +> tagged `[C1]`…`[X7]`. Full review: `docs/FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md` +> (**first action on implementation: move it there from +> `~/.claude/plans/create-a-comprehensive-plan-majestic-hamster-agent-a0d75f8717fc20eb0.md`** — +> plan mode blocked writing it into the repo). + +## Context + +Field failures: during PIPE_WRITE uploads from Home Assistant, lost ACKs / blind retransmits leave the device **frozen or unresponsive**. Investigation traced four wedge mechanisms plus several unbounded waits: + +1. **Nonce replay-window overrun** — the client burns a nonce per transmission (incl. retransmits); losing a full 32-frame window puts the next frame >32 ahead of `last_seen_counter`; rejections accumulate into `integrity_failures >= 3` → `clearEncryptionSession()` mid-transfer. The device then answers everything `0xFE` while `directWriteActive` keeps the panel powered 15 min. Bonus bug: `verifyNonceReplay` commits `last_seen_counter` **before** CCM tag verification ([encryption.cpp:149-155](../src/encryption.cpp)). + + > **Corrected 2026-07-26 after Phase 1 shipped.** Two claims in the sentence above were wrong and are struck: + > - **"3 rejections (= client's `MAX_PTO`)" is a false coincidence.** `MAX_PTO = 3` yields only *two* probe sends (the client increments and raises at the threshold before sending, `device.py:2721-2726`), and the client aborts the transfer on the **first** NACK, not the third (`device.py:834-838` raises `IntegrityCheckError`, uncaught by the pipe loop at `device.py:2714-2717`). Within one transfer `integrity_failures` plausibly reaches 1, not 3. Reaching 3 needs repeated attempts on the same session. + > - **The freeze actually reproduced on the bench was not a forward nonce gap at all.** It was a *session-identity divergence*: the client lost its session while the device still believed one was live, and py-opendisplay silently degraded to **unencrypted 230-byte `0x0071` chunks** (`device.py:1916-1929`, `:772-776`; `commands.py:70`). At 232 bytes those frames clear the firmware's short-frame gate and enter `decryptCommand`, where 8 bytes of image data are read as a session id → `NONCE_BAD_SESSION` → fatal NACK, forever. See `PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md` § "What actually happened on the bench". + > + > The nonce-gap defect class is real and Phase 1 fixed it. It is simply **not** the mechanism behind the observed field failure, and no baseline capture (Phase 1 Step 5 Test 0) has yet been taken to establish what is. +2. **Pipe fatal-NACK latch** — `sendPipeNack` leaves `pipeState.active=true` forever ([display_service.cpp:2562-2578](../src/display_service.cpp)); `transferActive()` latches → touch dead ([touch_input.cpp:584](../src/touch_input.cpp)), WiFi roam dead; the 15-min watchdog keys on `directWriteActive`, just cleared → nothing bounds it. +3. **Queue overflow** — response ring (10) drops newest on full ([communication.cpp:117-121](../src/communication.cpp)); command ring drops on full ([esp32_ble_callbacks.h:128](../src/esp32_ble_callbacks.h)). The **command** ring is never flushed on disconnect, so stale commands survive. *(The response ring IS drained whenever no central is connected — [main.cpp:307-312](../src/main.cpp) — so stale responses were never the wedge* `[L1]`*.)* +4. **Cross-transport session clobber** — LAN accept/close unconditionally `clearEncryptionSession()` ([wifi_service.cpp:879, :804](../src/wifi_service.cpp)), killing a live BLE session. BLE+LAN can be connected simultaneously today. +5. **Unbounded waits** — `pwrmgmLockTake` infinite spin ([display_service.cpp:401-408](../src/display_service.cpp)); `powerOff` stuck-button loop ([power_latch.cpp:87-90](../src/power_latch.cpp)); FastEPD refresh has no firmware-side bound. +6. **Session lifetime is unbounded and unscoped** — nothing clears `encryptionSession` on a BLE disconnect (only LAN does, [wifi_service.cpp:804](../src/wifi_service.cpp)), so the key and `last_seen_counter` survive into the next connection. The survival is unusable for resumption (a reconnecting client resets its counter to 0 → rejected as out-of-window or as a ring replay) and it enables a real attack: `verifyNonceReplay` exempts `counter_diff == 0` from the replay-ring check ([encryption.cpp:136](../src/encryption.cpp)), so a captured last-frame replayed after the owner disconnects is **accepted and re-executed**. Separately, `session_timeout_seconds` expiry is evaluated inside `isAuthenticated()` on every command ([encryption.cpp:195-199](../src/encryption.cpp)) and so can fire mid-transfer — a deterministic wedge on a long upload, since the client's proactive re-auth is deliberately skipped for the whole pipe stream ([device.py:778-786](../../py-opendisplay/src/opendisplay/device.py)). + +**User decisions (confirmed):** software-only supervisor (no hardware WDT — reset state, never reboot; a reboot wipes RTC incl. `displayed_etag` and forces a boot-screen redraw); clear encryption session on BLE disconnect; **encryption is always scoped to the life of the connection — `session_timeout_seconds` expiry is disabled**; BLE idle timeout = 5 minutes, kept firmware-local (not promoted to the canonical protocol header). + +**Platform facts (verified):** +- ESP32: commands queue via SPSC ring (NimBLE host task → loop task, [main.cpp:406-423](../src/main.cpp)); responses via a 10-slot ring. nRF: NO queues — `imageDataWritten` runs inline on the Bluefruit *Callback* task; `Bluefruit.begin(1,0)` already caps BLE at 1 link. +- `-DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=1` does NOT work: precompiled `sdkconfig.h:613` redefines it to 3 and wins (empirically verified — do not re-add). Enforce in `onConnect` (NimBLE fills `m_connectedPeers` BEFORE `onConnect`, so `getConnectedCount() > 1` is a valid gatecrasher test). +- OTA exception satisfied by design: ESP32 has no OTA (0x0051 = `esp_restart`); nRF DFU jumps to the bootloader with the app gone. Comment it for future OTA work. +- Drain-loop trap: [main.cpp:409-417](../src/main.cpp) caches `tail` before dispatch, stores `tail+1` after — a flush from handler context gets clobbered. +- `setConnectableMode(NON)` internally calls `setFlags(0)` (one-way), so re-push `setAdvertisementData(*advertisementData)` before `start()` — same trap already documented at [ble_init.cpp:307-312](../src/ble_init.cpp). + +--- + +## Hard constraint — NO wire protocol changes + +**Nothing in this plan may change the BLE/LAN wire protocol.** This is a firmware-internal robustness effort; every fix must be observably compatible with today's clients (`py-opendisplay`, the HA integration, the web configurator) and with the other three firmware repos. + +Concretely, the following are **out of bounds** for every phase: +- Editing `include/opendisplay_protocol.h` — it is a byte-for-byte vendored copy of `../opendisplay-protocol/src/opendisplay_protocol.h`. No local edit, and no change pushed through the canonical repo either. +- Adding, removing, or renumbering any `CMD_*` opcode or `RESP_*` code; changing the meaning of an existing one. +- Changing frame layout, framing, header/field sizes, nonce or CCM parameters, or the auth handshake sequence. +- Changing the config-packet layout in `include/opendisplay_structs.h` (field add/remove/resize/reorder), which is the same contract by another name. +- Changing any value the protocol header specifies — notably the **30 s auth-challenge window** (already recorded under *Deliberately NOT changed*) and the **LAN 30 s idle timeout** (`opendisplay_protocol.h:984`). +- Changing client-observable behaviour documented in `docs/pipe-write-protocol.md` (SACK semantics, discard rules, NACK meaning). + +What **is** in bounds, and why each stays inside the constraint: +- Firmware-local constants that no client reads: `OD_NONCE_WINDOW`, `OD_BLE_IDLE_DISCONNECT_MS`, the pipe error-release deadline, supervisor/backstop timeouts, `COMMAND_QUEUE_SIZE`. None appear on the wire; a client cannot observe their value, only the (already-legal) behaviour they produce. +- Widening the replay window and fixing the `counter_diff == 0` hole — accepting *more* legitimate frames and rejecting a replay are both already-permitted outcomes of the existing nonce rules. +- Disabling `session_timeout_seconds` expiry — the field's own spec already defines `0 = no timeout (persists until disconnect)` ([opendisplay_structs.h:916](../include/opendisplay_structs.h)); the firmware simply behaves as if the field is always 0. The struct field stays, unchanged in size and position, and becomes advisory-only. +- Dropping a link (idle timeout, supervisor abort, connection-exclusivity refusal) — disconnect is always a legal outcome; clients already handle it and reconnect. +- Sending an existing NACK/`RESP_*` code in a new situation, as long as the code's documented meaning is unchanged. + +If any phase appears to require a protocol change to work, **stop and escalate** — do not push a header change through `../opendisplay-protocol` as part of this work. Documentation-only additions (a note in `docs/pipe-write-protocol.md` §5.1, field notes in `tools/od-device-cli.py`) are permitted and expected, provided they describe behaviour the current spec already allows. + +Verification of the constraint itself, run before any phase is called done: +```bash +cd ../opendisplay-protocol && tools/sync_protocol_header.py --check --only Firmware # must pass, unchanged +cd ../Firmware && git diff main --stat -- include/opendisplay_protocol.h include/opendisplay_structs.h # must be empty +``` + +--- + +## Phase order + +Reordered per review: **root cause first, owner token before anything depends on it, supervisor before the escalations that rely on its accounting.** + +### Phase 1 — Nonce/replay correctness `(was Phase 3 — highest value-per-risk, ship first)` + +> ## ✅ SHIPPED 2026-07-26 — `0a60712`…`23ecaed` on `debug/freeze-fix-phase2` +> +> Ground truth, with `file:line` anchors, is the **"As-built"** section of +> [`PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md`](PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md). The bullets +> below are kept for provenance but **three of them describe a design that was superseded before +> implementation** — they are struck through and corrected inline. Read the Phase 1 plan, not this +> entry, before touching the code. +> +> **Verified:** 12/12 `pio run` environments build; host test `tools/test_nonce_window.cpp` passes +> 38,199 checks under UBSan/ASan; a separate `host-tests` CI job gates every push. +> **Not verified:** the *entire* hardware matrix, including the baseline capture (Test 0) and the +> test that decides whether an interrupted upload actually completes (Test 2b). + +[encryption.cpp](../src/encryption.cpp) / [encryption_state.h](../src/encryption_state.h) / **new** [nonce_window.h](../src/nonce_window.h) / [communication.cpp](../src/communication.cpp) +- Split `verifyNonceReplay` → pure `nonceCheck()` (OK / BAD_SESSION / OUT_OF_WINDOW / REPLAY, **no state writes**) + `nonceCommit(counter)` (advance `last_seen_counter` + seen-set). **Shipped as specified** — `verifyNonceReplay` deleted outright, `nonceCheck`/`nonceCommit` file-static, pure logic in a dependency-free `src/nonce_window.h`. +- `decryptCommand`: `nonceCheck` → nonce failures return false **without** touching `integrity_failures` (loss ≠ tampering; only a CCM tag failure is tamper evidence) → CCM decrypt → on success `nonceCommit` + reset counter; on tag failure increment (≥3 → clear session, unchanged). **Shipped as specified.** +- ~~`[M1]` Make the window **symmetric**: `OD_NONCE_WINDOW = ±128` with `replay_window[]` grown 64 → 256 (+1.5 KB `.bss`). If `esp32-N4` won't link, fall back to ±64.~~ **Superseded.** The value ring was replaced with an **RFC 4303 sliding bitmap**: `OD_NONCE_BACKWARD_BITS = 256` (`uint64_t[4]`, 32 B) and a separate `OD_NONCE_FORWARD_CAP = 128`. Net struct change is **−480 B**, not +1.5 KB, so **the `esp32-N4` link-headroom gate is moot** — it links at 81,468 B, *below* the pre-Phase-1 figure. `[M1]`'s jam-forward concern was withdrawn: it cannot occur once commit happens after CCM verification. +- ~~Move `replay_window_index` out of the function static into `encryptionSession` so `clearEncryptionSession()` resets it.~~ **Superseded — the field no longer exists.** A bitmap has no insertion index, so the bug is structurally impossible rather than fixed. +- ~~**Close the `counter_diff == 0` replay hole** … replace the special case with an explicit `has_seen_counter` bool (or a sentinel initial value).~~ **Superseded — no `has_seen_counter` was needed.** Under the bitmap, "not seen" is a clear bit rather than a reserved sentinel, so a fresh session (`last_seen = 0`, all-zero bitmap) accepts counter 0 exactly once with no exemption. The `!= 0` term is simply gone. *(Note: this hole is also wider than this bullet says — the old accept path wrote the ring unconditionally, so replaying the highest-seen frame 64× flushed every genuine entry and re-opened the whole backward window. See `[H3]` in the Phase 1 plan.)* +- Log: distinguish `nonce out-of-window` from `CCM tag failure %u/3`. **Shipped, plus more than specified:** both nonce logs demoted to WARN and given **independent** 5 s rate-limit budgets, and the session-id-mismatch line no longer dumps two full session IDs. +- **Added, not in this entry:** *Step 4b* — a nonce-rejected `CMD_PIPE_WRITE_DATA` (`0x0081`) frame is now answered with **silence** instead of a fatal `RESP_NACK`, so the client's SACK path can repair it. This is conformance to `docs/pipe-write-protocol.md` §5.2 ("NACKs are reserved for unrecoverable conditions … not ordinary packet loss"), not a wire change. **It is also the highest-value change in Phase 1 and the least verified** — see Test 2b. +- **Added after implementation, in response to a live hardware failure** (`55a2478`, `77ebdcd`, `23ecaed`): a session-id mismatch now answers `RESP_AUTH_REQUIRED` rather than a fatal NACK, and **the BLE link is dropped after 10 consecutive `0xFE` answers**. The link drop is Phase 5 work pulled forward — see the Phase 5 entry below. +- **Hard-constraint check: passes.** `git diff 02bdd5c..HEAD -- include/` is empty; no opcode or response code was added; both `RESP_AUTH_REQUIRED` and `RESP_NACK` are used in their documented meanings. + +### Phase 2 — Bound the refresh waits `(was Phase 0; scope cut 2026-07-26)` + +> **Scope cut — Phase 2 is now three items, not seven.** Current plan: +> [`PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md`](PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md). +> The earlier [`PLAN_PHASE2_BOUND_WAITS_2026-07-26.md`](PLAN_PHASE2_BOUND_WAITS_2026-07-26.md) is +> **obsolete** — kept only for the analysis behind the cut items. +> +> **In scope:** `[X2]` `epdRefreshInProgress` on both boot paths · `[X3]` a real +> `fastepd_wait_refresh` · a real wall-clock `waitforrefresh` deadline (P2-8, added by the Phase 2 +> plan and *not* in the original list below). Two files — `src/display_service.cpp`, +> `src/display_fastepd.cpp`. No new file, no `src/main.cpp` change, no `platformio.ini` change. +> +> **Dropped, with the residual each leaves open:** +> +> | Dropped | Residual now carried | +> |---|---| +> | `[C2]` `pwrmgmLockTake` deadline | The spin stays **unbounded on both targets**. A holder that never releases blocks its waiter forever. No `panelStateUnknown` flag is produced, so **Phase 3's `abortToKnownState` has nothing to report** — drop that from its remit. | +> | `powerOff` stuck-button bound | ESP32-only; needs a hardware fault; removes a recovery path rather than creating a freeze. | +> | Loop-drain 2 s cap | Withdrawn as unsound, not merely descoped — see below. | +> | `[L3]` inert TWDT flag | The dead `=120` knob stays in 9 ESP envs, still implying a watchdog that does not exist. | +> | Loop-liveness monitor (P2-9) | **A stalled `loop()` is now undetected on both targets.** ESP32's TWDT will not fire (every long wait yields, so IDLE0 is never starved) and nRF has no watchdog at all. | +> +> **Consequence for Phase 6.** Phase 2 was to be the "defensive floor"; it now delivers *bounded +> refreshes* only, not *detected stalls*. Everything in the table above lands on the supervisor — +> and on nRF, where none of the ESP32 wall-clock watchdogs run, there is nothing between a stall and +> Phase 6. Weigh that when sequencing Phase 6, which already had to be extended to nRF. +> +> **The loop-drain cap is withdrawn on the merits, not descoped.** The parent premise here — "a full +> window of commands can hold `loop()` for minutes" — does not survive checking: 32 pipe DATA frames +> cost 0.1–1 s total, the genuinely long case is a single END triggering a 30–60 s refresh (which a +> between-commands check cannot interrupt), and stacked refreshes are unreachable because a second +> `0x0072` short-circuits at [display_service.cpp:2366](../src/display_service.cpp). Do not +> re-propose it; if a saturation signal is wanted it belongs to Phase 7's `[H1]`. + +**Original item list, retained for the record:** + +- `[C2]` **`pwrmgmLockTake` — do NOT steal.** Legitimate holds already exceed 10 s: `bbepWaitBusy` caps at **30 000 ms** for 3/4/7-colour panels (`bb_ep.inl:3959-3975`) and `epdSessionForceOffLocked` holds the lock across `bbepSleep` → `bbepWaitBusy` (`bb_ep.inl:4122`). A steal on a bare 0/1 flag with no owner means two tasks drive the same SPI/CS, the true holder's later `Give` unlocks it under the stealer (mutual exclusion permanently dead), and `pwrmgmState` ends up `PWR_ACTIVE` on a dead rail. **Instead:** `pwrmgmLockTake` returns `bool` with a **60 s** deadline (≥2× worst-case busy wait); on expiry log ERROR, return false, caller skips its panel work and sets a "panel state unknown" flag that `abortToKnownState` reports. If a forced take is ever genuinely needed, add `volatile TaskHandle_t pwrmgmOwner` so the original holder's `Give` becomes a detectable no-op. +- `powerOff` button wait ([power_latch.cpp:87-90](../src/power_latch.cpp)): bound at 10 s, then drop the latch anyway. +- `[X2]` Set/clear `epdRefreshInProgress` around **both boot-refresh paths** (`refreshBootScreenFull` [display_service.cpp:533-542](../src/display_service.cpp) and the FastEPD boot path at `:1588-1594`). Today a 30–60 s Spectra boot refresh is invisible to every `epdRefreshInProgress` gate — including the supervisor's "never interrupt a refresh" rule. +- `[X3]` **FastEPD really is unbounded.** `fastepd_wait_refresh()` is a stub that ignores its timeout ([display_fastepd.cpp:228-231](../src/display_fastepd.cpp)) and `waitforrefresh(60)` short-circuits to it, so the "60 s cap" does not exist on IT8951/E1004. Implement `fastepd_wait_refresh` as a real busy poll against the IT8951 LUT-busy register honouring `timeout_sec`, and wrap **`fastepd_direct_refresh`** (the path a real transfer takes, [display_service.cpp:2422-2423](../src/display_service.cpp)) — not just `fastepd_full_update`. +- `[X1]` **I2C — DOWNGRADED after verification; the original finding was wrong.** The review claimed a wedged GT911 spins unbounded and that touch polls too rarely to notice. Neither holds: + - The driver **already gives up**: 5 consecutive read failures (`TOUCH_I2C_FAIL_DISABLE_THRESHOLD`, [touch_input.cpp:39](../src/touch_input.cpp)) trigger `touch_disable_controller(..., "too many I2C read failures")` ([:642](../src/touch_input.cpp), [:677](../src/touch_input.cpp)), and `TOUCH_I2C_FAIL_BACKOFF_MS` suppresses INT-driven re-reads while failing ([:609-611](../src/touch_input.cpp)). A wedged controller is dropped, not retried forever. + - The poll rate is fine — the floor is 100 ms (`TOUCH_PROCESS_MIN_INTERVAL_MS`, [:38](../src/touch_input.cpp)), enforced globally at [:589](../src/touch_input.cpp) **regardless of the configured `poll_interval_ms`**, whose per-controller value at [:600](../src/touch_input.cpp) can only ever slow polling further. (Note: [opendisplay_structs.h](../include/opendisplay_structs.h) documents `0 = 25 ms default`; the firmware uses 100 and cannot go below it. Header/behaviour divergence, documentation-only, not fixed here.) + - **So: no nine-clock SDA recovery, no new state machine.** The only residual is that each failing transaction blocks for the Arduino default (~50 ms on ESP32) since `Wire.setTimeOut()` is never called — worst case ~250 ms of blocked `loop()` before the controller is disabled. Bounded and acceptable. **Optional**: add `Wire.setTimeOut(25)` after each `Wire.begin()` (incl. `wireBeginForOpenDisplay`, [display_service.cpp:785-800](../src/display_service.cpp)) to halve that window. A wedged GT911 costs touch until reboot; it cannot freeze the device. +- Loop command drain: 2 s wall-clock cap alongside the count cap. +- `[L3]` Delete or rename the inert `-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` in every ESP env — the IDF 5.x symbol is `CONFIG_ESP_TASK_WDT_TIMEOUT_S` and the precompiled `sdkconfig.h` wins regardless. Leaving a dead knob that reads like a 120 s guarantee misleads the next reader. Add a comment that the real TWDT is 5 s/panic on IDLE0 and that today's long waits survive only because they all yield. + +### Phase 3 — `abortToKnownState()` + queue flushes + drain-trap fix `(was Phase 1)` +New `src/session_guard.h/.cpp` (both targets; ESP32 parts `#ifdef TARGET_ESP32`, LAN parts `#ifdef OPENDISPLAY_HAS_WIFI` — **not** `TARGET_ESP32`, since `esp32-N4` is ESP32 without WiFi). +- Flags: `commandDrainAbortPending`, `commandQueueOverflowAbort`, `responseQueueOverflowAbort`; `g_lastProgressMs` + `markSessionProgress()`. +- `flushCommandQueue()` / `flushResponseQueue()` in main.cpp; loop-task only; `tail := head` snapshot (SPSC-safe — tail has a single writer). +- `abortToKnownState(reason, dropLink)`: log first → optional client NACK (skip when dropping link) → set drain-abort flag → flush command ring → `cleanupDirectWriteState(true)` → `cleanupPartialWriteOnDisconnect()` → `resetPipeWriteState()` → **new** `resetChunkedWriteState()` → **new** `touchForceResume()` → buzzer/LED stop → `epdSessionForceOff()` **only if** `!epdRefreshInProgress` → `clearEncryptionSession()` → flush response ring → if dropLink: disconnect → release owner token → `markSessionProgress()`. +- `[M3]` `touchForceResume()` must also clear `directWriteTouchSuspended` ([display_service.cpp:2035-2038](../src/display_service.cpp)) and assert the counter reached 0. Keep the stated ordering (`cleanupDirectWriteState` first). +- `[M5]` **Drain-trap fix — exact placement**: the check goes **between** [main.cpp:415](../src/main.cpp) and `:416`, i.e. immediately after `imageDataWritten` returns and *before* `commandQueue[tail].pending = false`, breaking without the tail store. Placed after `:416` it writes into a slot the producer may have re-filled. While there, delete the vestigial `pending` field — it has no readers in either ring. +- `[H4]` `g_commandInFlight` is a `volatile uint8_t` **depth counter**, not a bool. Bluefruit's `ada_callback_invoke` falls back to invoking the write callback **inline on the BLE task** when `rtos_malloc` fails (`BLECharacteristic.cpp:538-542`), so "single task on nRF" is not an invariant — heap pressure during a large transfer is exactly when it breaks. + +### Phase 4 — Connection exclusivity `(was Phase 4, corrected)` +- Owner token: `OWNER_NONE/BLE/LAN`, `linkClaim()`/`linkRelease()`. +- `[C3]` **Refuse with `BLE_ERR_REM_USER_CONN_TERM` (0x13), NOT `BLE_ERR_CONN_LIMIT` (0x09).** `NimBLEServer::disconnect` forwards to `ble_gap_terminate` (`NimBLEServer.cpp:321-332`) and 0x09 is not in the Core Spec's legal `HCI_Disconnect` reason allowlist — the controller rejects it with 0x12 and the gatecrasher stays connected while the code looks like it worked. Check the `bool` return; log WARN on failure. +- `[C4]` **Do not let the refusal re-enter the shared cleanup.** `onDisconnect` ([esp32_ble_callbacks.h:57-70](../src/esp32_ble_callbacks.h)) is a blind flag-setter, and the `ownerStillUp` guard is inside `#ifdef OPENDISPLAY_HAS_WIFI` ([main.cpp:328-338](../src/main.cpp)) — so on **`esp32-N4`** a refused stranger's disconnect tears down the incumbent's live transfer (a new remote DoS). Two required changes: (a) capture the refused conn handle in `onConnect` and skip raising `bleDisconnectCleanupPending` for it in `onDisconnect`; (b) move the `ownerStillUp` early-return **out** of the `#ifdef` — its `getConnectedCount() > 0` half is unconditionally correct. +- `[M2]` Prefer **evicting an idle incumbent** over refusing a reconnect: after an abrupt client loss the link lingers until supervision timeout (4–32 s) and a returning client would be refused. If the incumbent has `!transferActive()` and last RX older than ~10 s, terminate the old link and accept the new one. Refuse only when the incumbent is actively transferring. +- LAN accept ([wifi_service.cpp:874-900](../src/wifi_service.cpp)): `linkClaim(OWNER_LAN)` or `incoming.stop()`; scope both `clearEncryptionSession()` sites to `OWNER_LAN`. +- Advertising while LAN owns: `esp32_set_ble_connectable(bool)` — stop → `setConnectableMode(NON/UND)` → re-push `setAdvertisementData` → start. Skip during the post-deep-sleep-wake window. `[M4]` **Check the return of both `setAdvertisementData()` and `start()`**; on failure force connectable mode, re-push, retry, and set `bleRestartAdvertisingPending` — otherwise a failed `start()` after a `stop()` leaves the radio permanently dark with nothing retrying. +- `[X7]` Add `advertisingHealthTick()`: no peer + not advertising (`getAdvertising()->isAdvertising()`) + no pending flag for >30 s → force restart, log WARN. Closes the "unresponsive but not frozen" hole at [ble_init.cpp:232-235](../src/ble_init.cpp), where a stale nonzero connection count *clears* the pending flag. + +### Phase 5 — Disconnect hardening `(was Phase 2, corrected — now lands after the owner token)` +- `[H2]` ESP32 `serviceBleDisconnectCleanup`: place `flushCommandQueue(); flushResponseQueue(); clearEncryptionSession();` **after** the (now unconditional) `ownerStillUp` early-return, scoped to `linkOwner() == OWNER_BLE`. Putting the clear before the guard would let every WiFi-lost tick ([main.cpp:452-457](../src/main.cpp)) destroy a live BLE session — the very bug Phase 4 exists to fix, from a second code path. +- `[H4]` nRF `disconnect_callback`: **defer** the session clear — set `nrfSessionClearPending`, service from `loop()` when the in-flight depth counter is 0. A `memset(session_key)` landing mid-`aes_ccm_decrypt` on the inline-fallback path is a real (if rare) race. +- Pipe NACK latch: add `error_since_ms` to `PipeWriteState` (genuine +4 B addition — the struct has no timestamp today) and a `pipeErrorTick()` in loop. `[L2]` Use **10 s**, not 60 s, and describe it honestly as a **hardware-release deadline** — py-opendisplay treats every `0x81` NACK as immediately fatal and never re-reads the ACK position, so the "client-retry window" rationale was fiction. Confirmed this does **not** break `docs/pipe-write-protocol.md` §5.1 (client-observable discard behaviour is unchanged) — add a line to §5.1 noting the reset. + +#### Session lifetime := connection lifetime + +**Disable `session_timeout_seconds` expiry.** `checkEncryptionSessionTimeout()` ([encryption.cpp:221-232](../src/encryption.cpp)) always returns true for an authenticated session; age-based expiry is removed. Encryption is scoped to the life of the connection and nothing else. + +- **No protocol change and no header edit.** The canonical field already documents `0 = no timeout (persists until disconnect)` ([opendisplay_structs.h:916](../include/opendisplay_structs.h)) — the firmware now behaves as if the field is always 0, which is an already-specified, already-supported value. The struct field stays (removing it is a cross-repo change); it simply becomes advisory-only on this firmware. Document it as ignored in `tools/od-device-cli.py`'s field notes. +- **No client breakage.** py-opendisplay's `_reauthenticate_if_needed` returns immediately when the value is 0 ([device.py:795-796](../../py-opendisplay/src/opendisplay/device.py)); with a legacy nonzero config it performs one unnecessary but harmless re-auth at 90% — that path is only reached from `_write`, never from `_write_pipe_frame`, so it cannot land mid-stream. Provision new units with 0 to skip the pointless handshake. +- **This removes a whole freeze class.** Expiry was evaluated inside `isAuthenticated()` on *every* command dispatch, so it could fire mid-transfer — deterministically wedging any upload longer than the configured timeout, since the client's proactive re-auth is skipped for the entire pipe stream. It also removes a query-with-side-effects: `isAuthenticated()` currently mutates session state as a side effect of being asked a question. +- With expiry gone, call site 1 of `clearEncryptionSession()` disappears, and site 2 (`handleAuthenticate`'s re-auth path, [encryption.cpp:583-585](../src/encryption.cpp)) simplifies to "authenticated → clear and re-challenge", which is the correct behaviour for a client-initiated re-auth. + +**Make a dead session with a live link impossible.** Add the guard inside `clearEncryptionSession()` itself ([encryption.cpp:238](../src/encryption.cpp)) rather than at each call site, so future callers cannot regress it: if a client is connected and the clear was not client-initiated, raise a flag that `loop()` services by dropping the link. A cleared session under a live link is invisible to the client — it keeps sending encrypted frames that all bounce `0xFE` and never re-authenticates mid-stream — so this is the difference between a recoverable error and a wedge. Surviving call sites and their disposition: + +> ### ⚠ Re-scoped: **Phase 1 already shipped a partial version of this guard** (`77ebdcd`, `23ecaed`) +> +> Phase 1's Scope boundaries said *"Do not add link-drop behaviour to `clearEncryptionSession()`. +> That guard is Phase 5."* A live bench failure forced the behaviour in early anyway, from the +> other end: `rejectUnauthenticated()` ([communication.cpp:114-166](../src/communication.cpp)) +> counts **consecutive `RESP_AUTH_REQUIRED` answers** and drops the BLE link at 10, serviced by +> `serviceBleAuthAbuseDisconnect()` ([:168-201](../src/communication.cpp)) from `loop()` on ESP32 +> and **inline** on nRF (where `loop()` runs at `TASK_PRIO_LOW` and is starved by the +> `TASK_PRIO_NORMAL` callback task during a flood). The counter is cleared by a successful decrypt +> ([:908](../src/communication.cpp)) and by a successful authentication +> ([encryption.cpp:691](../src/encryption.cpp)). +> +> **This does not complete Phase 5's guard — do not delete this section, and do not duplicate it +> either.** The shipped version keys on the *symptom* and only after ten wasted round trips; Phase 5 +> keys on the *event* and drops immediately. Phase 5 must therefore: +> +> 1. **Subsume, not duplicate.** Land the guard inside `clearEncryptionSession()` as specified, then +> reduce the Phase 1 counter to a backstop for the cases the clear-site guard cannot see +> (a client that never had a session at all, and the `NONCE_BAD_SESSION` desync path). Do **not** +> leave two independent disconnect requests racing each other. +> 2. **Fix the three defects the Phase 1 guard shipped with**, all recorded in +> [`PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md`](PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md) § `77ebdcd`: +> (a) the count is **not cleared on disconnect**, so a new client can inherit its predecessor's +> rejections — a one-line `resetAuthGateRejects()` in `disconnect_callback` +> ([device_control.cpp:227](../src/device_control.cpp)) and in `onDisconnect` +> ([esp32_ble_callbacks.h:57](../src/esp32_ble_callbacks.h)); (b) on ESP32 the guard identifies +> the offender as `getPeerInfo(0)` rather than the actual sender, because the command ring +> discards the conn handle — **fold this into Phase 4**, which is already widening connection +> identity; (c) the threshold of 10 is **below** py-opendisplay's default 16-frame pipe window +> (`device.py:2689-2694`), so a *legitimate* client whose session dies mid-upload trips it. That +> is probably the right outcome, but Phase 5 must decide it deliberately rather than inherit it. +> 3. **`[H4]` is discharged for the drop path only.** Verified against the Adafruit core: the +> Bluefruit disconnect callback is queued via `ada_callback` onto the *same* FreeRTOS task as the +> write callback, and `sd_ble_gap_disconnect()` is asynchronous — so an inline +> `Bluefruit.disconnect()` cannot unwind into the callback it is called from. `[H4]`'s actual +> hazard (a `memset(session_key)` landing mid-`aes_ccm_decrypt`) is untouched and still applies +> to the **session clear** this section specifies, which nRF's `disconnect_callback` does not do +> today. + +| Site | Disposition | +|---|---| +| [encryption.cpp:585](../src/encryption.cpp) re-auth challenge | **Exempt** — client-initiated, expects a new session | +| [encryption.cpp:670](../src/encryption.cpp) `aes_cmac` failure | Exempt — aborts a session being born | +| ~~[encryption.cpp:695](../src/encryption.cpp) /~~ [:794-798](../src/encryption.cpp) `integrity_failures >= 3` | Must drop the link. **Phase 1 removed the nonce trigger as planned** — there is now exactly **one** call site, the CCM-tag arm; the pre-decrypt site at old `:695` no longer exists. | +| [communication.cpp:204](../src/communication.cpp) `reloadConfigAfterSave` | Must drop the link — a config write always arrives over a live link, and security settings may have changed. *(Shifted down ~136 lines by Phase 1's auth-guard block.)* | +| [wifi_service.cpp:804](../src/wifi_service.cpp) / [:879](../src/wifi_service.cpp) LAN | Scope to `OWNER_LAN` (Phase 4) | +| [config_parser.cpp:883](../src/config_parser.cpp) boot load | Exempt — not connected | + +**Note in code that deep sleep already wipes the session.** `encryptionSession` is plain `.bss` ([main.h:289](../src/main.h)), not `RTC_DATA_ATTR`, so a deep-sleep cycle destroys it regardless. Comment this so nobody later "optimises" it into RTC memory — persisting the key and `last_seen_counter` across sleeps would reintroduce the replay vector Phase 1 closes. + +### Phase 6 — The 10-minute supervisor `(was Phase 6, corrected)` + +> ⚠️ **Phase 6 inherited work from the Phase 2 scope cut (2026-07-26).** Phase 2 no longer bounds +> `pwrmgmLockTake`, no longer bounds `powerOff`'s stuck-button wait, and — most significantly — no +> longer detects a stalled `loop()` on either target (the loop-liveness monitor was dropped). ESP32's +> TWDT will not cover the gap: every long wait yields, so IDLE0 is never starved and it does not +> fire. nRF has no watchdog at all and none of the ESP32 wall-clock watchdogs run there. +> +> So the supervisor is now the **first and only** thing that notices a stall, not the last line of a +> layered defence. Two consequences for this phase: its nRF arm moves from "must not be forgotten" +> to load-bearing, and a panel-lock holder that never releases is a fault class it must survive +> without any upstream bound or signal. + +- `[C1]` **Progress means the state machine advanced — never "a command arrived" or "a notify succeeded."** After `clearEncryptionSession()`, [communication.cpp:664-670](../src/communication.cpp) answers every retry with `RESP_AUTH_REQUIRED` — a dispatch *and* a notify per retry — so dispatch/notify stamps keep `g_lastProgressMs` fresh forever and the supervisor never fires in the exact wedge it was built for. Stamp **only** at: `pipeState.expected_seq` advancing (inside the in-order accept), `directWriteBytesWritten` increasing, `chunkedWriteState.receivedChunks` incrementing, `partialCtx` byte counter advancing, refresh completion, and `handleAuthenticate` success. **Not** on command dispatch, notify, or LAN frame dispatch. +- Wedge: `(transferActive() || chunkedWriteState.active || directWriteActive) && now - g_lastProgressMs > 600000` → if `epdRefreshInProgress`, log and retry next pass; else `abortToKnownState("supervisor", true)`. +- `[H3]` **Keep the existing wall-clock watchdogs** ([main.cpp:436-442](../src/main.cpp), `checkPartialWriteTimeout`) as a backstop, raised to 20 min. They key on *start* stamps nothing refreshes, so they bound cases a progress predicate can't. Delete them only after hardware soak proves the progress arm fires. +- **nRF has NO transfer watchdog today — H3 is "keep" on ESP32 but "ADD" on nRF.** Both 900 s bounds live inside the `#ifdef TARGET_ESP32` arm of `loop()`: the direct-write check at [main.cpp:438](../src/main.cpp) and the `checkPartialWriteTimeout()` call beside it. The nRF `loop()` body is the `#else` arm and evaluates neither, so on nRF a stalled transfer is bounded by **nothing** — not today, and not by H3's "backstop" unless it is explicitly added there. Neither the original plan nor the adversarial review caught this. The supervisor and the wall-clock backstop must both be wired into the nRF `loop()` path, and the nRF hardware soak must cover a stalled transfer explicitly rather than assuming ESP32 parity. +- `[X5]` Add `transferActive()` to the `workInFlight` disjunction ([main.cpp:474-479](../src/main.cpp)) so a latched transfer with a dropped link can't reach `enterDeepSleep()` with the panel rail up. +- `[H4]` Abort only when the in-flight depth counter is 0. If stale >10 min while in-flight, log an ERROR heartbeat — Phase 2's bounds are the recovery story. +- No hardware WDT (user decision). Comment the OTA-exception rationale. +- `[X4]` Config chunked-write has no timer of its own ([communication.cpp:496](../src/communication.cpp) set, cleared only on completion/malformed/auth-fail) — it is covered by the supervisor predicate *once C1 is fixed*. Dependency noted deliberately. + +### Phase 7 — Queue-full handling + BLE idle timeout `(was Phase 5, corrected — must land after Phase 6)` +- `[H1]` **Command-ring overflow logs and drops; it does NOT drop the link.** The pipe protocol is designed for a dropped frame (zero bit in the next SACK → client retransmits that chunk, `docs/pipe-write-protocol.md` §5.2) — one round trip vs. a link drop + re-auth + restarted transfer. Escalate to `abortToKnownState` only if overflow recurs while `transferActive()` **and** `g_lastProgressMs` is already stale, i.e. let the supervisor own the decision. Also fix the off-by-one in the [main.h:365-370](../src/main.h) comment: usable capacity is `COMMAND_QUEUE_SIZE - 1 = 32`, not 33 (producer refuses at `nextHead == tail`), so the documented "W=32 window + END" claim is false — bump `COMMAND_QUEUE_SIZE` to 34 on envs with DRAM to spare (**not** `esp32-N4`). +- Response-ring overflow: flag, serviced in loop, gated on `transferActive()`. +- **BLE idle disconnect.** `OD_BLE_IDLE_DISCONNECT_MS = 300000` (5 min; 0 disables). Gate on peer connected && `!epdRefreshInProgress`, then drop the link. + + **Definition of activity (authoritative):** *a chunk arriving in the command queue carrying a **valid command**, or a **continuation of a data upload**.* Nothing else stamps `g_lastLinkActivityMs`. + + `[C1]` "Valid" is the load-bearing word and it is what makes this timer resistant to the defect that killed the original RX-keyed design. Validity is not knowable at queue-insert time — `onWrite` runs on the NimBLE host task before decryption — so the stamp goes in `imageDataWritten` **after** the decrypt/auth gate passes ([communication.cpp:663-711](../src/communication.cpp)) **and** the opcode resolves to a known command (`commandName(command) != nullptr`, i.e. not the `default:` unknown-opcode branch). A post-`clearEncryptionSession()` retry flood therefore never stamps: every frame short-circuits to `RESP_AUTH_REQUIRED` at [communication.cpp:664-670](../src/communication.cpp) before reaching the stamp, and the link is dropped at 5 min. + + "Continuation of a data upload" means a `0x0071`/`0x0081` frame that was **accepted** (consumed in order, or queued in the reorder window) — **not** one silently discarded because `pipeState.error` is latched ([display_service.cpp:2811](../src/display_service.cpp)). Counting discarded frames would let a client retransmitting into a dead pipe hold the link forever, which is the same defect one layer down. + + **Relationship to the supervisor's `g_lastProgressMs` (Phase 6):** two distinct signals, deliberately. Idle activity answers *"is the client still talking sense?"*; supervisor progress answers *"is the state machine advancing?"* They compose: a client politely polling battery status every minute is active (valid commands) and correctly not dropped, while making no transfer progress — and the supervisor doesn't fire either, because its wedge predicate requires `transferActive()`. A client flooding undecryptable frames trips the idle timer at 5 min; a client sending valid frames into a stalled transfer trips the supervisor at 10 min. + + **LAN keeps its 30 s ([opendisplay_protocol.h:984](../include/opendisplay_protocol.h)); the asymmetry is a deliberate design choice, not an oversight.** LAN is a machine-to-machine push transport: a client connects, pushes, and closes, so 30 s of silence means it is gone or broken — drop it fast and free the socket. **Only BLE carries interactive sessions**, where a human using the web configurator or the HA UI legitimately pauses between commands (reading config, composing a change, waiting on a slow refresh). A 30 s BLE timeout would break interactive use; 5 min tolerates human latency while still bounding the hostage window. BLE reconnect is also far more expensive than a TCP reconnect — advertising, connection setup, re-auth, and possibly a deep-sleep wake. + + **The constant stays firmware-local.** Mirroring LAN by promoting it to the canonical header would be a cross-repo change through `../opendisplay-protocol` plus a `--push` to all four firmware repos; not warranted until the behaviour is proven on hardware. Revisit only if a client ever needs to read the value. + + **Interaction with deep sleep is cooperative, not adversarial.** `pollActivity()` refreshes `lastActivityMs` every pass while a client is connected ([main.cpp:243-258](../src/main.cpp): *"A live link … is activity in itself"*), so today a silent client pins the device out of deep sleep indefinitely — the hostage case. Dropping the link makes `connCount` fall to 0, `lastActivityMs` stops being refreshed, and the existing `sleep_timeout_ms` hold elapses normally. No change to the sleep gates is required. + +--- + +## Files touched +`src/session_guard.h/.cpp` (new), `src/main.cpp`/`main.h`, `src/encryption.cpp`/`encryption_state.h`, `src/display_service.cpp`, `src/wifi_service.cpp`, `src/esp32_ble_callbacks.h`, `src/ble_init.cpp`, `src/device_control.cpp`, `src/power_latch.cpp`, `src/display_fastepd.cpp`, `src/touch_input.cpp`, `src/communication.cpp`, `src/structs.h`, `platformio.ini`, `docs/pipe-write-protocol.md`. + +## Verification +- Session lifetime: verify a transfer longer than any legacy `session_timeout_seconds` completes untouched; verify a client-initiated re-auth mid-connection still works; verify a captured last-frame replayed after reconnect is now REJECTED (the `counter_diff == 0` hole). +- Per phase: `pio run -e nrf52840custom -e esp32-s3-N16R8 -e esp32-c3-N16 -e esp32-c6-N4 -e esp32-N4`. CI builds all **12** on push (the matrix grew; several places in these plans still say 11). ~~**`esp32-N4` is the gate** for Phase 1's `replay_window[256]` (+1.5 KB `.bss`) — if it won't link, drop to ±64.~~ **Moot as of Phase 1:** the sliding bitmap made the struct **480 B smaller**, and `esp32-N4` links at 81,468 B / 24.9% RAM — below where it started. A `host-tests` CI job now also compiles and runs `tools/test_nonce_window.cpp` under UBSan/ASan on every push. +- Hardware (py-opendisplay CLI): forward-gap nonce test (skip 100 counters) → session survives; true replay → rejected, session survives; kill client mid-pipe-window → reconnect, re-auth, clean push; forced pipe NACK → touch recovers ≤10 s; second BLE central → **verify on-air with a sniffer or `nRF Connect` that the refusal actually terminates** (this is the C3 trap); LAN connect during BLE session → accept-then-close; BLE connect while LAN owns → refused, telemetry still advertising; silent client 5+ min → dropped, advertising resumes, deep sleep reachable; stalled pipe with live connection **that keeps sending doomed frames** → supervisor still cleans at 10 min (this is the C1 regression test); regression: full Spectra transfer (60 s+ refresh) and an E1004 ~960 KB upload complete untouched. +- Every recovery action logs one ERROR/WARN line with reason + counters. +- Final soak: 24 h cron'd pushes alternating BLE/LAN with ~10% induced client kills. + +## Deliberately NOT changed + +Recorded so implementation does not "fix" these and review does not re-litigate them. Both were surfaced by `TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md`; both are decided. + +- **The 30 s auth-challenge validity window stays exactly as-is.** Step 2 of `CMD_AUTHENTICATE` must arrive within 30 s of the challenge — stamped at [encryption.cpp:587-588](../src/encryption.cpp), enforced at [:604-608](../src/encryption.cpp). This is a **cross-repo wire contract**, not a firmware-local knob: [opendisplay_protocol.h:384](../include/opendisplay_protocol.h) specifies *"STEP 2 must arrive within 30 s of the challenge or it is rejected"* with `@targets: Firmware | NRF54 | Silabs | NRF52811`. Changing it would require an edit in `../opendisplay-protocol` plus a `--push` to four firmware repos. It is also **not** a liveness timer and cannot wedge anything — a late step 2 gets `AUTH_STATUS_ERROR` and the client restarts the handshake. Phase 1 restructures `encryption.cpp` but **must not touch this check**, including the known boot-window quirk (`server_nonce_time = 0` from `clearEncryptionSession` means a step 2 with no preceding step 1 passes the freshness test during the first 30 s of uptime; not exploitable, since the MAC still requires the master key). Correct Phase 5's "scoped to the connection **and nothing else**" phrasing to acknowledge this second, independent encryption time bound — wording only, no code. + +- **No new bounding timer for the buzzer or the LED.** `abortToKnownState` stops both as part of teardown (Phase 3) — that is a teardown action, not a timer, and it stays. Nothing further is added. For the record, and correcting review finding `[X6]` which lumped them together: + - **Buzzer is already bounded** — `kBuzzerMaxTotalMs = 30000` ([buzzer_control.cpp:17](../src/buzzer_control.cpp), enforced at `:168`). No gap. *(The source comment there claims a "5 s cap" against a 30 s constant; comment-only defect, left alone.)* + - **LED flash is genuinely unbounded** — `processLedFlash` has no global cap equivalent. **Accepted as-is.** A stuck LED sequence wastes power but cannot freeze the device: it holds no lock, blocks no task, and is cleared by `abortToKnownState`, by disconnect teardown, and by an explicit `0x0075` LED_STOP. + +## Explicit non-goals / residual risk +- A true CPU/peripheral hard hang remains detect-and-log only — accepted with the software-only decision. **But "recoverable by power cycle" is weaker than it sounds on latching devices.** The button *press* is captured by an ISR ([device_control.cpp:679-693](../src/device_control.cpp)), but the hold-duration evaluation and the power-off action run in `processButtonEvents()`, which is called **only** from `loop()`/`idleDelay()` ([main.cpp:481](../src/main.cpp), [:514](../src/main.cpp), [:527](../src/main.cpp), [:542](../src/main.cpp)) — and the power-off hold test itself is at [device_control.cpp:78](../src/device_control.cpp). So while `loop()` is blocked (e.g. inside a 60 s `waitforrefresh`, whose `delay(10)` yields to FreeRTOS but never services buttons), a long-press does not trigger power-off. On a `DEVICE_FLAG_BATTERY_LATCH` unit with no way to interrupt the rail, the user's fallback is unavailable for the duration of the block. This does not change the software-only decision, but it means the residual risk is "wait out the block or remove the battery", not "hold the button". +- No config-schema changes (timeouts are compile-time constants in v1). +- The client-side nonce burn (py-opendisplay) is untouched; firmware-side widening makes it safe. +- ~~`[M1]` A local attacker with a captured frame can still jam `last_seen_counter` forward within the window and stall a session; the supervisor is the recovery path. Symmetric windowing keeps the stall bounded by the ring.~~ **Withdrawn — this risk does not survive Phase 1.** With commit-after-verify, only a CCM-authenticated frame advances `last_seen_counter`, so an attacker can only re-commit counters the client genuinely transmitted and can never push past the client's own high-water mark. Repairs then carry fresh, higher counters, so no future client frame falls below the window. The jam was an artifact of the value-ring design. +- **Residual risk that replaces it:** a forward gap beyond `OD_NONCE_FORWARD_CAP = 128` is still reachable on a pathological link with `blocks_per_ack = 1` and a multi-thousand-chunk upload. Step 4b makes it non-fatal (silent drop → SACK repair) rather than impossible — **and that repair path has never been observed on hardware.** diff --git a/docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md b/docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md new file mode 100644 index 0000000..f06ecff --- /dev/null +++ b/docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md @@ -0,0 +1,1083 @@ +# Phase 1 Implementation Plan — Nonce / Replay Correctness + +**Branch:** `debug/ble-hardening` · **Date:** 2026-07-26 +**Parent plan:** [`PLAN_FREEZE_PROOFING_2026-07-26.md`](PLAN_FREEZE_PROOFING_2026-07-26.md) § "Phase 1" +**Review that shaped it:** [`FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md`](FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md) `[M1]` + +> **Revised 2026-07-26 after adversarial review** — +> [`FINDINGS_PHASE1_PLAN_REVIEW_2026-07-26.md`](FINDINGS_PHASE1_PLAN_REVIEW_2026-07-26.md) +> (1 Critical, 3 High, 3 Medium, 7 Low; verdict: safe to ship alone, but not as written). +> +> **Applied here:** `C1` — forward cap 64 → **128**, and the "hard bound" derivation replaced +> (Decision A); `M2` — the jam-forward DoS argument withdrawn, since it cannot occur once D2 is +> fixed; `H1` — **new Step 4b**, stop answering a nonce-rejected pipe frame with a fatal NACK. +> Bitmap widened to `uint64_t[4]` so the cap stays inside the window. `M1` — Step 2 now +> specifies **unsigned wrapping deltas**, resolving both the plan's internal contradiction and +> the UB on attacker-controlled counters. +> +> `H2` recorded under Decision E (shipping defect, cross-repo wire fix — no Phase 1 code); +> `H3` folded into the D3 row and a new hardware test 4b; `M3` `resetNonceState()` now names all +> four fields; `L1`-`L7` corrected in place. +> +> **All 14 findings are now addressed.** The only ones carrying no code change are `H2` +> (recorded, deferred to a protocol revision) and `L1` (D4 demoted to latent — the fix stays). + +> ## ⚠ THIS PLAN HAS SHIPPED — READ ["As-built"](#as-built-what-actually-shipped) FIRST +> +> Everything above and below this banner is the plan **as written before implementation**. The +> code landed on `debug/freeze-fix-phase2` (commits `0a60712`…`23ecaed`) and diverges from the +> plan in three places, one of which is a deliberate scope expansion into Phase 5. The +> [As-built section](#as-built-what-actually-shipped) at the end of this file is the ground +> truth, with `file:line` anchors into the real code. +> +> **Nothing in Step 5's hardware list has been run.** Test 0 (the baseline that settles the D1 +> mechanism caveat) and Test 2b (whether Step 4b's silent drop actually lets py-opendisplay's +> SACK path repair and complete an upload) are both still open. See +> ["Unverified on hardware"](#unverified-on-hardware--the-honest-list). + +Phase 1 is the root-cause fix and ships first. It is self-contained: it has no dependency on any +later phase and delivers field benefit on its own — unlike Phase 3, which is dead code until +Phase 5/6 call it. Scope is the encryption layer (`encryption.cpp`, `encryption_state.h`, a new +`nonce_window.h`) plus a small, deliberate change in `communication.cpp` (Step 4b); the full list +is in "Files touched". + +--- + +## What is actually wrong today + +Four distinct defects live in `verifyNonceReplay()` ([encryption.cpp:114-156](../src/encryption.cpp)) +and its one caller `decryptCommand()` ([:688-735](../src/encryption.cpp)): + +| # | Defect | Evidence | Consequence | +|---|---|---|---| +| **D1** | Nonce rejection is counted as **tamper evidence** | [:691-696](../src/encryption.cpp) — `verifyNonceReplay` false → `integrity_failures++` → 3 ⇒ `clearEncryptionSession()` | Packet loss ≠ attack. A lost window puts the next frame out of range, and each such frame counts toward session destruction; at 3 the session is destroyed mid-transfer and everything then answers `0xFE`. **See the mechanism caveat below — how the count reaches 3 is not what this plan originally claimed.** | +| **D2** | State is committed **before** the CCM tag is verified | `last_seen_counter` at [:149-151](../src/encryption.cpp), ring write at [:153](../src/encryption.cpp), all *before* `aes_ccm_decrypt` at [:714](../src/encryption.cpp) | An unauthenticated attacker (or corrupt frame) advances the replay state of a live session. Forged counter `last_seen + 32` sticks even though the frame is discarded. | +| **D3** | `counter_diff == 0` is exempted from the replay-set check | [:136](../src/encryption.cpp) `nonce_counter <= last_seen && counter_diff != 0` | **Replay of the highest-seen frame is accepted and re-executed** — the tag is valid because the frame is genuine. Harmless for a pipe DATA frame (duplicate seq is discarded) but not for `CMD_CONFIG_WRITE`, `CMD_POWER_OFF`, or a buzzer/LED command, which is typically what the last frame of a session is. The `!= 0` term exists only so a fresh session's first frame (client counter 0 vs `last_seen_counter` initialised to 0 at [:211](../src/encryption.cpp)) isn't flagged. **`[H3]` — worse than one replayed command:** the accept path writes the ring unconditionally ([:152-154](../src/encryption.cpp)), *including* on this exempted re-accept, while `last_seen_counter` never moves (`:149` is `>`-conditional). So replaying the highest-seen frame **64 times flushes every genuine entry out of the ring**, after which the whole `[L−32, L]` backward window is replayable — the last 32 genuine commands, not just the last one. Reachable on ESP32: `CONFIG_BT_NIMBLE_MAX_CONNECTIONS` is really 3 (`sdkconfig.h:613`), the write callback does not discriminate connection handles, and `isAuthenticated()` ([:195-199](../src/encryption.cpp)) is a global flag with no peer binding, so a *second* central can feed captured frames into a live session. nRF is capped at one link by `Bluefruit.begin(1, 0)`. | +| **D4** | `replay_window_index` is a **function static** | [:152](../src/encryption.cpp) | `clearEncryptionSession()` memsets the ring ([:217](../src/encryption.cpp)) but cannot reset the index. **`[L1]` — latent, not live:** both reset sites zero the *whole* ring, and writing from an arbitrary offset into a uniformly-empty 64-slot ring with a +1 index gives the same strict-FIFO eviction order as starting from 0, so no counter's accept/reject decision differs today. It becomes a real bug the moment the ring stops being uniformly reset. Fix it anyway — the bitmap removes the field entirely. | + +### ⚠ Mechanism caveat on D1 — verify on hardware before trusting the narrative + +Earlier versions of this plan (and the parent plan's context section) asserted that a lost window +produces *"exactly 3 rejections, because the client's `MAX_PTO` is 3"*. **That coincidence is not +real, and the chain it describes may not be how the field failure actually happens.** Two +corrections compound: + +1. **`MAX_PTO = 3` yields only two probe sends** (`[L4]`; `device.py:2721-2726` increments and + raises at the threshold *before* sending). Two rejections do not reach a threshold of 3. +2. **The client aborts on the *first* rejection, not the third** (`[H1]`). The `RESP_NACK` for + rejection #1 raises `IntegrityCheckError` (`device.py:833-838`), uncaught by the pipe loop — + so the transfer is already dead and the client sends nothing further on that path. + +So within a single transfer, `integrity_failures` plausibly reaches **1**, not 3. Reaching 3 +requires *repeated* attempts on the same session — an HA retry of the whole upload, or unrelated +commands, each rejected because the device's `last_seen_counter` is stranded far below the +client's. That is plausible but **unverified**. + +**Why this matters, and why it does not weaken Phase 1:** + +- **It re-weights the fix.** If the client aborts at rejection #1, the observed field symptom — + latched `pipeState.active`, dead touch, powered panel — needs no session destruction at all; + the abort alone leaves the device latched. That makes **Step 4b (`[H1]`) potentially the + highest-value change in Phase 1**, not a refinement of it, and it reinforces that the latch + itself is only cleared by Phase 3's `abortToKnownState()`. +- **D1 is still a genuine defect and still worth fixing first.** Counting packet loss as tamper + evidence is wrong on its own terms, and it is what turns a recoverable transfer failure into a + device that answers `0xFE` to everything until reconnect. The fix does not depend on how the + count reaches 3. + +**Action:** Step 5 test 1-2 must **capture the baseline on unmodified firmware first** — log the +actual `integrity_failures` trajectory and whether the session is cleared during a real +window-loss event — before asserting the before/after story anywhere. If the session is never +actually cleared in the field, say so and re-rank the phases accordingly rather than defending +this plan's original framing. + +Plus the structural issue `[M1]`: the window is **±32 symmetric today** +([:131](../src/encryption.cpp)) and one replayed frame can jam `last_seen_counter` forward, +stranding every legitimate frame between the old and new positions. How wide the forward side +should be is Decision A; how the seen-set is represented is Decision B. **Both are now +resolved below** — D3 and D4 disappear entirely under the chosen representation rather than +being patched. + +--- + +## Design: split check from commit + +``` +decryptCommand(...) + ├─ isAuthenticated() unchanged (Phase 5 removes the timeout side-effect) + ├─ NonceResult r = nonceCheck(nonce) PURE — no writes to encryptionSession + │ ├─ OK → continue + │ ├─ BAD_SESSION → return false, integrity_failures UNTOUCHED + │ ├─ OUT_OF_WINDOW → return false, integrity_failures UNTOUCHED ← D1 fix + │ └─ REPLAY → return false, integrity_failures UNTOUCHED + ├─ aes_ccm_decrypt(...) the ONLY tamper oracle + │ ├─ tag fail → integrity_failures++ ; >=3 ⇒ clearEncryptionSession() (unchanged) + │ └─ tag OK → nonceCommit(counter) ← D2 fix: commit AFTER authentication + └─ integrity_failures = 0 ; updateEncryptionSessionActivity() ; return true +``` + +The rule in one line: **only a CCM tag failure is evidence of tampering; a nonce failure is +evidence of a lossy link.** Nonce failures drop the frame and keep the session. + +--- + +## Steps + +### Step 1 — `encryption_state.h`: replace the value ring with a sliding bitmap + +```c +// Anti-replay: bit i == "counter (last_seen_counter - i) has been consumed". +// Bit 0 is last_seen_counter itself. Backward window is implicitly +// OD_NONCE_BACKWARD_BITS - 1; there is no separate window constant to keep in +// step, and no index to reset. +#define OD_NONCE_BACKWARD_BITS 256 // uint64_t[4], 32 B +#define OD_NONCE_FORWARD_CAP 128 // see Decision A + uint64_t replay_bitmap[OD_NONCE_BACKWARD_BITS / 64]; +``` + +- **Delete** `uint64_t replay_window[64]` (512 B) and the function-static + `replay_window_index` ([:152](../src/encryption.cpp)). Net struct change: **−480 B**. +- **Why the backward width is 256, not 128:** keeping `OD_NONCE_FORWARD_CAP < + OD_NONCE_BACKWARD_BITS` means a legal forward slide can never exceed the bitmap width, so the + wholesale-clear branch in Step 3 is unreachable on any legitimate input. At width 128 with a + 128 cap the two are equal and a maximal slide clears the whole bitmap — still *correct* (every + discarded counter is then out-of-window and rejected on width), but it puts the hardest branch + on the normal path for the sake of 16 bytes. Keep the margin. +- **No `replay_window_index` field** — a bitmap has no insertion point, so **D4 cannot recur**. +- **No `has_seen_counter` field** — "not seen" is a clear bit, not a reserved value, so **D3 + cannot recur**. A fresh session is `last_seen_counter = 0` with an all-zero bitmap; the + first frame at counter 0 has `fwd == 0`, finds bit 0 clear, and is accepted exactly once. +- `clearEncryptionSession()` ([:201-219](../src/encryption.cpp)) and the fresh-session block in + `handleAuthenticate` ([:654-660](../src/encryption.cpp)) must **both** reset the nonce state. + Fold that into one `resetNonceState()` helper so a third caller cannot drift — but **`[M3]` + define it by naming all four fields**, because the two blocks share only these four and are + *opposite* on everything else (`authenticated` false vs true, timestamps zeroed vs stamped, + keys wiped vs populated): + + ```c + static void resetNonceState(void) { + encryptionSession.nonce_counter = 0; /* device's OWN outbound counter */ + encryptionSession.last_seen_counter = 0; + encryptionSession.integrity_failures = 0; + memset(encryptionSession.replay_bitmap, 0, sizeof(encryptionSession.replay_bitmap)); + } + ``` + + A helper described loosely as "reset the bitmap and `last_seen_counter`" invites someone + tidying the surrounding lines to drop `nonce_counter = 0`. That would leave the device's + **outbound** counter running across a re-auth while the client restarts at 0 — walking + straight into the keystream reuse described under Decision E `[H2]`, against itself. + +### Step 2 — `encryption.cpp`: `nonceCheck()`, pure +```c +/* enum lives in src/nonce_window.h — the TYPE is shared (Step 4b needs it in + encryption.h to carry a reason out of decryptCommand); the FUNCTIONS stay + file-static (Decision C). Sharing a type grants no ability to commit state. */ +enum NonceResult { NONCE_OK, NONCE_BAD_SESSION, NONCE_OUT_OF_WINDOW, NONCE_REPLAY }; + +static NonceResult nonceCheck(const uint8_t* nonce, uint64_t* counter_out); /* encryption.cpp */ +``` +- Keep the existing `constantTimeCompare` session-id check ([:122](../src/encryption.cpp)) → + `NONCE_BAD_SESSION`. +- Then, **unsigned arithmetic only** (`[M1]` — see below for why this is not a style choice): + +```c +const uint64_t fwd = counter - last_seen; /* wraps; 0 when equal */ +const uint64_t back = last_seen - counter; /* wraps; fwd + back == 0 mod 2^64 */ + +if (fwd == 0) return bit_test(bm, 0) ? NONCE_REPLAY : NONCE_OK; +if (fwd <= OD_NONCE_FORWARD_CAP) return NONCE_OK; /* ahead: cannot have been seen */ +if (back < OD_NONCE_BACKWARD_BITS) return bit_test(bm, back) ? NONCE_REPLAY : NONCE_OK; +return NONCE_OUT_OF_WINDOW; +``` + + The `fwd == 0` case is where **D3 closes**: no `!= 0` exemption, the bit is simply tested like + any other. + +- **The four tests are ordered, and the order is load-bearing.** `fwd` and `back` sum to zero + mod 2^64, so they cannot both be small: with the current constants `fwd <= 128 && back < 256` + would need `fwd + back <= 384 ≡ 0 (mod 2^64)`, true only when both are zero — the case already + consumed by the first test. A counter far from the window in either direction leaves both huge + and falls through to `NONCE_OUT_OF_WINDOW`. Do not reorder. + +- **Why unsigned, not `int64_t` deltas.** The 8 counter bytes are parsed off the wire at + [:119-121](../src/encryption.cpp) and reach this function *before* `aes_ccm_decrypt` + ([:714](../src/encryption.cpp)), so an **unauthenticated attacker controls both operands**. + Converting a `uint64_t >= 2^63` to `int64_t` is implementation-defined before C++20, the + subtraction can overflow outright (`counter = 2^63`, `last_seen = 1` → `INT64_MIN - 1`), and + negating `INT64_MIN` is UB as well — so a table keyed on `diff`/`-diff` has three separate + ways to be undefined on attacker-chosen input. Unsigned overflow is defined as modular + arithmetic, making the expression total over all 2^64 inputs with no range precondition. This + is the standard formulation in IPsec/DTLS implementations. Today's [:130](../src/encryption.cpp) + uses the signed form; it is one of the things being replaced, not preserved. + *(Practical note: on Xtensa and ARM the signed form almost certainly compiles to the wrapping + behaviour anyway — no known live miscompilation. It is worth fixing because the correct form + is simpler than the buggy one, the input is attacker-controlled in a security check, and + Decision D's `-fsanitize=undefined` gate would otherwise fail against the plan's own code.)* + +- **No writes to `encryptionSession` on any path.** This is the property Step 5 tests. + +### Step 3 — `encryption.cpp`: `nonceCommit(uint64_t counter)` + +Same `fwd`/`back` unsigned deltas as Step 2 — do not reintroduce a signed `diff` here. + +- **Forward** (`fwd != 0`, i.e. `counter > last_seen_counter` in window terms): shift the bitmap + left by `fwd`, clearing the vacated low bits; `last_seen_counter = counter`; set bit 0. +- **Backward/equal**: set bit `back`. `last_seen_counter` does not move. +- **Keep a `fwd >= OD_NONCE_BACKWARD_BITS` guard that zeroes the bitmap wholesale, but know that + it is unreachable in practice.** `nonceCheck` rejects anything with `fwd > OD_NONCE_FORWARD_CAP` + (128) before commit is ever called, and the bitmap is 256 wide — that margin is deliberate + (Step 1). The guard exists so the function is total if called directly (the host test does + exactly that) and so a future cap increase cannot silently produce an over-wide shift. It is + still *correct* when it does fire: every counter it discards is then ≥256 behind and gets + rejected on width. +- Shifting across a `uint64_t[4]` must handle `shift == 0` and `shift >= 64` explicitly — + `x << 64` is undefined behaviour in C, and it is the classic bug in this pattern. +- Step 5's host test drives `fwd` = 0, 1, 63, 64, 65, 127, 128 (**the reachable range**, capped by + `OD_NONCE_FORWARD_CAP`) and additionally 129, 191, 192, 255, 256, 257 **directly against + `nonce_window.h`** to exercise the guard and the word-boundary shifts that the reachable range + alone would leave untested. +- Called from exactly one place: after a successful `aes_ccm_decrypt`. + +### Step 4 — `decryptCommand()` rewiring +- Replace the `verifyNonceReplay` block ([:691-698](../src/encryption.cpp)) with the `nonceCheck` + call and its result handling from Step 2; **delete** the `integrity_failures++` on that path. +- Insert `nonceCommit()` as the **first statement of the `if (success)` arm**, i.e. at + [:718](../src/encryption.cpp) — `[L2]` **not** merely "before `integrity_failures = 0`". + There is an early `return false` in between, for a decrypted-but-malformed `payload_length` + ([:719-722](../src/encryption.cpp)). That frame is *authentic* — it passed the CCM tag — and + today's unconditional commit at `:149-153` does record it. Placing the commit after the early + return would leave an authentic frame replayable, i.e. a silent behaviour change dressed as a + refactor. +- Leave the tag-failure arm ([:729-733](../src/encryption.cpp)) exactly as-is. +- Logging: `nonce out-of-window (counter=%llu last_seen=%llu fwd=%llu) — frame dropped, session + kept` at WARN vs. `CCM tag failure %u/3` at ERROR. The current out-of-window log is + `od_log_error` ([:132](../src/encryption.cpp)) and will now fire routinely on a lossy link — + demote it or it becomes noise that masks real errors. **`[L7]`** applies the same treatment to + the session-id mismatch log at [:123-127](../src/encryption.cpp), which prints two full session + IDs at ERROR: once nonce failures stop counting toward `integrity_failures`, nothing rate-limits + an attacker driving that line. Demote and/or rate-limit it. +- **`[L7]` — state the `NONCE_BAD_SESSION` policy change explicitly.** Today a session-id + mismatch *does* count as tamper evidence ([:122-128](../src/encryption.cpp) → `:691-696`); + routing it to "`integrity_failures` untouched" alongside the loss cases is a deliberate + decision, not a consequence of D1. It is the right call — a mismatched session id is what a + stale client sends after the device re-authenticated, i.e. usually confusion rather than + attack, and the CCM tag remains the tamper oracle — but it must be written down rather than + arrived at silently. +- **Delete `verifyNonceReplay()`** and both of its declarations (Decision C: the body at + [:114-156](../src/encryption.cpp), [encryption.h:17](../src/encryption.h), + [main.h:276](../src/main.h)). The build is the check here — the compiler will name any caller + we missed. + +### Step 4b — Stop answering a dropped pipe frame with a fatal NACK `[H1]` + +**Without this, Phase 1 saves the device but still loses the transfer.** Today every +`decryptCommand` failure — nonce *and* tag alike — produces the same unencrypted 3-byte +`RESP_NACK` ([communication.cpp:698-703](../src/communication.cpp)), and the client turns that +shape into a fatal exception before it ever reaches pipe-frame classification: + +``` +device.py:833-838 if len(raw) == 3 and raw[2] == 0xFF: raise IntegrityCheckError(...) +device.py:2716 the pipe send loop's ONLY except is BLETimeoutError +``` + +So one out-of-window frame aborts the whole upload, no matter how wide the cap is. The client +cannot repair the hole, because the frame it would have repaired is the one whose NACK killed +the transfer. + +**Change:** + +1. Give `decryptCommand` a reason out-param (or an enum return) so the caller can distinguish + nonce rejection from tag failure. It has exactly one caller, so this is mechanical — but it + touches the declarations in [encryption.h:21](../src/encryption.h) and + [main.h:274](../src/main.h) as well. +2. At [communication.cpp:698-703](../src/communication.cpp): when the reason is + `NONCE_OUT_OF_WINDOW` or `NONCE_REPLAY` **and** the opcode is `CMD_PIPE_WRITE_DATA` + (`0x0081`), **send nothing at all**. Every other combination keeps today's `RESP_NACK`. + +**Why silence is the correct answer and not a hack.** A pipe DATA frame is not +request/response — the client never blocks on a per-frame reply; it blocks on sliding-window +ACK reads. Dropping the frame silently is therefore *exactly* the signal "this frame was lost", +which is the one condition the pipe protocol is built to repair: the seq is absent from the next +SACK mask, the client retransmits it, and the transfer continues. Answering instead with a fatal +NACK converts recoverable loss into an aborted upload. + +**This is a conformance fix, not a protocol change.** `docs/pipe-write-protocol.md` §5.2 already +specifies the rule: + +> *"NACKs are reserved for unrecoverable conditions (bad payload, protocol violation), **not +> ordinary packet loss**."* + +A frame rejected because its nonce fell outside the window **is** ordinary packet loss — it is +the direct consequence of frames having been dropped. Today's firmware answers it with a fatal +`0x81` NACK, which §5.1 defines as unconditionally fatal. **Today's behaviour therefore violates +the pipe spec as written; Step 4b restores conformance.** That also settles it against the parent +plan's *"NO wire protocol changes"* constraint: no documented client-observable behaviour +changes, because silence-on-loss is what the document already prescribes. No `.md` edit is +required — at most a clarifying sentence in §5.2 that a nonce-rejected data frame is classed as +loss, not as an unrecoverable condition. + +**Deliberately narrow:** +- **Tag failures keep the NACK.** They are tamper evidence, not loss, and §5.2's "unrecoverable + condition" is exactly right for them. +- **`0x0071` (legacy DIRECT_WRITE_DATA) is left alone.** It has a different ACK discipline that + has not been analysed here, and the field failure lives on the pipe path. Note it as a + deliberate exclusion so the next reader does not assume it was an oversight. +- **No canonical-header change** (Decision E still stands): no opcode, response code, or envelope + changes. + +### Step 5 — Verification +- **Host test** (Decision D): `tools/test_nonce_window.cpp` against `src/nonce_window.h`, run + under UBSan/ASan. Full case list in Decision D — it covers the window state machine, *not* + the `integrity_failures` behaviour, which is what hardware tests 1-2 below are for. +- **Build gate:** `pio run -e nrf52840custom -e esp32-s3-N16R8 -e esp32-c3-N16 -e esp32-c6-N4 + -e esp32-N4`. CI builds all 11. +- **Hardware** (py-opendisplay CLI + `tools/od-device-cli.py`). **Test 0 comes first:** + 0. **Baseline on unmodified firmware.** Induce a real window-loss event and record the + `integrity_failures` trajectory, whether `clearEncryptionSession()` actually fires, and + whether the client aborts at the first NACK. This settles the D1 mechanism caveat above. + Without it, the before/after claims for tests 1-2 rest on an unverified model. + 1. Forward-gap **within** the cap: skip 100 counters mid-session → next frame accepted, + transfer continues, session survives (today: session destroyed after 3). + 2. Forward-gap **beyond** the cap: skip 200 (> `OD_NONCE_FORWARD_CAP`) → frames rejected as + out-of-window, but **`integrity_failures` stays 0 and the session survives**. This is the + D1 regression test. + 2b. **`[H1]` — the gap must not kill the transfer.** Run test 2 *during a live pipe upload* + and assert the upload **completes**: the rejected frames are silently dropped (Step 4b), + absent from the next SACK mask, retransmitted, and repaired. Today, and in the pre-review + version of this plan, the client raises `IntegrityCheckError` and aborts. This is the test + that distinguishes "the device survived" from "the transfer survived". + 2c. **Worst-case client settings.** Repeat the pipe regression with + `blocks_per_ack = 1` and `W = 32` — the configuration that makes the gap widest + (Decision A). Confirms the 128 cap in the conditions that motivated it. + 3. True replay of an old counter → rejected, session survives. + 4. **Replay of the last frame of a session** (D3) → now REJECTED. Use a + non-idempotent command (buzzer) so acceptance is observable. + 4b. **`[H3]` — ring-flush replay.** Replay the final frame **64 times**, then replay an + *older* counter that is still inside the backward window. Must be `NONCE_REPLAY`. On + today's firmware the 64 re-accepts flush the value ring and the older frame is **accepted + and re-executed**; this test fails before the change and passes after, which is the only + way to demonstrate the bitmap closed the widened hole rather than just the narrow one. + 5. Forged/corrupt tag ×3 → session still cleared (unchanged behaviour, deliberately). + 6. Regression: full Spectra transfer and an E1004 ~960 KB upload complete untouched. + +### Step 6 — Comment hygiene +- [communication.cpp:772-775](../src/communication.cpp) documents that the replay counter + "already advanced at decrypt time … so drops/dupes never desync it". The invariant still + holds (commit happens for every frame that *decrypts*, including ones the pipe handler + discards) but the function name and the ordering claim are now wrong. Rewrite it in the + same change. **Write the mechanism, not a number** (Decision A, `[C1]`): the gap is driven by + the client's *retransmit budget* `max_retx = max(3·W, n/2)` and by `blocks_per_ack`, both of + which live in another repo and one of which is a user-facing Home Assistant setting — so + `OD_NONCE_FORWARD_CAP` is a heuristic with headroom, **not** an invariant firmware can prove. + A comment asserting a specific bound would be falsified silently by a client-side config + change. Say that, and point at Decision A. + +--- + +## Decisions + +**All five are settled.** Nothing blocks implementation. + +### Decision A — ~~RESOLVED: forward cap **128**~~ — **REVERSED 2026-07-31** + +> ⛔ **This decision no longer holds. There is no forward cap.** The constant was removed in +> `aef3a6b` because the cap made a session permanently unrecoverable once a gap crossed it. See +> [Reversal of Decision A](#reversal-of-decision-a--the-forward-cap-was-removed-2026-07-31) at the +> end of this file. The analysis below is retained because its framing of *what the gap is* is +> still correct and is what ultimately showed the cap could not be sized safely — but do not +> implement from it. + +> **Revised after adversarial review** (`C1`, `M2` in +> [`FINDINGS_PHASE1_PLAN_REVIEW_2026-07-26.md`](FINDINGS_PHASE1_PLAN_REVIEW_2026-07-26.md)). +> This decision previously said 64, derived from `PIPE_MAX_W + MAX_PTO = 35` and asserted as a +> **hard bound**. That derivation was incomplete and the assertion was wrong. Both are corrected +> below; the earlier reasoning is retained only where it is still valid. (`[L4]`: even that +> figure was one too many — `MAX_PTO = 3` yields **two** probe sends, since the client increments +> and raises at the threshold *before* sending, `device.py:2721-2726`. Moot now, but noted so the +> arithmetic is not re-derived wrongly later.) + +**What the gap actually is** (unchanged, and still the right framing): not the in-flight chunk +depth, but the run of consecutive transmissions that never reach `decryptCommand` — the client +burns a counter per transmission whether or not it lands +([device.py:747-760](../../py-opendisplay/src/opendisplay/device.py)). Sources: frames lost on +air; frames dropped at the command ring +([esp32_ble_callbacks.h:127-128](../src/esp32_ble_callbacks.h), on the NimBLE host task *before* +decrypt); and retransmissions. + +**Why there is no firmware-side hard bound.** The earlier derivation counted two of the client's +three transmit sites. New sends are window-credit-limited +([device.py:2688-2694](../../py-opendisplay/src/opendisplay/device.py)) and PTO probes resend +exactly one chunk (`:2715-2726`) — but **selective repair spends no window credit at all**: + +```python +device.py:2789-2796 + for m in missing: # every hole below highest_recv, up to W-1 of them + if do_retx: + await _send(m) # fresh counter each; no credit consumed + retx_count += 1 +``` + +and the client deliberately preserves queued ACKs to spend on repeat repair rounds +(`drain_stale=False`, `device.py:781-786`). The only ceiling is the client's retransmit budget: + +``` +max_retx = max(3 * W, ceil(n * 0.5)) device.py:2672, commands.py:96 +``` + +which is 96 for `W = 32` and scales with *chunk count* for large uploads. Worked through, the +reachable gap is ~66 at `blocks_per_ack = 2` and ~96 at `blocks_per_ack = 1` — and +`blocks_per_ack` is a **user-settable Home Assistant option** (`min=1, max=32`), which firmware +clamps only at the top ([display_service.cpp:2723-2725](../src/display_service.cpp)). + +**So the honest statement is: firmware cannot bound this from its own constants.** The bound +lives in a client in another repo, behind a user-facing setting. Any cap is a heuristic; the +question is only how much headroom it buys and what it costs. + +**`OD_NONCE_FORWARD_CAP = 128`.** It covers the realistic worst case (~96) with margin, and +under the bitmap it costs **nothing** — the cap is a comparison, not storage. The old "tight is +correct" argument is withdrawn: it rested on the `[M1]` jam-forward DoS, which **cannot occur +once D2 is fixed.** After commit-after-verify, only a CCM-authenticated frame advances +`last_seen_counter`, so an attacker can only commit counters the client genuinely transmitted — +never past the client's own high-water mark. Repairs then carry *fresh, higher* counters +(`device.py:759`), so no future client frame ever falls below the window. The frames a jam could +strand do not exist. `[M1]` was an artifact of the value-ring design and does not survive +commit-after-verify plus a bitmap. + +**Step 6's source comment must record the mechanism, not the number.** A `blocks_per_ack` change +in Home Assistant, or a larger `max_retx`, silently invalidates any figure written into +`communication.cpp`. The comment should say *why* the cap exists and *where* the real bound +lives, so the next reader knows it is a heuristic to be re-checked rather than an invariant that +has been proven. + +**Residual risk, stated plainly.** A gap beyond 128 is still possible on a pathological link with +`blocks_per_ack = 1` and a multi-thousand-chunk upload. With Step 4b in place that is no longer +fatal — the frames are silently dropped and repaired by the normal SACK path — which is +precisely why `[H1]` is treated as in-scope rather than deferred. + +**Backward width is a separate constant** — see Decision B. Under a bitmap the two sides are +independent: forward acceptance stores nothing. + +### Decision B — RESOLVED: sliding bitmap, **IPsec/DTLS shifting style**, `uint64_t[4]` = 32 B + +**Bitmap over value ring.** To be precise about why, because the ring is *not* buggy: a ring of +depth `D` policing a backward window `W` is sound iff `D >= 2W`, since at most `2W` distinct +counters can be accepted while any given one remains in-window. Today's 64/32 and the parent +plan's 256/128 both satisfy it. The objection is not correctness but that `D >= 2W` is a +hand-maintained coupling between constants in two files, provable only by non-obvious +combinatorics, in a function that already shipped one undetected state bug (D4). The bitmap +makes eviction and falling-out-of-window *the same event*, so the coupling ceases to exist — +and takes D3 and D4 with it (Step 1). + +Storage scales at **16 B per unit of backward window** for a ring (`2W` entries × 8 B) versus +**1 bit** for a bitmap — a fixed 128:1 ratio at any width: + +| Backward window | Ring (`2W × 8 B`) | Bitmap | +|---|---|---| +| 32 (today) | 512 B | 8 B | +| 127 | 2,032 B | 16 B | +| 255 (**chosen**) | 4,080 B | **32 B** | + +`OD_NONCE_BACKWARD_BITS = 256` (`uint64_t[4]`, backward window 255) is generous for a tolerance +that is **never exercised in normal operation** — the client's counters are strictly increasing +and both transports preserve ordering — but at 32 B there is no reason to economize, and the +margin over `OD_NONCE_FORWARD_CAP = 128` keeps the wholesale-clear branch off the normal path +(Step 1). Net struct change is **−480 B** against today, versus **+1,536 B** for the ring plan. +The `esp32-N4` link headroom measured for the ring (81,940 → 83,476 B of 327,680, SUCCESS both +ways) is therefore moot; recorded only so the fallback question never gets reopened. + +**Shifting (RFC 4303 / RFC 6347) over non-shifting (RFC 6479 / WireGuard).** Both are the same +algorithm; they differ only in how the window advances: + +- **Shifting** — the bitmap is a plain integer shifted left by `diff` on advance. This is what + IPsec ESP §3.4.3 and DTLS §4.1.2.6 describe. +- **RFC 6479 / WireGuard** — a *circular* bit array indexed by `counter mod size`, where + advancing clears the blocks between the old and new positions instead of shifting. It also + requires the array to be strictly larger than the window ("redundant bits") for the clearing + to be safe. + +**Pick shifting.** RFC 6479 exists to avoid the cost of shifting a *large* window — WireGuard +carries ~8,192 bits because it is a high-throughput VPN over UDP with genuine reordering. +Ours is 128 bits over two words at roughly 40 frames/s, where the whole operation is a handful +of instructions and is dwarfed by the AES-CCM decrypt of the same frame. Choosing RFC 6479 +would buy nothing measurable and would add modular indexing plus the redundant-bits invariant — +more subtlety, in exactly the function where subtlety has already cost us. The one real hazard +in the shifting form is UB on `x << 64`, which Step 3 calls out and Step 5 tests directly. + +Both styles are equally standard; this is a sizing call, not a security one. + +### Decision C — RESOLVED: delete `verifyNonceReplay()` outright + +No compatibility wrapper. Nothing outside `decryptCommand` should ever be able to commit nonce +state, and a surviving wrapper is an invitation to re-introduce exactly the commit-before-verify +bug (D2) that Phase 1 exists to remove. Three deletions, all in Step 4: + +| Location | Action | +|---|---| +| [encryption.cpp:114-156](../src/encryption.cpp) | Delete the function body; `nonceCheck` + `nonceCommit` replace it | +| [encryption.h:17](../src/encryption.h) | Delete the declaration | +| [main.h:276](../src/main.h) | Delete the duplicate declaration | + +`nonceCheck` and `nonceCommit` are **file-static** in `encryption.cpp` — they get no header +declaration at all, so the "only `decryptCommand` may commit session state" rule is enforced by +linkage rather than by convention. The pure logic they wrap lives in `src/nonce_window.h` and +is what the host test targets (Decision D), so staying static costs no testability. + +**The `NonceResult` *type* is shared, and that is not a loophole.** Step 4b needs +`decryptCommand` to report *why* it failed, so the enum is declared in `nonce_window.h` and +reaches `encryption.h`. A visible type conveys no ability to read or mutate `encryptionSession`; +the functions that can are still unreachable outside `encryption.cpp`. + +**Sequencing note:** this is not a standalone edit — `decryptCommand` is the sole caller, so the +deletion only compiles as part of Steps 1-4 landing together. + +### Decision D — RESOLVED: standalone `tools/test_nonce_window.cpp`, no PlatformIO env + +No `[env:native]` and no `test/` directory, so the 11-env matrix and a bare `pio run` are +untouched. (Note `.cpp`, not `.c` as first written — it includes a header shared with C++ +firmware code.) + +**This forces one structural refinement, and it is a good one.** The pure window logic must +compile with no Arduino, no mbedtls, and no `millis()` in its translation path — so it moves +into a dependency-free header operating on plain values: + +``` +src/nonce_window.h static inline, zero dependencies: (bitmap*, last_seen, counter) + -> NonceResult / updated state. No session, no logging, no crypto. +src/encryption.cpp static nonceCheck()/nonceCommit() wrap it with encryptionSession + + od_log_*. Still file-static (Decision C) — linkage still enforces + "only decryptCommand may commit session state". +tools/test_nonce_window.cpp includes ONLY src/nonce_window.h +``` + +This keeps Decision C's guarantee intact while making the part worth testing reachable: the +bit-shifting state machine is exactly the code with edge cases, and it has no business knowing +about sessions or logging anyway. + +```bash +g++ -std=c++17 -Wall -Wextra -Werror -O1 -fsanitize=undefined,address \ + tools/test_nonce_window.cpp -o /tmp/test_nonce_window && /tmp/test_nonce_window +``` + +`-fsanitize=undefined` is not decoration — it is what catches the `x << 64` UB in Step 3 +automatically rather than relying on the test author to predict it. + +**Coverage (all against `nonce_window.h` directly):** +- **Shift edges:** `fwd` = 0, 1, 63, 64, 65, 127, 128 (reachable), plus 129, 191, 192, 255, 256, + 257 driven directly against `nonce_window.h` to cover the word boundaries and the + wholesale-clear guard (Step 3). +- **Purity of `nonceCheck` (D2):** snapshot the state, call `nonceCheck` on every result class, + `memcmp` the state afterwards. This is the single most valuable assertion in the file — it is + the property that "the tag is the only thing that may advance replay state" rests on. +- **D3:** commit a counter, re-present the same counter → `NONCE_REPLAY`. Cover `fwd == 0` + specifically, which is the exempted case today. +- **Fresh session:** `last_seen = 0`, empty bitmap → counter 0 accepted exactly once, rejected + on re-presentation. No `has_seen_counter` involved. +- **Wholesale slide:** forward jump ≥ `OD_NONCE_BACKWARD_BITS` → bitmap cleared; previously + seen counters now return `OUT_OF_WINDOW`, **not** `REPLAY` (both reject, but conflating them + would hide a genuine slide bug). +- **Differential/property test:** run a few thousand pseudo-random accept/replay/gap sequences + against a naive `std::set` oracle that models "seen, within window". Cheap, and it covers + the interleavings hand-written cases miss. +- **Counter arithmetic (`[M1]`):** assert the unsigned form from Step 2 behaves correctly at + `counter = 2^63`, `last_seen = 1` and near `UINT64_MAX` — the inputs that make the signed form + undefined. Unreachable in normal operation (2^63 frames) but attacker-reachable, and free to + get right. UBSan makes this test self-checking. + +**Not covered here, deliberately:** that a nonce failure leaves `integrity_failures` untouched +(D1) lives in `decryptCommand`, not in the window logic. That assertion belongs to Step 5's +hardware tests 1-2. + +**CI `[L5]`:** add a **separate top-level `host-tests` job** in `.github/workflows/main.yaml` — +**not** a step inside the existing `build` job, which is an 11-entry `matrix.environment` +(`:10-23`) and would run the host test eleven times. It needs no toolchain beyond the runner's +stock `g++` and gates every push alongside the firmware builds. + +Confirmed build-safe: `tools/test_nonce_window.cpp` is invisible to every firmware build — +`build_src_filter = +<*> -` (`platformio.ini:34`) is relative to the default +`src_dir`, and no env adds `tools/`. + +### Decision E — RESOLVED: no wire change. Recorded for the future, **not actioned** + +`decryptCommand` returning false yields an unencrypted `RESP_NACK` +([communication.cpp:698-703](../src/communication.cpp)) for both "lost your window" and +"tag failed". Phase 1 leaves that exactly as it is. + +**Why it is acceptable to leave — corrected.** This decision originally claimed the client's +"existing retransmit/PTO machinery recovers on its own". **That was false** (`[H1]`): the 3-byte +`0xFF` NACK is intercepted at `device.py:833-838` and raised as `IntegrityCheckError`, which the +pipe send loop does not catch, so the transfer dies on the first rejected frame. + +What makes leaving the *wire* alone acceptable is **Step 4b**, which fixes this firmware-side by +sending nothing at all for a nonce-rejected pipe DATA frame. Silence is already a first-class +signal in the pipe protocol — it means "lost", and the SACK path repairs it. No new response +code is required to get correct recovery; the client needs no change. + +**Recorded for a future protocol revision** (do not implement in Phase 1, and do not let a +reviewer re-open it here): a distinct response code for "nonce out of window" would let a +client re-sync deliberately — abandon the in-flight window and re-authenticate — instead of +burning its `MAX_PTO` budget discovering the same thing by timeout. That is a strictly better +recovery, and worth doing *if* the wire is being revised for other reasons. It is not worth +doing on its own: it is a cross-repo change through `../opendisplay-protocol`, a `--push` to +all four firmware repos, and a coordinated py-opendisplay release, to save a few seconds on a +path Phase 1 already makes non-fatal. + +#### `[H2]` Also recorded here: device and client share one nonce space (shipping defect) + +**Not a Phase 1 change — recorded so one future wire revision fixes it together with the +response code above.** Phase 1 is the change that will make a future reader believe this layer +has been audited, so an unrecorded defect here is worse than one nobody has looked for. + +The outbound nonce is built as `session_id || nonce_counter` +([encryption.cpp:158-174](../src/encryption.cpp)) from the device's **own** counter, and the +inbound nonce is `session_id || client_counter` off the wire. `encryptResponse` +([:740-743](../src/encryption.cpp)) and `decryptCommand` ([:704-705](../src/encryption.cpp)) +both then take `nonce_full[3..15]` as the CCM nonce, under the **same `session_key`**, with +**no direction separator**, and both counters reset to 0 at session start +([:210](../src/encryption.cpp)/[:655](../src/encryption.cpp); `device.py:735`). + +So device response #*k* and client command #*k* encrypt under an identical (key, nonce). CCM is +CTR underneath and the keystream depends only on key and nonce — the AAD differs but feeds only +the tag — so `C_resp ⊕ C_cmd = P_resp ⊕ P_cmd`. Responses are short and highly predictable +(`{RESP_ACK, cmd, status}`, pipe ACKs), so a passive eavesdropper recovers the leading plaintext +of the matching command. Authenticity is unaffected; this is a confidentiality failure. + +**Age and blast radius (verified from history):** introduced in `b04a22b` *"Add encryption"* +(2026-03-10) — the construction is byte-identical today, and `fd0d73a` merely moved it from +`main.cpp` into `encryption.cpp`. It is **not** a regression from this branch. The same +construction is in all four firmware repos (`Firmware_NRF54/src/opendisplay_pipe.c:518-522`, +`Firmware_Silabs/opendisplay_pipe.c:476`, `Firmware_NRF/encryption.c:197`) and both clients, +because [opendisplay_protocol.h:203-209](../include/opendisplay_protocol.h) specifies the +envelope and `CCM nonce = nonce[3..15]` but says **nothing about the nonce's internal +structure** — there is no single place where a reviewer would have seen the missing direction +separator. + +**Therefore the fix is a spec change first, not four patches:** define the nonce layout in the +canonical header *including* a direction bit, then `--push`. Whoever schedules it must also plan +the compatibility story — an old client and a new device would disagree on the nonce. See +`[M3]` in Step 1 for the in-scope consequence: `resetNonceState()` must keep zeroing +`nonce_counter`, or the device reproduces this reuse against itself across a re-auth. + +**Action for Phase 1: none in code.** This paragraph, plus a note in +`../opendisplay-protocol/agents/` where cross-repo design issues live — this repo is not where +the next person will look for a protocol-level defect. + +--- + +## Scope boundaries (do not drift) + +- **Do not touch the 30 s auth-challenge freshness window** ([:587-588](../src/encryption.cpp) + stamp, [:604-608](../src/encryption.cpp) enforcement). It is a cross-repo wire contract + specified at [opendisplay_protocol.h:384](../include/opendisplay_protocol.h), it is not a + liveness timer, and it cannot wedge anything — see the parent plan's "Deliberately NOT + changed". This includes leaving the known boot-window quirk (`server_nonce_time == 0`) alone. +- **Do not disable `session_timeout_seconds` here.** That is Phase 5. Consequence to state + plainly: Phase 1 alone does **not** close the mid-transfer freeze caused by expiry firing + inside `isAuthenticated()` ([:195-199](../src/encryption.cpp)) — it closes the *nonce* arm + only. Both arms must land before the field failure is fully addressed. +- **Do not add link-drop behaviour to `clearEncryptionSession()`.** That guard is Phase 5. + After Phase 1 the CCM-tag path can still clear a session under a live link, leaving the + client talking to a device that answers `0xFE` — a known, accepted gap until Phase 5. +- **Do not shrink the backward window to zero.** TLS over TCP accepts no out-of-order records + at all and lets the AEAD tag enforce ordering for free (RFC 8446 §5.3), and OpenDisplay's + situation is arguably TLS's rather than DTLS's — ordered transports, a strictly increasing + client counter. But narrowing the accepted range is a larger behavioural change than + widening it, other firmware repos share this protocol, and the field failure is a *forward* + gap problem. Out of scope for Phase 1; noted so the option is not lost. +- No protocol-header edits, no config-schema changes, no client-side changes. + +**Compliance with the parent plan's "NO wire protocol changes" constraint** — every Phase 1 +change is inside its in-bounds list: + +| Change | Why it is in bounds | +|---|---| +| Widened forward cap, bitmap replay set | Firmware-local constants no client reads; accepting more legitimate frames and rejecting a replay are both already-legal outcomes of the existing nonce rules | +| D3 fix (replay of the highest-seen counter now rejected) | Rejecting a replay is what the nonce rules already prescribe; the exemption was the deviation | +| Step 4b (no fatal NACK for a nonce-rejected `0x0081`) | **Conformance**, not change — `pipe-write-protocol.md` §5.2 already reserves NACKs for unrecoverable conditions, "not ordinary packet loss" | +| `decryptCommand` reason out-param | Internal signature; nothing on the wire | + +Nothing here requires an edit to `include/opendisplay_protocol.h` or +`include/opendisplay_structs.h`. Run the constraint check from the parent plan before calling +Phase 1 done. + +## Files touched + +| File | Change | +|---|---| +| `src/nonce_window.h` | **new** — dependency-free window state machine (Decision D) | +| `src/encryption_state.h` | ring → bitmap, **−480 B** | +| `src/encryption.cpp` | the substance: `nonceCheck`/`nonceCommit`, `decryptCommand` rewiring, `verifyNonceReplay` deleted | +| `src/encryption.h` | declaration removal (Decision C); `decryptCommand` reason out-param (Step 4b) | +| `src/main.h` | duplicate declaration removal (Decision C); same signature change (Step 4b) | +| `src/communication.cpp` | **Step 4b**: suppress the fatal NACK for nonce-rejected `0x0081` — plus the comment at [:772-775](../src/communication.cpp) | +| `docs/pipe-write-protocol.md` | *optional* — one clarifying sentence in §5.2 that a nonce-rejected data frame is classed as loss. No behaviour change to document: Step 4b makes the firmware conform to what §5.2 already says. | +| `tools/test_nonce_window.cpp` | **new** — host test (Decision D) | +| `.github/workflows/main.yaml` | separate `host-tests` job: compile + run the host test | + +**No `platformio.ini` change**: the bitmap is smaller than what it replaces, so the `esp32-N4` +link headroom that gated the original proposal is not a consideration on any target. +**No protocol-header change** (Decision E). **No client-side change.** + +--- + +# As-built: what actually shipped + +**Written 2026-07-26 after reviewing the landed code.** Everything above this line is the plan as +written. This section is the ground truth. Line numbers are against the tree at `23ecaed`. + +Commits, in order: + +| SHA | Subject | Corresponds to | +|---|---|---| +| `0a60712` | replace 512 B replay value ring with 32 B sliding bitmap | Steps 1-4, Decisions A/B/C | +| `9b827f3` | split check from commit; stop counting packet loss as tampering | Steps 2-4 (D1/D2) | +| `eeadbe0` | do not answer a nonce-dropped 0x0081 frame with a fatal NACK | Step 4b | +| `44df35a` | ci: separate host-tests job | Decision D `[L5]` | +| `23a586a` | test: host test for the nonce sliding-window state machine | Step 5 / Decision D | +| `c87ff60` | review follow-ups (split log budgets, readable replay log, honest comments) | Step 4 logging, Decision C caveat | +| `55a2478` | answer session-id mismatch with AUTH_REQUIRED, not a fatal NACK | **not in the plan** — field-failure response | +| `77ebdcd` | drop the BLE link after 10 consecutive unauthenticated commands | **not in the plan** — Phase 5 work, pulled forward | +| `23ecaed` | drop the BLE link inline on nRF; loop() is starved mid-transfer | **not in the plan** — follow-up to `77ebdcd` | + +## Per-step / per-decision status + +| Item | Status | Evidence | +|---|---|---| +| **Step 1** — value ring → sliding bitmap | **As specified** | `src/encryption_state.h:22-28` — `uint64_t replay_bitmap[OD_NONCE_BITMAP_WORDS]` replaces `uint64_t replay_window[64]`. `OD_NONCE_BACKWARD_BITS 256` / `OD_NONCE_BITMAP_WORDS` at `src/nonce_window.h:33,42`. No `replay_window_index`, no `has_seen_counter` anywhere (`grep` for both returns nothing), so **D3 and D4 are structurally gone**, not patched. | +| **Step 1** — `resetNonceState()` naming all four fields `[M3]` | **As specified** | `src/encryption.cpp:123-128`, sets `nonce_counter`, `last_seen_counter`, `integrity_failures`, `memset(replay_bitmap)`. Called from both required sites: `clearEncryptionSession()` at `src/encryption.cpp:247` and `handleAuthenticate()`'s fresh-session block at `src/encryption.cpp:692`. The `[H2]`-motivated warning about `nonce_counter` is carried in the comment at `:114-122`. | +| **Step 2** — pure `nonceCheck()` | **As specified** | `od_nonce_check()` at `src/nonce_window.h:74-82` is byte-for-byte the four ordered tests from the plan, with unsigned wrapping `fwd`/`back` at `:75-76`. The session-aware wrapper `nonceCheck()` is file-static at `src/encryption.cpp:162-188` and writes nothing to `encryptionSession` on any path. | +| **Step 3** — `nonceCommit()` | **As specified (one structural difference)** | `od_nonce_commit()` at `src/nonce_window.h:126-145`; `od_nonce_bitmap_shift_left()` at `:87-105` handles `shift == 0` (`:88`) and `shift >= 256` (`:89-92`) explicitly. **Difference:** the plan put the wholesale-clear guard in `nonceCommit`; as built it lives inside the shift helper. Behaviourally identical and arguably better — the guard now protects *every* caller of the shift, not just the commit path. | +| **Step 4** — `decryptCommand` rewiring | **As specified** | `src/encryption.cpp:722-800`. `nonceCheck` at `:738`; the nonce-rejection arm at `:739-755` returns false with **no** `integrity_failures` touch. `nonceCommit(nonce_counter)` at `:782` is the **first statement of the `if (success)` arm**, ahead of the malformed-`payload_length` early return — `[L2]` honoured, with the reason spelled out in the comment at `:776-781`. The tag-failure arm at `:794-798` is unchanged. | +| **Step 4** — logging demotion + rate limit `[L7]` | **As specified, and better** | Out-of-window/replay log at `src/encryption.cpp:741-753` is `od_log_warn`, rate-limited; session-id mismatch at `:174-181` is `od_log_warn` with the two full session-ID dumps reduced to two bytes each. The plan asked for "a" rate limit; as built there are **two independent 5 s budgets** (`nonce_log_badsession_ms` / `nonce_log_window_ms`, `:137-145`) so a peer spamming session-id mismatches cannot silence the out-of-window line. Improvement over the plan. | +| **Step 4** — delete `verifyNonceReplay()` (Decision C) | **As specified** | Gone from `src/encryption.cpp`, `src/encryption.h`, and `src/main.h`. `grep -rn "verifyNonceReplay" src/ tools/ include/` returns nothing. | +| **Step 4b** — silent drop for nonce-rejected `0x0081` | **As specified** | `src/communication.cpp:866-868`: `if (nonce_loss && command == CMD_PIPE_WRITE_DATA) return;` where `nonce_loss` covers `NONCE_OUT_OF_WINDOW` and `NONCE_REPLAY` only. Tag failures still NACK (`:900-903`). `0x0071` deliberately untouched, documented at `:862-865`. Reason out-param plumbed through `src/encryption.h:24-28` and `src/main.h:274`. | +| **Step 5** — host test (Decision D) | **Done** | `tools/test_nonce_window.cpp`, 702 lines. `g++ -std=c++17 -Wall -Wextra -Werror -O1 -fsanitize=undefined,address` → **PASSED 38199 checks**. Every case in Decision D's coverage list is present: `test_fresh_session` (`:161`), `test_d3_same_counter_replay` (`:190`), `test_check_is_pure` (`:241`, the memcmp purity assertion), `test_shift_edges` (`:387`), `test_wholesale_slide` (`:408`, asserting `OUT_OF_WINDOW` not `REPLAY`), `test_bit_indices_after_shift` (`:449`), `test_counter_arithmetic_extremes` (`:493`), `test_differential_against_oracle` (`:623`). | +| **Step 5** — build gate | **Done** | `pio run` — **all 12 environments SUCCESS** (the plan and CI both said "11"; the matrix is now 12). `esp32-N4` links at 24.9% RAM / 81,468 B, i.e. **below** the 81,940 B the ring plan measured — the bitmap gave the byte budget back as predicted. | +| **Step 5** — hardware tests 0, 1, 2, 2b, 2c, 3, 4, 4b, 5, 6 | **NOT DONE** | See ["Unverified on hardware"](#unverified-on-hardware--the-honest-list). This is the single largest gap in Phase 1. | +| **Step 6** — comment hygiene | **As specified** | `src/communication.cpp:977-995`. Records the *mechanism* (client retransmit budget `max_retx = max(3*W, n/2)`, `blocks_per_ack` as a user-facing HA option) and explicitly calls `OD_NONCE_FORWARD_CAP` a heuristic, not an invariant — exactly what Decision A `[C1]` demanded. No number is asserted. | +| **Decision A** — forward cap 128 | **As specified** | `src/nonce_window.h:40` — `#define OD_NONCE_FORWARD_CAP 128`, with the "this is a heuristic, the bound lives in another repo" rationale in the comment at `:35-39`. | +| **Decision B** — shifting bitmap, `uint64_t[4]` | **As specified** | `src/nonce_window.h:33` (256 bits), `:87-105` (RFC 4303 shifting form, not RFC 6479 circular). | +| **Decision C** — file-static, no wrapper | **As specified, with an honest correction the plan did not anticipate** | `nonceCheck`/`nonceCommit` are file-static (`src/encryption.cpp:162`, `:190`) and have no header declaration. **But** the implementation noticed and documented at `src/encryption.cpp:152-160` that Decision C's "enforced by linkage" claim is weaker than written: `encryption_state.h` must include `nonce_window.h` for `OD_NONCE_BITMAP_WORDS`, and `main.h` includes `encryption_state.h`, so the `static inline` primitive `od_nonce_commit()` is visible in **every** translation unit alongside `extern encryptionSession`. Nothing stops a determined caller from committing state directly. **Decision C as written above is therefore inaccurate and this paragraph supersedes it:** linkage enforces the rule for the session-aware wrappers; convention enforces it for the raw primitive. | +| **Decision D** — standalone host test + separate CI job | **As specified** | `.github/workflows/main.yaml:8-27` — top-level `host-tests` job, not a step in the 11-entry matrix, with the `-fsanitize` rationale in-line. No `[env:native]`, no `test/` dir, `pio run` unaffected. | +| **Decision E** — no wire change, `RESP_NACK` left alone | **Superseded in part by `55a2478`** | See below. Phase 1 as shipped no longer answers a session-id mismatch with `RESP_NACK`; it answers `RESP_AUTH_REQUIRED`. Still no header change and no new response code. | + +## Post-implementation changes — what the field failure forced + +The last three commits were **not** in the plan. They were added after the implementation agent +finished, in response to a live hardware failure. + +### What actually happened on the bench + +A client lost its session mid-connection while the device still believed the session was live. +py-opendisplay's `_direct_write_chunk_size()` keys purely on `self._session_key is not None` +(`../py-opendisplay/src/opendisplay/device.py:1916-1929`), and `_write` picks the plaintext branch +under the same condition (`:772-776`), so the client silently fell back to **unencrypted +`0x0071` chunks of `CHUNK_SIZE = 230`** (`../py-opendisplay/src/opendisplay/protocol/commands.py:70`) +— 232 bytes on the wire. + +232 bytes is **not** below the firmware's "unencrypted command received" length gate +(`BLE_CMD_HEADER_SIZE + 16 + 16 = 34`, `src/communication.cpp:818`), so those frames sailed past +the gate and into `decryptCommand`, where `nonceCheck` read 8 bytes of image data as a session id +and returned `NONCE_BAD_SESSION`. Before Phase 1 that counted toward `integrity_failures`, and +three of them cleared the session — ugly, but it is what made every subsequent command answer +`0xFE`, which is what eventually made the client re-authenticate. **Phase 1's `[L7]` change +removed that accidental recovery path** and left the device answering a fatal 3-byte `0xFF` NACK +forever to a client that could never resolve the mismatch by retrying. + +**The parent plan's Context §1 narrative is wrong about this, and so is the "mechanism caveat" +above:** the observed field wedge did not come from a forward nonce gap at all. It came from a +session-identity divergence plus a client that degrades to plaintext instead of erroring. The +nonce-gap story remains a genuine defect class, but it is **not** what was reproduced on hardware. + +### `55a2478` — session-id mismatch answers `AUTH_REQUIRED`, not `NACK` + +`src/communication.cpp:893-897`. On `NONCE_BAD_SESSION`, call `rejectUnauthenticated(command)` +(3-byte `{RESP_ACK, cmd_lo, RESP_AUTH_REQUIRED}`) instead of falling through to the NACK. + +**Verdict: correct, and in bounds.** The client classifies a 3-byte `0xFE` as +`AuthenticationRequiredError` (`device.py:824-827`) — a different exception hierarchy from the +`IntegrityCheckError` raised by `0xFF` (`device.py:834-838`) — and the HA integration escalates +`AuthenticationRequiredError` into a user-visible reauth flow rather than a silent abort. This is +`RESP_AUTH_REQUIRED` used in exactly its documented meaning ("this command requires a live +authenticated session"), which satisfies the parent plan's "sending an existing `RESP_*` code in a +new situation, as long as the code's documented meaning is unchanged" allowance. No header edit. + +**Caveat that is not written down in the code:** py-opendisplay does **not** re-authenticate +reactively. `_reauthenticate_if_needed` is proactive and time-based only (`device.py:788-804`), +and the pipe send loop catches nothing but `BLETimeoutError` (`device.py:2714-2717`). So the +in-flight upload still dies; what `55a2478` buys is the *right kind* of death — one that HA +converts into a reauth — instead of an `IntegrityCheckError` loop with no exit. The comment at +`src/communication.cpp:884-886` ("the client raises `AuthenticationRequiredError` and +re-authenticates, and the mismatch clears in one round trip") **overstates this**: it clears on the +next *connection*, not the next round trip. + +### `77ebdcd` — drop the BLE link after 10 consecutive unauthenticated commands + +`src/communication.cpp:56-201`. New `rejectUnauthenticated()` / `resetAuthGateRejects()` / +`serviceBleAuthAbuseDisconnect()`; every `RESP_AUTH_REQUIRED` the encryption gate emits now routes +through the counter (`src/communication.cpp:815`, `:821`, `:895`). + +**This crosses the plan's own scope boundary.** The Scope boundaries section above says verbatim: +*"Do not add link-drop behaviour to `clearEncryptionSession()`. That guard is Phase 5."* The +letter of that boundary is not violated — the drop is not in `clearEncryptionSession()`; it is +keyed on the *symptom* (repeated `0xFE`) rather than on the *event* (session cleared). But it is +unambiguously **the Phase 5 deliverable "make a dead session with a live link impossible", arrived +at from the other end**, and it should be recorded as such rather than as a Phase 1 refinement. + +**Verdict: justified as a field-failure response, but it is scope creep and Phase 5 must now be +re-scoped around it** (see the parent plan's Phase 5 entry, updated accordingly). It is *not* +redundant with Phase 5: Phase 5's guard fires on the clear itself and drops the link immediately; +this one waits for ten wasted round trips first. Phase 5 should subsume it, not duplicate it. + +**Three problems found in review, none of them blockers, none fixed here (documentation-only pass):** + +1. **The threshold is below the client's pipe window.** `AUTH_GATE_MAX_CONSECUTIVE_REJECTS = 10` + (`src/communication.cpp:87`), but `_send_pipe_chunks` blasts a full window of `0x0081` frames + before its first read (`device.py:2689-2694`) with `w_eff = max(1, min(max_queue_size, + dev_max_window, 32))`, **default 16**, and `_write_pipe_frame` deliberately skips re-auth for + the entire stream (`device.py:778-786`). So when a session dies mid-upload — precisely the + Phase 5 scenario — a **legitimate** client emits 16-32 gated frames back-to-back and trips the + guard. That is arguably the right outcome (the upload is dead either way, and a clean reconnect + is better than a spin), but the code's justification comment at `src/communication.cpp:70-80` + reasons only about a client "probing several gated commands before it authenticates" and never + considers the window burst. Worse, that stated justification is **not corroborated by + py-opendisplay**: on the normal path the client sends *zero* gated commands before + `CMD_AUTHENTICATE` (`device.py:652-675` — auth is the first write after connect), and `0x0044` + named in the comment is not a py-opendisplay opcode at all (`READ_FW_VERSION` is `0x0043`, + `commands.py:22`, and it bypasses the crypto wrappers entirely). The threshold of 10 is fine; + the reasoning recorded for it is wrong. +2. **The count is not cleared on disconnect.** `resetAuthGateRejects()` has exactly two callers: + a successful decrypt (`src/communication.cpp:908`) and a successful authentication + (`src/encryption.cpp:691`). Neither `disconnect_callback` (`src/device_control.cpp:227-240`) + nor `MyBLEServerCallbacks::onDisconnect` (`src/esp32_ble_callbacks.h:57-70`) clears it. The + guard tries to compensate with `authGateLastHandle` (`src/communication.cpp:122-126`), but on + nRF `Bluefruit.connHandle()` returns the single `_conn_hdl` (`bluefruit.cpp:643-646`), which + is typically the *same* value for successive peripheral connections. So client A can accrue 9 + rejections, disconnect, and client B inherit them — the exact outcome the comment at + `src/communication.cpp:121-123` promises cannot happen. Impact is small (B normally + authenticates first, which resets), and **the fix is a one-line `resetAuthGateRejects()` call + in each disconnect callback.** Not applied here. +3. **On ESP32 the guard cannot identify which central offended.** `authGuardLiveConnHandle()` + returns `pServer->getPeerInfo(0).getConnHandle()` (`src/communication.cpp:104-112`) — peer + *zero*, not the sender. The NimBLE write callback discards `connInfo` + (`src/esp32_ble_callbacks.h:81-82`) and the command ring carries no handle, so the sender's + identity is genuinely unavailable by the time `imageDataWritten` runs on the loop task + (`src/main.cpp:415`). With `CONFIG_BT_NIMBLE_MAX_CONNECTIONS` really being 3, a second central + can therefore drive peer 0 — the legitimate client — off the link. This is the same missing + peer-binding that finding `[D3]` above already documents for `isAuthenticated()`, so it adds no + new capability an attacker did not have; but the guard's "drop only the link that actually + earned it" comment (`src/communication.cpp:186-188`) is only true on nRF. Fixing it properly + means widening the ESP32 command ring to carry the conn handle — a Phase 4 (connection + exclusivity) change, not a Phase 1 one. + +### `23ecaed` — drop the link inline on nRF + +`src/communication.cpp:145-166`: on `TARGET_NRF` only, `rejectUnauthenticated()` calls +`serviceBleAuthAbuseDisconnect()` **inline** instead of leaving it to `loop()`. + +**Verdict: the inline disconnect is SAFE on nRF, and both of the commit's factual claims check +out.** This was the highest-risk change in the branch and it survives scrutiny. The chain, verified +against the Adafruit core in `~/.platformio/packages/framework-arduinoadafruitnrf52-seeed`: + +- **`loop()` really is starved.** The Arduino loop task is created at `TASK_PRIO_LOW = 1` + (`cores/nRF5/main.cpp:88`, `cores/nRF5/rtos.h:58`); the "Callback" task that runs the write + callback is `TASK_PRIO_NORMAL = 2` (`cores/nRF5/utility/AdaCallback.c:145`, `rtos.h:59`); the + "BLE" event task is `TASK_PRIO_HIGH = 3` (`bluefruit.cpp:473`). A sustained flood of write + callbacks therefore preempts `loop()` indefinitely. On top of that, the nRF `loop()` calls + `serviceBleAuthAbuseDisconnect()` only *after* `idleDelay(sleep_timeout_ms)` + (`src/main.cpp:522-531`), which can be seconds. The deferral genuinely does not work here. +- **`Bluefruit.disconnect()` cannot unwind into the callback we are inside.** It resolves to + `BLEConnection::disconnect()` → `sd_ble_gap_disconnect(...)` + (`libraries/Bluefruit52Lib/src/BLEConnection.cpp:204-207`), which is an **asynchronous** + SoftDevice call: it queues the terminate and returns. +- **The disconnect callback is queued to the same task as the write callback, so it cannot + preempt an in-flight command.** `BLE_GAP_EVT_DISCONNECTED` dispatches via + `ada_callback(NULL, 0, Periph._disconnect_cb, ...)` (`bluefruit.cpp:849`), and `ada_callback` + always enqueues onto the single "Callback" task queue (`AdaCallback.c:102-138`). The write + callback reaches the same task because `setWriteCallback(fp, useAdaCallback = true)` defaults to + the ada path (`BLECharacteristic.h:108`, dispatched at `BLECharacteristic.cpp:536-542`), and + `src/ble_init.cpp:157` uses the default. **Strict serialization through one FreeRTOS queue** is a + stronger safety argument than the one written in the source comment. +- **`[H4]` does not apply.** `[H4]`'s hazard is a `memset(session_key)` landing mid-`aes_ccm_decrypt`. + Two independent reasons it cannot happen here: (a) nRF's `disconnect_callback` + (`src/device_control.cpp:227-240`) does **not** call `clearEncryptionSession()` at all — it only + runs `cleanupDirectWriteState`/`cleanupPartialWriteOnDisconnect`/`resetPipeWriteState`; and + (b) by the time the drop is requested, `decryptCommand` has already returned — the caller does + `rejectUnauthenticated(command); return;` — so there is no in-flight decrypt on this task + either. Even on the rare inline-fallback path where `ada_callback` fails on `rtos_malloc` and + `_wr_cb` runs on the BLE task (`BLECharacteristic.cpp:541`), the disconnect stays async and the + teardown still lands on the Callback task afterwards. +- **There is prior art in this repo.** `enterDFUMode()` already calls + `Bluefruit.disconnect(Bluefruit.connHandle())` from command-dispatch context + (`src/device_control.cpp:844-848`). +- **The 0xFE really is on the air first.** nRF's `sendResponseUnencrypted` notifies inline via + `imageCharacteristic.notify()` with no response ring (`src/communication.cpp:375-387`), so the + comment at `:154-157` is accurate. + +**One factual error in the code comments, which should be fixed when someone next touches the +file.** `src/communication.cpp:171-173` claims *"the nRF disconnect callback runs synchronously +from `Bluefruit.disconnect()`"*. It does not — `sd_ble_gap_disconnect` is async +(`BLEConnection.cpp:206`) and the callback is queued (`bluefruit.cpp:849`). This directly +contradicts the correct statement 13 lines earlier at `:158-160`. The *code* is right either way +(clearing `authAbuseDisconnectPending` before the disconnect is the conservative order regardless), +but a future reader relying on that comment would reason wrongly about re-entrancy. + +## Things the plan asserts that the code contradicts + +1. **Decision C's "enforced by linkage rather than by convention"** — half true. See the Decision C + row above and `src/encryption.cpp:152-160`. +2. **Decision E's "Phase 1 leaves that exactly as it is"** — no longer true for + `NONCE_BAD_SESSION`, which now answers `RESP_AUTH_REQUIRED` (`src/communication.cpp:893-897`). +3. **Step 4's `[L7]` policy statement** — "It is the right call" was written without foreseeing + that routing `NONCE_BAD_SESSION` to "does not count" also removes the only mechanism that ever + made a desynced client re-authenticate. `55a2478` restores that path deliberately. The `[L7]` + reasoning is still sound; it was just incomplete. +4. **"CI builds all 11"** (Step 5) and `.github/workflows/main.yaml`'s "11-entry matrix" comment — + the matrix is **12** environments. Cosmetic, but wrong in three places. +5. **The D1 mechanism caveat's framing** — the field failure that was actually reproduced was a + session-identity divergence, not a forward nonce gap. See "What actually happened on the bench". + +## Hard-constraint check — passes + +| Constraint | Result | +|---|---| +| No edit to `include/opendisplay_protocol.h` | ✅ `git diff 02bdd5c..HEAD -- include/` is empty | +| No edit to `include/opendisplay_structs.h` | ✅ same | +| No config-schema change | ✅ no `tools/od-device-cli.py` `BLOCKS` change needed; no struct field added/resized/reordered | +| No new opcode or response code | ✅ `RESP_AUTH_REQUIRED` and `RESP_NACK` are both pre-existing, used in their documented meanings | +| Nothing beyond what `docs/pipe-write-protocol.md` permits | ✅ Step 4b is conformance to §5.2; `55a2478` sends an existing code in a new situation, expressly allowed by the parent plan | +| No Phase 5 work pulled in without acknowledgement | ❌ **`77ebdcd` pulls Phase 5's link-drop forward.** Acknowledged here and in the parent plan. | + +## Unverified on hardware — the honest list + +**Not one item of Step 5's hardware matrix has been run.** Phase 1 has been verified by +compilation (12/12 envs) and by a host-side state-machine test (38199 checks) and by nothing else. +Everything below is still open: + +- **Test 0 — baseline on unmodified firmware.** Never run. The D1 mechanism caveat above therefore + remains **unsettled**, and the "before/after" story for tests 1-2 still rests on an unverified + model. The bench failure that *was* observed (session-id divergence + plaintext fallback) is a + different mechanism entirely, which makes running Test 0 more important, not less. +- **Test 2b — does Step 4b actually let the transfer complete?** ⚠ **This is the one that matters + most and it is completely unverified.** The entire justification for Step 4b is that silently + dropping a nonce-rejected `0x0081` frame lets py-opendisplay's SACK path notice the hole, + retransmit, and finish the upload. Nobody has watched that happen. The code path is + `src/communication.cpp:866-868` — three lines whose correctness is a claim about a client in + another repo. Reading the client supports the claim (the pipe loop blocks on ACK reads, not + per-frame replies) but reading is not running. **Until Test 2b passes on hardware, treat "Phase 1 + saves the transfer" as a hypothesis and "Phase 1 saves the device" as the only supported claim.** +- **Tests 1, 2, 2c** — forward gap within/beyond the cap, and the `blocks_per_ack = 1`, `W = 32` + worst case that motivated the 128 figure. Not run. `OD_NONCE_FORWARD_CAP = 128` is unvalidated + against a real link. +- **Tests 3, 4, 4b** — true replay rejected; replay of the last frame of a session (D3) rejected + with an observable non-idempotent command; the 64× ring-flush replay (`[H3]`) that is the only + test distinguishing "the bitmap closed the widened hole" from "it closed the narrow one". The + host test covers the equivalent state-machine transitions, but not end-to-end over BLE. +- **Test 5** — three forged tags still clear the session. +- **Test 6** — full Spectra transfer and an E1004 ~960 KB upload complete untouched. +- **Unlisted, added by the last two commits and therefore untested by construction:** + - Does the nRF inline disconnect actually drop the link mid-flood? The starvation analysis says + the deferred version could not, but neither version has been observed on a board. + - Does a legitimate client whose session dies mid-pipe-upload get dropped at 10 rejections, and + does HA recover cleanly from that disconnect (as opposed to from the `0xFE` it would otherwise + have seen)? Per the pipe-window arithmetic above this **will** happen with default settings. + - Does `55a2478`'s `AUTH_REQUIRED` actually drive HA's reauth flow end to end? + +--- + +# Reversal of Decision A — the forward cap was removed (2026-07-31) + +**Commit `aef3a6b`, on `fix/nonce-replay-window` (rebased onto the squashed `#132`/`#133`/`#134` +`main`).** Decision A is reversed. `OD_NONCE_FORWARD_CAP` no longer exists, and comparison moved +from modular to numeric ordering. Decisions B, C and D stand. + +## What the cap did + +The cap did not merely fail to help — it converted a transient link fault into a permanent session +fault. Once a gap exceeded 128: + +1. `od_nonce_check()` returns `NONCE_OUT_OF_WINDOW`, so nothing commits. +2. `last_seen_counter` therefore never advances — that is the D2 fix working as designed. +3. The client re-encrypts every retransmission with a **fresh, higher** counter and never resends + the original ciphertext (`_write_pipe_frame`, `device.py:2683`), so the next frame is rejected at + a *greater* distance than the last. +4. Every subsequent frame is rejected, forever. Only re-authentication recovers, and the client + deliberately does not re-authenticate mid-transfer (`device.py:778`). The transfer stalls until + the 15-minute stuck-transfer watchdog releases the panel. + +Step 4b's silent drop does not rescue this. Dropping silently avoids the immediate fatal teardown a +`0x81` NACK would cause, but the transfer is dead either way. + +## Why the cap could not be sized + +Decision A's own framing was right and is what condemns it: the ceiling is the client's retransmit +budget `max_retx = max(3*W, n/2)`, scaled by `blocks_per_ack`, a user-facing Home Assistant option +in another repo. That is order thousands for a full-panel upload — and it **accumulates across +aborted attempts**, because the client's counter keeps climbing while `last_seen` is frozen. With +`W = 32` and `blocks_per_ack = 1`, 16 queued gap-ACKs at `PIPE_RETX_ACK_SPACING = 2` burn 128 +counters on repairs alone. No firmware-side number is defensible. + +## Why removing it costs nothing + +The cap was vestigial once D2 landed. All 8 counter bytes sit inside the CCM nonce, so a tampered +counter changes the keystream and fails the tag; `nonceCommit()` runs only on the success arm; and +passing the check mutates nothing. An attacker who cannot forge a tag could not advance `last_seen` +at any distance, cap or no cap. Nor is it DoS protection: the session id is cleartext in every +frame, so anyone able to flood CCM with a capped window could flood it without one. + +## What replaced it + +Numeric ordering, matching RFC 4303 Appendix A2, which likewise has no forward bound: + +``` +counter == last_seen -> bit 0 set ? REPLAY : OK +counter > last_seen -> OK (any distance; the tag is the gate) +counter < last_seen, back<256 -> bit[back] set ? REPLAY : OK +counter < last_seen, back>=256 -> OUT_OF_WINDOW +``` + +Consequences worth recording: + +- **Modular arithmetic is gone.** It made a counter far behind indistinguishable from one far + ahead, which is what allowed an ancient counter to present as an enormous forward jump — the + "sharp edge" the old header admitted, where committing one would rewind `last_seen` and clear the + bitmap. That is now impossible by construction rather than by the caller's contract. **Do not** + reintroduce a bound as `cap = UINT64_MAX` or as "not-backward implies forward": either restores + the overlap. `test_far_behind_is_never_forward()` pins both. +- **Counters no longer wrap**, per RFC 4303 §3.3.3. Reaching `UINT64_MAX` requires + re-authentication; wrapping would reuse a `(key, nonce)` pair. Unreachable in practice. +- **`NONCE_OUT_OF_WINDOW` now means only "too far behind."** The rejection log computes direction + from the counters instead of inferring it from the reason, which would print an underflowed + 20-digit distance. +- **`OD_NONCE_BACKWARD_BITS` keeps its value but loses its old justification**, which was stated in + terms of the cap ("kept strictly greater than `OD_NONCE_FORWARD_CAP`"). It is now purely + out-of-order tolerance, and its exact value is not load-bearing: a backward rejection is + self-healing, because the retransmit carries a higher counter that is accepted unconditionally. + +## Effect on the rest of this document + +| Item | Status | +|---|---| +| **Decision A** | **Reversed.** No forward cap. | +| **Decision B** — shifting bitmap, `uint64_t[4]`, IPsec/DTLS style | **Stands.** The representation is unchanged; only the arithmetic over it moved from modular to numeric. | +| **Decision C** — file-static wrappers | **Stands**, including its as-built correction. | +| **Decision D** — standalone host test + separate CI job | **Stands.** | +| **Decision E** — no wire change | **Stands.** This reversal changes no byte on the wire: the accept set only grows, so no peer needs updating in lockstep. | +| **Step 6** — "write the mechanism, not a number" | **Stands, and is now literal**: there is no number to write. The `communication.cpp` comment records why there cannot be one. | +| **Step 5 hardware tests 1, 2, 2c** | **Obsolete as written.** They existed to validate the 128 figure. What replaces them is confirming a transfer survives a gap that *would* have crossed it. | +| **"Unverified on hardware"** | Still accurate, and this reversal did not change it: the cap was condemned by analysis, not by the bench. | + +## Verification + +Host suite: **47445 checks** pass under `-Werror` with ASan+UBSan. The same suite run against the +pre-change implementation fails **1635** checks, which is the evidence that the new tests +discriminate rather than merely pass. Added `test_forward_gap_is_not_a_cliff()` (the defect as a +*sequence*, since a point test at `last_seen+129` would also pass under `cap = UINT64_MAX`), +`test_far_behind_is_never_forward()`, and `od_nonce_never_accepts_consumed()` — a sweep over every +counter ever committed, which is the one assertion in the file that does not restate the code. The +oracle no longer prunes its seen set; that pruning encoded the implementation's forgetting and so +could only ever agree with it. + +Builds: `nrf52840custom`, `esp32-c3-N16`, `esp32-N4`. + +Still unverified on hardware, unchanged from the list above. diff --git a/docs/PLAN_PHASE2_BOUND_WAITS_2026-07-26.md b/docs/PLAN_PHASE2_BOUND_WAITS_2026-07-26.md new file mode 100644 index 0000000..21f72ce --- /dev/null +++ b/docs/PLAN_PHASE2_BOUND_WAITS_2026-07-26.md @@ -0,0 +1,1465 @@ +# Phase 2 Implementation Plan — Bound Every Unbounded Wait + +> # ⛔ OBSOLETE — SUPERSEDED +> +> **This plan is no longer the Phase 2 specification.** It was written for a seven-item Phase 2; +> five of those items (P2-1, P2-2, P2-5, P2-6, P2-9) were subsequently cut, leaving a document that +> is mostly struck-through material. +> +> **The current plan is +> [`PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md`](PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md)** — three +> items (P2-3, P2-8, P2-4), two files, written from scratch against the current tree. +> +> **Do not implement from this document.** It is retained for one reason: the analysis behind the +> cut items is expensive to reproduce and is what stops each being re-proposed — +> `[C2]`'s do-not-steal argument for the panel lock, P2-5's arithmetic showing a drain cap is +> powerless, P2-9's rejected alternatives (nRF hardware WDT, idle-hook heartbeat), and the eight +> decisions those items carried. Read it as an appendix to the current plan, not as a plan. + + +**Branch:** `debug/freeze-fix-phase2` (branched from Phase 1 as-built) · **Date:** 2026-07-26 +**Parent plan:** [`PLAN_FREEZE_PROOFING_2026-07-26.md`](PLAN_FREEZE_PROOFING_2026-07-26.md) § "Phase 2" +**Review that shaped it:** [`FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md`](FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md) `[C2] [X1] [X2] [X3] [L3]` + +Phase 2 bounds the **refresh waits**. It adds no new subsystem, no new state machine, and after the +2026-07-26 scope cut no new file and no new task: it puts a wall-clock bound on each place where `loop()` can block indefinitely, and +it makes the one gate the supervisor depends on (`epdRefreshInProgress`) actually cover every +refresh. + +> **Scope, in one line:** three items — P2-3, P2-8, P2-4 (+ optional P2-7) — touching +> `src/display_service.cpp` and `src/display_fastepd.cpp` and nothing else. Everything cut is in the +> appendices, struck through, with its residual recorded. Nothing blocks implementation. +> +> **Adjusted 2026-07-26 for Phase 1 as-built.** Phase 1 has shipped (`02bdd5c`..`d62cb29`) and this +> branch is cut from it, so the "independent, can land before or after" framing is now moot in +> practice: Phase 2 lands *on top of* Phase 1. Three concrete consequences, folded in below. +> +> 1. **The `esp32-N4` RAM gate moved in Phase 2's favour, not against it.** Phase 1 did *not* ship +> `replay_window[256]` (+1,536 B) as this plan assumed. Decision B changed to a 32 B sliding +> bitmap replacing the 512 B ring, so Phase 1 **gave back 480 B**: `esp32-N4` measured +> 81,940 → **81,468 B**. P2-9's ~2 KB task stack has *more* headroom than budgeted here. +> 2. **P2-9's core premise is now confirmed by shipped code, not just by reading the core.** Phase 1's +> `23ecaed` was forced to drop the BLE link *inline from the nRF callback task* precisely because +> `loop()` is starved mid-transfer — the same starvation P2-9 exists to observe. The priority +> facts in the table below were independently re-verified during that work. +> 3. **Phase 1 pulled Phase 5's link-drop forward** (an auth-gate guard that disconnects after 10 +> consecutive unauthenticated commands). Phase 2 does not interact with it, but it means an +> out-of-`loop()` actor that drops the link **already exists** on nRF — relevant context for D-H +> and D-I, which assumed P2-9 would be the first such actor. Neither decision is reopened here. +> +> Line references to `src/main.cpp` and `src/communication.cpp` in this plan were taken against +> `02bdd5c`; Phase 1 modified both, so re-anchor before editing rather than trusting a line number. + +**Both targets, or it does not count.** Every bound here must be *binding* on `nrf52840custom` as +well as the ESP32 envs — executing, measured in a clock that keeps running, and observable when +violated. Most of the original findings were ESP32-shaped and four of them compile out on nRF; § +"Making the bounds binding on both targets" works through the coverage per item and adds the two +items (**P2-8**, **P2-9**) needed to close the nRF side. + +**Scope discipline.** Phase 2 must remain self-contained — `src/session_guard.*` does not exist +yet (Phase 3) and `abortToKnownState()` cannot be called from here. Where Phase 2 produces a +signal that Phase 3 will consume, it exports a flag and a getter and nothing else. Those +hand-off points are marked **→ Phase 3** below. + +--- + +## Verification done before writing this plan + +Two of the parent plan's five Phase-2 findings did not survive re-verification against the actual +sources. `[X1]` was already downgraded by the parent plan. `[X3]` is downgraded here for the same +reason: the claim was made from the shape of the firmware-side stub without reading the library +underneath it. + +| Parent claim | Verified status | +|---|---| +| `[C2]` `pwrmgmLockTake` spins forever; a steal is unsafe | **CONFIRMED.** [display_service.cpp:401-408](../src/display_service.cpp) is `while (exchange(&pwrmgmLock,1)) delay(1);` with no deadline. Legit holds really do reach 30 s: `bbepWaitBusy` uses `iMaxTime = 30000` for `BBEP_3COLOR\|4COLOR\|7COLOR` (`bb_ep.inl:3966-3968`), and `epdSessionForceOffLocked` holds the lock across that. The steal is unsafe exactly as described — `pwrmgmLock` is a bare `volatile uint8_t` ([main.h:185](../src/main.h)) with no owner field. | +| `powerOff` stuck-button loop | **CONFIRMED.** [power_latch.cpp:85-90](../src/power_latch.cpp) — `while (digitalRead(buttonPin()) == LOW) delay(20);` with no bound. ESP32-only file (`#if defined(TARGET_ESP32)`, [:3](../src/power_latch.cpp)). | +| `[X2]` boot refreshes bypass `epdRefreshInProgress` | **CONFIRMED.** The flag is set in exactly two places — [display_service.cpp:2415/2436](../src/display_service.cpp) (direct-write END refresh) and [:3284/:3294](../src/display_service.cpp) (partial refresh). Neither boot path sets it: `refreshBootScreenFull` [:533-542](../src/display_service.cpp) and the FastEPD boot path [:1586-1594](../src/display_service.cpp). | +| `[X3]` FastEPD refresh is unbounded | **WRONG — DOWNGRADED.** Every FastEPD/IT8951 wait in the vendored library is already bounded: `it8951WaitForLUTReady` breaks at **30 000 ms** (`FastEPD.inl:2027-2037`) and `it8951WaitForReady` breaks at **3 000 ms** (`:1908-1919`). `bbepFullUpdate` on a parallel panel is a fixed-trip-count DMA loop with no busy-wait at all (`:2732-3040`), and on `BB_PANEL_IT8951` it short-circuits to `it8951WriteFramebuffer*Bit` (`:2745-2754`), which waits LUT-ready at entry. **There is a real defect here, but it is the opposite one** — see D-D. | +| `[L3]` `CONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` is inert | **CONFIRMED.** The symbol appears in 9 ESP envs (`platformio.ini:53, 83, 112, 140, 189, 209, 229, 253, 295`) and in **no** IDF 5.x sdkconfig. The real setting, from the precompiled `sdkconfig.h`, is `CONFIG_ESP_TASK_WDT_TIMEOUT_S 5` with `CONFIG_ESP_TASK_WDT_PANIC 1` and `CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 1`. So the true TWDT is **5 s / panic-reboot on IDLE0 starvation**, and today's 30–60 s waits survive only because every one of them yields (`delay`/`vTaskDelay`/`bbepLightSleep`). | + +--- + +## What Phase 2 changes + +Eight independent work items (P2-5 dropped — see D-F). Each is separately revertable; none depends +on another. + +| # | Item | Files | Targets | Risk | +|---|---|---|---|---| +| ~~P2-1~~ | ~~Bound `pwrmgmLockTake` (60 s deadline, no steal)~~ — ❌ **DROPPED** (owner decision) | — | — | — | +| ~~P2-2~~ | ~~`powerOff` stuck-button wait bounded at 10 s~~ — ❌ **DROPPED** (owner decision) | — | — | — | +| **P2-3** | `epdRefreshInProgress` around both boot-refresh paths | `display_service.cpp` | both | Low | +| **P2-4** | Make `fastepd_wait_refresh` a real bounded LUT wait | `display_fastepd.cpp` | ESP32/E1004 | Medium | +| ~~P2-5~~ | ~~Wall-clock cap on the loop command drain~~ — ❌ **DROPPED** (rationale did not survive checking; see D-F) | — | — | — | +| ~~P2-6~~ | ~~Delete the inert TWDT flag; document the real one~~ — ❌ **DROPPED** (owner decision) | — | — | — | +| **P2-7** | *(optional)* `Wire.setTimeOut(25)` — **ESP32 only, the API does not exist on nRF** | `display_service.cpp` | ESP32 | Low | +| **P2-8** | `waitforrefresh` → wall-clock deadline, not an iteration count | `display_service.cpp` | **both (nRF-critical)** | Low | +| ~~P2-9~~ | ~~Loop-liveness heartbeat + monitor task~~ — ❌ **DROPPED** (owner decision) | — | — | — | + +> **Scope cut 2026-07-26 (owner decision): P2-1, P2-2, P2-6 and P2-9 are DROPPED**, joining P2-5. +> Phase 2 is now **P2-3, P2-4, P2-8**, with P2-7 still optional. Each dropped item keeps its +> full specification below, struck through, with its residual cost stated. +> +> What survives is exactly the refresh path: the real `waitforrefresh` deadline (P2-8), the real +> FastEPD wait (P2-4), and `epdRefreshInProgress` covering the boot paths (P2-3). Everything that +> added a new mechanism is gone (the lock deadline, the button bound, the monitor task), and so is +> the one documentation-only item (P2-6). **Three items, two files, one subsystem.** +> +> **Net effect on the phase's own thesis.** Phase 2 opened by defining a bound as binding only if it +> (1) executes, (2) has a live timebase, and (3) is observable by a third party. With P2-9 dropped, +> **condition 3 is satisfied by nothing on either target**, and with P2-1 dropped the panel lock +> fails condition 1 as well. Phase 2 is now a *narrower* claim than it set out to make: it bounds +> the refresh paths, and defers detection of a stalled `loop()` to Phase 6. + +Suggested landing order: **P2-3 → P2-8 → P2-4** (+ P2-7 if D-G flips). All three are low-risk +and independent. Phase 2 no longer touches `src/main.cpp` or `platformio.ini` at all, so there is no conflict with +Phase 3's `[M5]` drain-trap fix. + +--- + +## Making the bounds binding on both targets + +The first draft of this plan was implicitly ESP32-shaped: four of the seven original items compile +out on nRF, and the one enforcement mechanism it leaned on (the IDF task watchdog) does not exist +there. This section states what "binding" has to mean, checks each item against it per target, and +added the two items (**P2-8**, **P2-9**) needed to close the nRF side. + +> **After the scope cut, only P2-8 of those two survives.** P2-9 was the answer to condition 3, so +> the analysis below still stands as *diagnosis* but Phase 2 no longer *treats* the third condition. +> Read the coverage table as "what Phase 2 bounds" (P2-3, P2-4, P2-6, P2-8) plus a record of what +> was knowingly left unbounded (P2-1, P2-2) and unobserved (P2-9). + +### A bound is binding only if all three hold + +1. **It executes on that target** — the deadline code is not `#ifdef`'d away, and its consumers exist. +2. **Its timebase advances while the thing it bounds is stuck** — a deadline measured in a clock that + stops when the fault occurs is not a deadline. +3. **A violation is observable by something other than the blocked party** — otherwise the bound + only protects against faults that were already going to resolve. + +(1) is a coverage question, (2) is an nRF timebase question, (3) is the "watchdog" question proper. + +### Verified platform facts + +ESP32 facts are from the precompiled `sdkconfig.h` (see `[L3]` above). nRF facts are from +`~/.platformio/packages/framework-arduinoadafruitnrf52-seeed`, the core the `nrf52840custom` env +actually builds against (`platformio.ini:29-32`). + +**The two nRF priority rows are no longer theory.** Phase 1's `23ecaed` had to move the BLE +link-drop *inline into the callback task* because deferring it to `loop()` did not execute during a +transfer — the loop task (priority 1) is starved by the callback task (2) and the Bluefruit task +(3), and on nRF `loop()` reaches its service calls only after `idleDelay(sleep_timeout_ms)`. That is +the same starvation P2-9 exists to detect, now observed on hardware rather than inferred. It +strengthens P2-9's case and is the strongest single argument that a loop-serviced bound is **not** +binding on nRF mid-transfer — the premise behind condition 2 below. + +The same work established that `Bluefruit.disconnect()` is safe from callback context (it defers to +`sd_ble_gap_disconnect()`, and the disconnect callback is serialized behind the write callback on +the one `ada_callback` queue). Useful precedent if any Phase 2 item is ever tempted to act from +outside `loop()` — though nothing in Phase 2 currently needs to. + +| | ESP32 (Arduino / IDF 5.5.4) | nRF52840 (Adafruit/Seeed core) | +|---|---|---| +| RTOS | FreeRTOS, dual-core (S3/classic), single (C3/C6) | FreeRTOS, single core, `configMAX_PRIORITIES 5`, tick **1024 Hz** (`FreeRTOSConfig.h:55-56`) | +| `loop()` task priority | 1 | 1 — `TASK_PRIO_LOW` (`cores/nRF5/main.cpp:88`, `rtos.h:58`) ✅ re-verified in Phase 1 | +| Higher-priority tasks | NimBLE host task | Callback task = 2, **Bluefruit task = 3** (`rtos.h:59-61`) ✅ re-verified in Phase 1 | +| `delay()` | `vTaskDelay` — yields | `vTaskDelay` — yields (`cores/nRF5/delay.c:33-49`) | +| **`millis()` source** | `esp_timer_get_time()/1000` — **hardware timer** | **`tick2ms(xTaskGetTickCount())` — FreeRTOS tick** (`delay.c:29-31`, `rtos.h:65`) | +| Tickless idle | n/a for the timebase | `configUSE_TICKLESS_IDLE 1` (`FreeRTOSConfig.h:52`) | +| Task watchdog | `CONFIG_ESP_TASK_WDT_TIMEOUT_S 5`, `_PANIC 1`, `_CHECK_IDLE_TASK_CPU0 1` | **none** | +| Hardware WDT in use | no | **no** — `NRF_WDT` never started; no `wdt` symbol anywhere in `src/` | +| Idle hook | via `esp_register_freertos_idle_hook()` | `configUSE_IDLE_HOOK 1`, `vApplicationIdleHook` is a **weak alias to `__empty`** (`cores/nRF5/hooks.c:33`) — free to override | +| Reset-cause reporting | `esp_reset_reason()`, decoded at [main.cpp:26-37](../src/main.cpp) | nothing equivalent wired up | + +### Item-by-item coverage + +| Item | ESP32 | nRF | Action | +|---|---|---|---| +| ~~**P2-1**~~ lock deadline ❌ dropped | — | — | **left unbounded**; `pwrmgmLockTake` keeps its infinite spin on both targets | +| ~~**P2-2**~~ `powerOff` ❌ dropped | — | **n/a** — whole file is `#if defined(TARGET_ESP32)` ([power_latch.cpp:3](../src/power_latch.cpp)); nRF has no latch path and therefore no stuck-button loop | none | +| **P2-3** `epdRefreshInProgress` | ✅ 4 consumers | ⚠️ **flag is set but has ZERO consumers on nRF** — all four live in ESP32-only code ([ble_init.cpp:236](../src/ble_init.cpp), [main.cpp:322](../src/main.cpp), [:478](../src/main.cpp), + Phase 6) | set it anyway (correct, cheap, and Phase 6 adds the nRF consumers); **note the inertness in the commit message** | +| **P2-4** FastEPD | ✅ | **n/a** — `OPENDISPLAY_FASTEPD` is ESP32-only (`platformio.ini:54, 84, 113, 254`) | nRF's only refresh bound is `waitforrefresh` → **P2-8** | +| ~~**P2-5**~~ drain cap ❌ dropped | — | **n/a by design** — nRF has no command queue; `imageDataWritten` runs inline on the Bluefruit **callback task (prio 2)**, which *preempts* `loop()` (prio 1) | see "different failure mode" below | +| ~~**P2-6**~~ TWDT flag ❌ dropped | — | — | inert flag left in place on ESP32; nRF's watchdog absence left undocumented | +| **P2-7** `Wire.setTimeOut` | ✅ default 50 ms, settable | ❌ **API absent**, and the TWIM driver busy-spins with *no* timeout (`Wire_nRF52.cpp:166-181`) | ESP32-only; the nRF gap is **D-L** | + +Two structural observations fall out of that table: + +**The nRF `loop()` body is four function calls.** Lines [519-529](../src/main.cpp): `idleDelay`, +`ble_nrf_advertising_tick`, `processButtonEvents`, `processTouchInput`, `buzzerService`. Everything +else in `loop()` — the drain, `serviceBleDisconnectCleanup`, the 900 s direct-write watchdog at +[:436-442](../src/main.cpp), `checkPartialWriteTimeout()` at [:443](../src/main.cpp), the +`workInFlight`/deep-sleep gate — is inside the `#ifdef TARGET_ESP32` arm. This is the same finding +the parent plan records for Phase 6 ("nRF has NO transfer watchdog today"); it applies equally to +Phase 2's coverage and is why P2-9 must not be written as an ESP32 addition with an nRF port bolted on. + +**nRF's blocking failure mode is inverted, not absent.** On ESP32 a long command blocks `loop()`, +which is the only drainer, so everything stops. On nRF a long command blocks the **callback task +(prio 2)**, while `loop()` (prio 1) keeps running — so buttons, touch, buzzer and `epdSessionTick` +stay alive, but no further BLE writes are serviced. Worse, per the parent plan's `[H4]`, Bluefruit +falls back to invoking the write callback **inline on the BLE task (prio 3)** when `rtos_malloc` +fails, and *that* blocks the stack itself. nRF therefore needs no drain cap, but it does need an +observer that is not the loop task — which is exactly P2-9. + +### The nRF timebase hazard (condition 2) + +**Every deadline in this plan is `millis()`-based, and on nRF `millis()` is derived from the +FreeRTOS tick, not from hardware.** `millis()` is `tick2ms(xTaskGetTickCount())` (`delay.c:29-31`). +If the tick stops, every Phase 2 bound silently stops counting — precisely in the pathological case +it was written for. + +How much does this actually cost? Checked case by case: + +- **Cooperative blocking (what Phase 2 targets): SAFE.** Every long wait yields via `vTaskDelay`, so + the tick keeps running and `millis()` advances normally. `pwrmgmLockTake`'s `delay(1)`, + `waitforrefresh`'s `delay(10)`, and `bbepWaitBusy`'s `bbepLightSleep(20, …)` all qualify — and + note that `bbepLightSleep` is a **plain `delay()` off ESP32** (`bb_ep.inl:3947-3950`), so the + 30 s `bbepWaitBusy` cap that P2-1's 60 s is derived from is sound on nRF too. +- **Tickless idle: SAFE.** `configUSE_TICKLESS_IDLE 1` suppresses ticks during idle, but + `vPortSuppressTicksAndSleep` compensates the count on wake, so `millis()` stays monotonic. +- **Interrupts disabled / critical section / SoftDevice storm: NOT COVERED.** Ticks are lost and + never compensated; `millis()` stalls and the 60 s deadline never expires. + +The third case is the parent plan's already-accepted "true CPU/peripheral hard hang" residual, and +no software-only mechanism recovers from it. **The correct action is to document it, not to +engineer around it** — a DWT-cycle-counter timebase (`dwt_enable()` / `DWT->CYCCNT` are already +exposed in `cores/nRF5/delay.c:52-58`) would work but wraps every ~67 s at 64 MHz, needs wrap +accumulation, and buys nothing for a fault class we have already accepted. Recorded so it is not +rediscovered. + +--- + +## P2-3 — `epdRefreshInProgress` around both boot-refresh paths + +Two edits, both mechanical. The flag is `volatile bool` at +[display_service.cpp:85](../src/display_service.cpp), declared in +[display_service.h:77](../src/display_service.h). + +**bb_epaper boot path** — [display_service.cpp:533-542](../src/display_service.cpp). Wrapping it +here covers both call sites ([:1624](../src/display_service.cpp) and the retry at +[:1632](../src/display_service.cpp)): + +```c +static bool refreshBootScreenFull() { + if (!writeBootScreenWithQr()) { od_log_warn("Boot screen render failed"); return false; } + od_log_info("EPD refresh: FULL (boot)"); + touchSuspendForEpdRefresh(); + epdRefreshInProgress = true; + bbepRefresh(&bbep, REFRESH_FULL); + bool ok = waitforrefresh(60); + epdRefreshInProgress = false; + return ok; +} +``` + +**FastEPD boot path** — [display_service.cpp:1586-1594](../src/display_service.cpp). Set before +`fastepd_full_update()`, clear after `waitforrefresh(60)` and **before** `epdSessionForceOff()` +(the force-off is teardown, not refresh, and holding the flag across it would block the very +Phase-6 supervisor pass that might need to act). + +### Why this matters more than it looks + +Three consumers gate on this flag and all three are wrong today during a boot refresh: + +- [ble_init.cpp:236](../src/ble_init.cpp) — advertising restart deferral. +- [main.cpp:322](../src/main.cpp) — `serviceBleDisconnectCleanup` deferral. +- [main.cpp:478](../src/main.cpp) — the `workInFlight` deep-sleep gate. + +A 30–60 s Spectra boot refresh is currently invisible to all of them, and **→ Phase 6** adds a +fourth consumer (the supervisor's "never interrupt a refresh" rule) that would inherit the same +hole. P2-3 is a prerequisite for Phase 6 being correct, not just a tidy-up. + +--- + +## P2-8 — `waitforrefresh`: wall-clock deadline, not an iteration count + +[display_service.cpp:747-776](../src/display_service.cpp). The bb_epaper wait — **the only refresh +bound nRF has** — counts iterations, not time: + +```c +for (size_t i = 0; i < (size_t)(timeout * 100); i++){ + delay(10); + ... +} +``` + +Each iteration is `delay(10)` = *at least* 10 ms. Under preemption it is more, and on nRF the loop +task is the **lowest** non-idle priority (1), sitting under the callback task (2) and the Bluefruit +task (3). So `waitforrefresh(60)` is a bound of "60 s of loop-task scheduling", not 60 s of wall +clock — it can overrun arbitrarily while a BLE transfer keeps the higher-priority tasks busy. It +also mis-reports: the `"Refresh took %.2f seconds"` line divides `i` by 100 and is wrong by the same +factor. + +Same defect on ESP32, less visible there because the loop task is less contended. + +```c +bool waitforrefresh(int timeout){ + ... + // Wall-clock deadline, not an iteration count. delay(10) is a *minimum*: on + // nRF the loop task is the lowest non-idle priority (1) under the Bluefruit + // callback task (2) and BLE task (3), so counting iterations bounds + // loop-task scheduling rather than elapsed time and can overrun arbitrarily + // during a transfer. + const uint32_t deadline = millis() + (uint32_t)timeout * 1000u; + uint32_t polls = 0; + const uint32_t t0 = millis(); + while ((int32_t)(millis() - deadline) < 0) { + delay(10); + if (polls % 50 == 0) od_log_raw("."); + if (!bbepIsBusy(&bbep)) { + if (polls == 0) { + od_log_error("ERROR: Epaper not busy after refresh command - refresh may not have started"); + return false; + } + od_log_raw(".\n"); + od_log_info("Refresh took %u ms", (unsigned)(millis() - t0)); + return true; + } + polls++; + } + od_log_warn("Refresh timed out after %u ms", (unsigned)(millis() - t0)); + return false; +} +``` + +Preserve the `polls == 0` check exactly — the existing comment at +[:754-757](../src/display_service.cpp) explains that BUSY asserts within µs of `MASTER_ACTIVATE`, so +a first poll at 10 ms is what makes "never went busy" a valid error. Do not reorder the increment. + +This is the cheapest item in the plan with the largest nRF-side effect: it converts nRF's single +refresh bound from advisory to real. + +--- + +## P2-4 — Make `fastepd_wait_refresh` a real, bounded wait + +### Correcting `[X3]` + +The parent plan says FastEPD is unbounded and asks for a busy poll "honouring `timeout_sec`". That +is not the defect. Every wait in the library is already capped (30 s LUT, 3 s HRDY, fixed-trip DMA +loops). The actual defect is the mirror image: + +> `fastepd_wait_refresh` returns **immediately** ([display_fastepd.cpp:228-231](../src/display_fastepd.cpp)), +> and the refresh functions it is paired with return **before the panel has finished refreshing**. +> So the caller believes the refresh is complete and proceeds to cut power. + +Trace it: `fastepd_direct_refresh(1)` ([:276-284](../src/display_fastepd.cpp)) → +`it8951_fullscreen_du()` ([:132-180](../src/display_fastepd.cpp)) → issues +`it8951DisplayArea1Bit(...)` then `it8951WaitForReady(st)`, which waits **HRDY** (host interface +ready, 3 s cap) and *not* **LUTAFSR** (waveform complete). The panel is still painting when the +function returns. `waitforrefresh(60)` then routes to the stub +([display_service.cpp:749](../src/display_service.cpp)) and returns instantly, and +`cleanupDirectWriteState(false)` → `epdSessionRelease()` can call `einkPower(0)` / `deInit()` +mid-waveform. + +The library gets away with this internally because `it8951WriteFramebuffer1Bit` waits LUT-ready at +**entry** (`FastEPD.inl:2125`) — the next operation absorbs the previous one's tail. Our code path +does not have a next operation; it has a power-down. + +### The fix + +```c +// display_fastepd.cpp — replace the stub at :228-231 +// Real wait: block until the IT8951 waveform LUT is idle. The refresh entry +// points (it8951_fullscreen_du / bbepFullUpdate->it8951WriteFramebuffer*Bit) +// return once the image is LOADED and HRDY is back, NOT once the panel has +// finished painting -- the library relies on the *next* operation's entry-side +// LUT wait to absorb that. Our next operation is a power-down, so we must do +// the wait here or we cut the rail mid-waveform. +// +// The bound is the library's own: it8951WaitForLUTReady breaks at 30 000 ms +// (FastEPD.inl:2027-2037). timeout_sec is therefore advisory and is logged when +// it is tighter than the library's cap; we do not reimplement the register poll. +bool fastepd_wait_refresh(int timeout_sec) { + if (s_init_failed) return false; + FASTEPDSTATE* st = g_epd.state(); + if (!st) return false; + const uint32_t start = millis(); + it8951WaitForLUTReady(st); // hard-bounded at 30 s inside the library + const uint32_t waited = millis() - start; + if (waited >= 30000u) { + od_log_error("[FastEPD] LUT-ready timeout (%u ms) - refresh incomplete", (unsigned)waited); + return false; + } + if (timeout_sec > 0 && waited > (uint32_t)timeout_sec * 1000u) { + od_log_warn("[FastEPD] refresh took %u ms (caller budget %ds)", (unsigned)waited, timeout_sec); + } + return true; +} +``` + +`it8951WaitForLUTReady` is already declared at +[display_fastepd.cpp:15](../src/display_fastepd.cpp), so no new extern is needed. + +### Coverage + +This one change covers **every** FastEPD refresh, because they all funnel through +`waitforrefresh()` → `fastepd_wait_refresh()`: + +- `fastepd_direct_refresh` + `waitforrefresh(60)` — [display_service.cpp:2422-2423](../src/display_service.cpp) *(the path a real transfer takes)* +- `fastepd_full_update` + `waitforrefresh(60)` — [display_service.cpp:1591-1592](../src/display_service.cpp) *(boot)* +- `fastepd_partial_refresh` — [display_service.cpp:3287](../src/display_service.cpp) + +The parent plan asked for `fastepd_direct_refresh` specifically; wrapping the *wait* instead of +each *refresh* gets all three for free and cannot be bypassed by a future fourth caller. + +### Blast radius + +`OPENDISPLAY_FASTEPD` is defined in 4 envs (`platformio.ini:54, 84, 113, 254`) and the code path is +live only when `fastepd_driver_used()` — i.e. IT8951/E1004 panels. **This change makes refreshes +take measurably longer on those boards** (they were previously returning early). That is the point, +but it means the E1004 ~960 KB upload regression test is mandatory, not optional. + +--- + +## P2-7 — *(optional)* `Wire.setTimeOut(25)` — **ESP32 only** + +### What the call actually does + +`TwoWire::setTimeOut(uint16_t timeOutMillis)` sets the per-transaction I2C timeout that Arduino-ESP32 +passes down to `i2cRead()` / `i2cWrite()` (`framework-arduinoespressif32/libraries/Wire/src/Wire.cpp:458, +:518, :525`). **The default is 50 ms** (`Wire.cpp:44`, `_timeOutMillis(50)`), and the firmware never +calls the setter, so every I2C transaction that fails to complete blocks the calling task for 50 ms. + +Per the parent plan's downgraded `[X1]`: the GT911 driver already self-disables after 5 consecutive +read failures ([touch_input.cpp:39](../src/touch_input.cpp), [:642](../src/touch_input.cpp)), so the +worst case is **~5 × 50 ms = ~250 ms of blocked `loop()`** before the controller is dropped for good. +`setTimeOut(25)` halves that to ~125 ms. That is the entire benefit — a latency trim, not a new bound. + +Call sites, all after a successful `Wire.begin()`: [display_service.cpp:786](../src/display_service.cpp) +and [:794](../src/display_service.cpp) (in `wireBeginForOpenDisplay`), [:872](../src/display_service.cpp) +and [:885](../src/display_service.cpp) (in `initOrRestoreWireForOpenDisplay`), and +[:920](../src/display_service.cpp). + +### ⚠️ Correction: ESP32-only, and nRF's situation is worse + +An earlier revision of this plan listed P2-7 as applying to both targets. That was wrong twice over: + +**1. The API does not exist on nRF.** `setTimeOut` (capital O) is an Arduino-ESP32 extension. The +Adafruit/Seeed core's `TwoWire` (`libraries/Wire/Wire.h:33`) does not declare it; it only inherits +`Stream::setTimeout` (lowercase o), which is a *stream read* timeout with nothing to do with I2C +transactions. + +**2. nRF's I2C driver has no timeout at all — it spins forever.** `Wire_nRF52.cpp` waits on TWIM +events with bare, non-yielding busy-loops: + +```c +while(!_p_twim->EVENTS_RXSTARTED && !_p_twim->EVENTS_ERROR); // :166 +while(!_p_twim->EVENTS_LASTRX && !_p_twim->EVENTS_ERROR); // :169 +while(!_p_twim->EVENTS_STOPPED); // :175 <- no ERROR check +while(!_p_twim->EVENTS_SUSPENDED); // :181 <- no ERROR check +``` + +(Same shape on the write path at `:230-247`.) No bound, no yield, and the last two do not even +inspect `EVENTS_ERROR`. + +Scope of the exposure, stated honestly: a **disconnected or NACKing** device is fine — TWIM raises +`EVENTS_ERROR` and the first two loops exit. The unbounded case is a genuine **bus lockup** (a +peripheral holding SDA low, a missing or weak pull-up), where neither the completion event nor the +error event ever arrives and the calling task hangs permanently. Rarer than a NACK, but it is exactly +the GT911-wedge scenario `[X1]` was originally written about — and on nRF there is no +`TOUCH_I2C_FAIL_DISABLE_THRESHOLD` escape, because the driver never returns to be counted. + +I2C is genuinely used on nRF: `sensor_sht40.cpp` includes `` and calls +`Wire.beginTransmission()` with no target guard. + +### Recommendation + +**Still defer P2-7 itself.** 250 ms is not a freeze; the setting is global to the bus, so it also +applies to SHT40, BQ27220 and AXP2101, and a 25 ms ceiling is not obviously safe for a +clock-stretching sensor. If deferred, record it as accepted-as-is alongside `[X1]`. + +**The nRF finding is a separate, new question — see D-L.** It is a real unbounded, non-yielding +wait, which is precisely Phase 2's subject matter, and `setTimeOut` cannot fix it because the API is +absent. It deserves its own decision rather than being folded into an optional latency trim. + +--- + +## Decisions needed + +> **After the scope cut, nothing blocks implementation.** D-A, D-A2, D-B, D-C and D-E existed only +> to shape P2-1; D-H, D-I and D-J only to shape P2-9. All eight are moot and struck through below, +> retained because their analysis is the useful part if either item is ever revived. +> +> **Still live:** **D-D** (accept the `[X3]` downgrade — recommend yes), **D-G** (include P2-7 — +> recommend no, defer), **D-K** (accept the nRF tick-derived `millis()` limitation — recommend yes), +> and **D-L** (nRF I2C busy-spins with no timeout: bound it or accept it). All four have a +> recommendation that can be taken as the default, so P2-3/P2-4/P2-6/P2-8 can start immediately. + +### D-D — Accept the `[X3]` downgrade? *(recommendation: yes)* + +The parent plan's "implement a real busy poll against the LUT-busy register honouring +`timeout_sec`" is superseded by P2-4, which calls the library's existing bounded poll instead of +reimplementing it. This is a **correction to the parent plan** and should be folded back into +`PLAN_FREEZE_PROOFING_2026-07-26.md` § Phase 2 the same way `[X1]` was, so review does not +re-litigate it. It also means the parent plan's Verification line "FastEPD refresh has no +firmware-side bound" needs rewording. + +### D-F — Keep P2-5 at all? — ✅ **RESOLVED: dropped** + +*Confirmed 2026-07-26.* **P2-5 is out of Phase 2 entirely** — no cap, no constant, no saturation +WARN. The drain loop is untouched by this phase. Phase 3's `[M5]` becomes the sole edit to that +region; the ring-saturation diagnostic, if wanted, belongs to Phase 7's `[H1]`. + +Reframed from "what value for `COMMAND_DRAIN_BUDGET_MS`" — the value question was moot once the +item went. Per the P2-5 section: a 2 s cap never fires (32 DATA frames ≈ 0.1–1 s), cannot interrupt the +one genuinely long command (a 30–60 s refresh — the check is between commands), and the stacked- +refresh case it *would* catch is unreachable +([display_service.cpp:2366](../src/display_service.cpp)). The drain is already bounded by +33 × (per-command time), with per-command time bounded by P2-1 / P2-4 / P2-8. + +Positions considered: + +1. **Drop entirely** — ✅ **chosen.** Also removes the `[M5]` merge conflict with Phase 3. +2. **Replace with a 2-line saturation WARN** on `drained == COMMAND_QUEUE_SIZE` — rejected for + Phase 2; handed to Phase 7's `[H1]` queue-full work, where the ring is already the subject. +3. **Keep the 2 s cap** — rejected. Only defensible as TWDT margin against a hypothetical future + where commands get 5× slower. Today's margin is already 5×. + +This is a **correction to the parent plan**, like `[X1]` and `[X3]`, and should be folded back into +`PLAN_FREEZE_PROOFING_2026-07-26.md` § Phase 2 ("Loop command drain: 2 s wall-clock cap alongside +the count cap") so it is not re-litigated. + +### D-G — Include P2-7? *(recommendation: no, defer)* + +See P2-7. If deferred, add it to the parent plan's *Deliberately NOT changed* section with the +reasoning, so it does not get rediscovered. Note the scope correction: P2-7 is **ESP32-only** — the +`setTimeOut` API does not exist on the nRF core. + +### D-L — nRF I2C has no timeout and busy-spins forever. Bound it, or accept it? *(new)* + +Discovered while checking D-G's scope. `Wire_nRF52.cpp:166-181` / `:230-247` spin on TWIM events with +no deadline and no yield; a bus lockup hangs the calling task permanently. This is a genuine unbounded +wait on nRF — Phase 2's exact subject matter — and unreachable via `setTimeOut`, which the core does +not implement. + +| Option | Assessment | +|---|---| +| **(a) Accept and document** | Consistent with `[X1]`'s downgrade and with the accepted hard-hang residual. Cheapest. But `[X1]`'s "the driver already gives up after 5 failures" reasoning **does not hold on nRF** — the driver never returns, so nothing counts failures | +| **(b) Wrap our own call sites** with a pre-flight bus check (sample SDA/SCL; skip the transaction if SDA is held low) | Bounded, ~15 lines, no library fork. Does not help if the bus locks *mid*-transaction | +| **(c) Fork/patch the core's Wire** to add a deadline to the four loops | Correct fix, but it is a vendored-core patch that every toolchain update must re-apply. High maintenance for a rare fault | +| **(d) Bound it at the caller** — run I2C only from a context where a hang is survivable | Not achievable; `loop()` is the caller | + +**Recommend (a) for Phase 2, with (b) noted as the cheap follow-up if a lockup is ever observed in +the field.** Rationale: the failure needs a physical bus fault (stuck SDA, bad pull-up), not a +software condition — no client, protocol state, or packet loss can trigger it — so it is outside the +freeze class this effort targets. But it must be recorded honestly rather than inherited from +`[X1]`, whose "the driver gives up" argument is ESP32-specific and does not transfer. + +If (a) is chosen, add it to the parent plan's residual-risk list, **not** to *Deliberately NOT +changed* — it is an accepted gap, not a considered-and-rejected change. + +### D-K — nRF timebase: accept the tick-derived `millis()` limitation? *(recommendation: yes)* + +Per the timebase analysis: every cooperative wait yields, so ticks keep running and all Phase 2 +bounds hold on nRF. Only a scheduler-starving fault (interrupts disabled / SoftDevice storm) stalls +`millis()` — which is the parent plan's already-accepted hard-hang residual. **Recommend accepting +and documenting**, rather than adding a DWT-cycle-counter timebase with 67 s wrap handling for a +fault class we cannot recover from anyway. Add it to the parent plan's residual-risk list. + +--- + +## Files touched + +| File | Items | Targets | +|---|---|---| +| `src/display_service.cpp` | P2-3, P2-8, (P2-7) | both | +| ~~`src/display_service.h`~~ | ~~P2-1 (`panelStateUnknown` extern)~~ — dropped | — | +| ~~`src/session_monitor.cpp/.h`~~ *(new)* | ~~P2-9~~ — dropped, **no new file in Phase 2** | — | +| `src/display_fastepd.cpp` | P2-4 | ESP32 | +| ~~`src/power_latch.cpp`~~ | ~~P2-2~~ — dropped | — | +| ~~`src/main.cpp`~~ | ~~P2-6 (comment)~~ — dropped; **Phase 2 does not touch `main.cpp` at all** | — | +| ~~`platformio.ini`~~ | ~~P2-6~~ — dropped; **no build-config change in Phase 2** | — | +| ~~`docs/TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md`~~ | ~~P2-6~~ — dropped | — | +| `docs/PLAN_FREEZE_PROOFING_2026-07-26.md` | D-D (`[X3]` downgrade), **the scope cut: § Phase 2 must drop P2-1/P2-2/P2-5/P2-9 and move their residuals to Phase 6's remit**, D-K (residual) | — | + +~~`src/session_monitor.*` is deliberately not `src/session_guard.*`…~~ — moot with P2-9 dropped. +**Phase 2 now creates no new file at all**, so Phase 3 owns `src/session_guard.*` with nothing to +coordinate around and nothing to `#include`. + +**No protocol surface is touched.** No `CMD_*`/`RESP_*`, no frame layout, no config-packet layout, +no client-observable behaviour change other than "a FastEPD refresh now completes before the device +reports it complete" — which is a bug fix in the client's favour. Run the parent plan's constraint +check before calling the phase done: + +```bash +cd ../opendisplay-protocol && tools/sync_protocol_header.py --check --only Firmware +cd ../Firmware && git diff main --stat -- include/opendisplay_protocol.h include/opendisplay_structs.h # must be empty +``` + +--- + +## Verification + +### Build + +```bash +pio run -e nrf52840custom -e esp32-s3-N16R8 -e esp32-c3-N16 -e esp32-c6-N4 -e esp32-N4 -e esp32-s3-E1004 +``` + +`esp32-s3-E1004` is added to the parent plan's set because it is the FastEPD/IT8951 gate for P2-4. +CI builds all **12** envs on push (`esp32-s3-N16R8-extuart-debug` is easy to miss when counting +from `platformio.ini`). Items P2-1…P2-8 add no meaningful `.bss` (one `bool`, a few `uint32_t` +locals). **P2-9 adds a ~2 KB task stack, and `esp32-N4` is the gate.** Phase 1 as-built *freed* +480 B there (81,940 → 81,468 B), so the headroom is better than this plan originally assumed — see +"Cost" under P2-9. Compare the new figure against **81,468 B**, not the pre-Phase-1 baseline. + +CI now also runs a `host-tests` job (added by Phase 1) alongside the 12-env matrix; Phase 2 adds +nothing to it unless P2-8/P2-9 grow host-testable pure logic, which is worth considering for the +`waitforrefresh` deadline arithmetic. + +### Static + +- ~~`grep -rn "CONFIG_FREERTOS_WATCHDOG_TIMEOUT_S" platformio.ini` → no hits.~~ — moot, P2-6 + dropped; the flag stays and `platformio.ini` must be **unchanged**. +- ~~`pwrmgmLockTake` call sites / lost-`Give` audit~~ — moot, P2-1 dropped; `pwrmgmLockTake()` keeps + its `void` signature and every existing call site is unchanged. **Confirm the diff does not touch + it**, which is now the check that matters. +- ~~`sessionMonitorHeartbeat` placement, P2-9 stack-size units~~ — moot, P2-9 dropped. +- `git diff --stat` should show **no new files**, and changes confined to + `src/display_service.cpp` and `src/display_fastepd.cpp`. Any touch to `src/main.cpp` or + `platformio.ini` means scope has crept back in. + +### Hardware — both targets + +**Every test below must be run on `nrf52840custom` as well as an ESP32 env.** The parent plan's +Phase 6 note applies here too: do not assume nRF parity from an ESP32 pass. nRF-specific +expectations are called out where the platforms legitimately differ. + +| Test | Expect | +|---|---| +| **P2-1** Force a lock timeout (temporary `pwrmgmLock = 1` from a debug command) | ERROR logged once, panel op skipped, **`loop()` continues**, BLE stays responsive, no reboot. ESP32: proves the yielding wait survives the 5 s TWDT. **nRF: proves `millis()` keeps advancing under the `delay(1)` spin** — the timebase check | +| **P2-2** Jumper the button pin low, issue power-off | ESP32 only. Latch drops at ~10 s with a WARN; note whether the held button re-latches (expected on a latching board) | +| **P2-3** Boot with a Spectra panel, `nRF Connect` scanning throughout | ESP32: advertising restart deferred, no disconnect-cleanup during the boot refresh. **nRF: expect no behaviour change** — the flag has no consumers there yet; confirming the *absence* of a regression is the test | +| **P2-4** E1004: full ~960 KB upload → refresh → immediate power-down | ESP32/E1004 only. Image fully painted; no truncated/ghosted waveform. Compare against a pre-change capture — this is the regression that proves the early return was real | +| **P2-4** E1004 boot refresh | Same, on the boot path | +| ~~P2-5~~ | Dropped — no test. *(If you want the evidence on record, instrument a saturated drain once and log its wall-clock; expect ≪ 2 s, which is why the cap went.)* | +| **P2-8** Refresh during a concurrent BLE transfer, both targets | Reported duration matches a stopwatch. **nRF is the real test**: with the callback task (prio 2) busy, the old iteration count would overrun 60 s while the new deadline holds | +| **P2-8** Disconnect the BUSY line to force a timeout | WARN at ~60 s wall clock on both targets, not later | +| **P2-9** Block `loop()` deliberately (debug command doing a non-yielding busy-wait > 2.5 min) | One ERROR at ~150 s naming the phase breadcrumb, rate-limited to one line per 30 s, one INFO on recovery with the total. **nRF: confirm the prio-2 task actually preempts prio-1 `loop()`** — this is the whole premise | +| **P2-9** Normal operation, 1 h idle + several full transfers | **Zero** stall lines. A false positive here means `LOOP_STALL_WARN_MS` (D-J) is too tight | +| **Regression** Full Spectra transfer (60 s+ refresh), both targets; E1004 ~960 KB upload | Complete untouched, no stall lines | + +Every new bound logs exactly one ERROR/WARN with the reason and the elapsed time — per the parent +plan's blanket requirement. + +--- + +## Residual risk after Phase 2 + +- **A hard peripheral hang inside a single library call is still unbounded from our side.** We bound + the *waits we own*; `bbepWaitBusy`'s own 30 s cap and `it8951WaitForLUTReady`'s 30 s cap are the + library's, and a hang below those (a wedged SPI transaction, a stuck DMA) is invisible to us. + Consistent with the software-only decision. +- **`pwrmgmLockTake` is still unbounded (P2-1 dropped).** A panel-lock holder that never releases + blocks its waiter forever, on both targets. This is the largest residual Phase 2 knowingly leaves; + it was previously the item's whole reason for existing. +- **A stalled `loop()` is undetected on both targets (P2-9 dropped).** ESP32's TWDT will not fire — + every long wait yields, so IDLE0 is never starved — and nRF has no watchdog at all. Phase 2 bounds + the refresh waits but reports nothing when a bound is exceeded by something it does not own. + Detection now waits for Phase 6. +- **`powerOff`'s stuck-button wait is still unbounded (P2-2 dropped).** ESP32-only, needs a hardware + fault, and it removes a recovery path rather than creating a freeze. +- **A single long command still owns `loop()` for its duration** — one 60 s refresh blocks the loop + task for 60 s, and no drain cap can change that (the check would be between commands). This is by + design: interrupting a refresh is worse than waiting for it. What bounds it is P2-4/P2-8, not P2-5. +- **A saturated drain costs ~1 s of unserviced touch/buttons.** Measured-order estimate, not a + freeze, and accepted — see D-F. +- **The inert `CONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` flag stays in 9 ESP envs (P2-6 dropped)**, + still reading like a 120 s watchdog guarantee that does not exist. Harmless to execution, but a + standing trap for the next reader of `platformio.ini`. nRF's total absence of a watchdog also goes + unrecorded. +- **Phase 2's own thesis is only partly delivered.** Of the three conditions for a "binding" bound, + condition 3 (observable by a third party) is now met by nothing, and condition 1 fails for the + panel lock. Phase 2 delivers *bounded refreshes*; it does not deliver *detected stalls*. +- ~~**P2-2 may re-latch.**~~ Moot — P2-2 is dropped, so the latch behaviour is unchanged from today. +- **nRF I2C can hang forever on a bus lockup** (`Wire_nRF52.cpp:166-181`, `:230-247` — bare + non-yielding `while(!EVENTS_x);`). Needs a physical fault (stuck SDA, bad pull-up), not a software + condition, so it is outside the freeze class this effort targets — but `[X1]`'s "the driver gives + up after 5 failures" reasoning is ESP32-specific and does **not** transfer. See D-L. +- **On nRF, `millis()` stops if the scheduler stops** (tick-derived, `delay.c:29-31`). Every Phase 2 + deadline goes with it. Only reachable via interrupts-disabled / critical-section / SoftDevice-storm + faults, which are the already-accepted hard-hang class; all cooperative waits yield and keep the + tick running. Documented rather than engineered around — see D-K. +- **P2-9 detects, it does not recover.** Under the no-reboot decision an observer task has no safe + action against a blocked `loop()`. It converts an invisible field freeze into a timestamped ERROR + naming the phase; the recovery story remains the per-wait deadlines in P2-1/P2-4/P2-8. **A hard + hang is therefore still unrecovered after Phase 2** — deliberately, per D-I, with a rate-limited + flash-persisted reset held as the documented follow-up if soak shows stalls actually occur. +- **`epdRefreshInProgress` remains inert on nRF until Phase 6** adds the consumers. P2-3 sets it + correctly on both targets, but on nRF nothing reads it yet. +- **nRF still has no transfer watchdog after Phase 2** — the 900 s direct-write bound and + `checkPartialWriteTimeout()` live in the `#ifdef TARGET_ESP32` arm of `loop()` + ([main.cpp:436-443](../src/main.cpp)). This is Phase 6's `[H3]` "ADD on nRF" item, restated here + so Phase 2 is not mistaken for having closed it. +## Appendix — decisions made moot by the scope cut + +Eight decisions existed only to shape P2-1 (D-A, D-A2, D-B, D-C, D-E) or P2-9 (D-H, D-I, D-J). +Both items are dropped, so none of them needs an answer. Retained because the analysis is the +part worth keeping if either item is ever revived. + +### ~~D-A~~ — `epdSessionAcquire` signature — ⛔ **MOOT: P2-1 dropped** + +#### The problem + +`epdSessionAcquire` currently returns `bool cold` — a *result* (was the rail off?), not a *status*. +Once `pwrmgmLockTake()` can fail there is nowhere to report "I did not acquire anything." The two +values are not merge-able: `false` already means "warm", which is a success. + +#### Proposed signature + +```c +// display_service.cpp:438 +// Bring the panel up for a transfer/refresh. +// +// Returns true iff the session was acquired. On FALSE the pwrmgm lock timed out: +// nothing was powered, nothing was initialised, panelStateUnknown is set, and the +// caller MUST skip all panel work -- driving SPI/CS without the lock is exactly +// the two-tasks-one-bus hazard the lock exists to prevent. +// +// *outCold is written ONLY when the function returns true, and is the old return +// value: true = the rail was off and we did a COLD bring-up (callers may need to +// reopen the address window). May be NULL if the caller does not care. +static bool epdSessionAcquire(bool partialInit, bool* outCold); +``` + +#### The full cascade — bounded, 6 functions, 4 leaf sites + +This is the real cost of D-A and it is worth stating exactly, because "the compiler finds them all" +is only reassuring if the count is small. It is: + +``` +epdSessionAcquire() 2 call sites +├─ :2083 directWriteActivatePanel() void -> must become bool +│ ├─ :2154 handleDirectWriteStart() ← leaf (protocol handler) +│ └─ :2807 handlePipeWriteStart() ← leaf (protocol handler) +└─ :3253 partial_prepare_panel_ram() void -> must become bool + ├─ :2252 handlePartialWriteStart() ← leaf (protocol handler) + └─ :2792 handlePipeWriteStart() ← leaf (same handler, other arm) +``` + +So: two `void`→`bool` changes on file-static helpers, and **three protocol handlers** that need a +failure branch. Every one of the three is a `*Start` handler, which is the best possible place for +this — the failure happens before any state is committed, so the branch is "NACK and return", not +a teardown. + +The `directWriteActivatePanel` site at [:2083](../src/display_service.cpp) is the one that matters +most: it currently **ignores the return value entirely** and sets `directWriteActive = true` at +[:2075](../src/display_service.cpp) *before* acquiring. If the acquire fails there and nothing +checks, the device latches `directWriteActive` with the panel un-acquired — a wedge of exactly the +kind this whole effort exists to remove. Order the new code so `directWriteActive` is set only +after a successful acquire. + +#### Which driver paths does this affect? — **all three, and both targets** + +`epdSessionAcquire` is **driver-agnostic**. It always takes the lock and always actuates the rail +via `pwrmgm(true)` ([:443](../src/display_service.cpp)); only the controller-init step branches, on +`epdSessionUsesFastepd()` ([:371-377](../src/display_service.cpp)): + +| Path | What `epdSessionAcquire` does inside the lock | Affected by D-A? | +|---|---|---| +| **bb_epaper generic** | `pwrmgm(true)` + `bbepInitIO` + `bbepWakeUp` + `bbepSendCMDSequence` + `epdAlignCustomPartialRamMode` ([:453-456](../src/display_service.cpp), warm re-acquire [:478-481](../src/display_service.cpp)) | **Yes** | +| **E1004 dual-CS** (`#ifdef BBEP_T133A01`) | `pwrmgm(true)` + `e1004InitPanel()` ([:448](../src/display_service.cpp)) | **Yes** | +| **FastEPD / IT8951** | `pwrmgm(true)` **only** — the TCON init happens outside, in `fastepd_direct_write_reset()` / `fastepd_epaper_begin()` | **Yes** (the rail is still the lock's business) | + +So this is emphatically **not** a FastEPD-only change. Note also that `epdSessionUsesFastepd()` +returns `false` unless `TARGET_ESP32 && OPENDISPLAY_FASTEPD`, so **on nRF the bb_epaper branch is +the only one that ever runs** — D-A is fully live on `nrf52840custom`, and both leaf call sites +(`directWriteActivatePanel`, `partial_prepare_panel_ram`) are outside every `#ifdef TARGET_ESP32`. + +#### What the lock does and does not guard — why only `epdSessionAcquire` changes + +Two separate questions hide behind "why only this function": + +**(a) Why no other lock-taker needs a signature change.** `epdSessionRelease` and +`epdSessionForceOff` return `void`, so an early return is a complete response; +`epdSessionTick` already uses `pwrmgmLockTryTake()`; `epdSessionForceOffLocked` is caller-holds. +`epdSessionAcquire` is the only one whose *caller must behave differently* on failure. + +**(b) Why `epdSessionAcquire` is not the only path that touches the panel.** It isn't — and that is +by design, not an oversight. Several paths drive the panel without ever taking the lock: + +| Bypass | Why it is safe today | +|---|---| +| `prepareEpdRailForBoot()` [:173-185](../src/display_service.cpp) — raw `pwrmgm(true/false/true)` | Boot only; BLE is not up, no transfer can exist | +| `initBbepPanelSession()` [:341-355](../src/display_service.cpp) — full `bbepInitIO`+`bbepWakeUp`+init-seq | Boot only, same reason | +| `initDisplay()` [:1567-1600](../src/display_service.cpp) — raw `pwrmgm(true)`, `fastepd_epaper_begin()`, `pwrmgm(false)` | Boot only, same reason | +| `fastepd_prepare_hardware()` at [:2140](../src/display_service.cpp) / [:2803](../src/display_service.cpp) | Runs in the START handler *before* the acquire; loop/callback task, serialized by transfer ownership | +| **All data streaming** — `bbepSetAddrWindow`/`bbepStartWrite` [:2098-2099](../src/display_service.cpp), `e1004_begin_plane()`, `fastepd_direct_write_reset()`, every subsequent `bbepWriteData` | The lock is `Give`n at [:488](../src/display_service.cpp) *before* `epdSessionAcquire` returns | + +**The lock is a rail-and-init guard, not a bus mutex.** Streaming is protected by `pwrmgmState` +instead: `epdSessionTick` acts only in `PWR_WARM`, and a live transfer holds `PWR_ACTIVE`, so the +keep-alive tick cannot rail-cut mid-stream. The lock exists for the narrower race the comment at +[:396-400](../src/display_service.cpp) names — the tick's `ForceOff` landing mid-*init*. This is +coherent and Phase 2 should not widen it; noted here so a reviewer does not read the bypass list as +a new defect. + +**Consequence for D-A's failure branch:** at both leaf sites, `fastepd_prepare_hardware()` has +*already run* by the time the acquire fails ([:2140](../src/display_service.cpp), +[:2803](../src/display_service.cpp)). On the FastEPD path the failure branch should therefore call +`fastepd_mark_hw_deinitialized()` so the next attempt does a full TCON re-init rather than a +`wake()` on a controller whose rail state we no longer know — the same reasoning the existing +comment at [:424-426](../src/display_service.cpp) gives for the rail-drop case. + +#### Call-site shape + +```c +static bool directWriteActivatePanel(void) { + ... + bool cold = false; + if (!epdSessionAcquire(false, &cold)) { + od_log_error("ERROR: panel session acquire failed (lock timeout) - not starting direct write"); + return false; // directWriteActive NOT set; nothing to tear down + } + directWriteActive = true; + directWriteStartTime = millis(); + ... + return true; +} +``` + +and at each leaf, the handler's existing NACK idiom: + +```c + if (!directWriteActivatePanel()) { /* NACK, see D-A2 */ return; } +``` + +#### Why not the alternative + +Keeping `bool cold` and having callers poll `panelStateUnknown` is a smaller diff, but it inverts +the default: every happy path proceeds to drive an un-acquired panel unless someone remembered to +add a check. That is the same failure shape as the `[C4]` finding in Phase 4 (a blind flag-setter +with the guard somewhere else). **Recommend the signature change.** + +`epdSessionRelease` / `epdSessionForceOff` do **not** need signature changes — both return `void` +today and an early return on lock-timeout is a complete response for them (P2-1 table). Only +`epdSessionAcquire` has a caller that must change behaviour. + +### ~~D-A2~~ — What does the START handler NACK with? — ⛔ **MOOT: P2-1 dropped** + +The three leaf handlers must tell the client "not started". Checked against +`include/opendisplay_protocol.h:784-804`: **neither error namespace has a "device busy" or +"internal error" code.** `OD_ERR_PIPE_START_*` 0x04 is unused, but assigning it a meaning is a +protocol change and explicitly out of bounds. + +| Option | Assessment | +|---|---| +| Reuse `OD_ERR_PIPE_START_PARTIAL_UNSUPPORTED` / `OD_ERR_PARTIAL_UNSUPPORTED` | Semantically wrong — tells the client the *mode* is unsupported, so it may permanently stop trying partials | +| Bare 2-byte `[RESP_NACK][opcode]` | **Precedent exists** — `handlePartialWriteStart` already sends `{RESP_NACK, 0x76}` at [display_service.cpp:2232](../src/display_service.cpp). But the header specifies the 4-byte form for 0x80 (`:600-608`), so this needs checking against py-opendisplay's NACK parser before use on the pipe path | +| Claim 0x04 | Out of bounds — protocol change | + +**Recommend the 2-byte form, contingent on a read of py-opendisplay's NACK parsing.** If the client +rejects a short NACK on 0x80, fall back to `OD_ERR_PIPE_START_BAD_HEADER` (0x01) — wrong, but +non-poisoning: the client retries rather than disabling a feature. This is the one point where D-A +touches the no-protocol-change constraint, so settle it before writing the handlers. + +### ~~D-B~~ — Who owns `panelStateUnknown`? — ⛔ **MOOT: P2-1 dropped** (no such flag is produced) + +Proposed: `display_service.cpp` owns it, set only by `pwrmgmLockTake`, cleared only by a successful +`epdSessionForceOff`. Alternative: defer the whole flag to Phase 3 and have Phase 2 only log the +timeout. **Recommend keeping it in Phase 2** — the flag is where the knowledge is, and Phase 3 +just reads it. Confirm that a dead-until-Phase-3 flag is acceptable in review. + +### ~~D-C~~ — Does the panel become unusable after a lock timeout? — ⛔ **MOOT: P2-1 dropped** (there is no lock timeout) + +*Confirmed 2026-07-26.* `pwrmgmLockTake` always attempts the full 60 s take; `panelStateUnknown` +is reported but never suppresses a retry. Aggregate cost is bounded by P2-5's drain budget and +(→ Phase 6) the supervisor. Revisit only if hardware soak shows 60 s retries stacking. Options +considered, retained for the record: + +1. **Keep trying** — every subsequent panel op re-attempts the 60 s take. Simple; a genuinely + wedged lock costs 60 s per operation forever. +2. **Fail fast while set** — `pwrmgmLockTake` returns false immediately when `panelStateUnknown` + is already set, until a successful force-off clears it. Bounds the damage; risks latching the + panel off after one transient. +3. **Fail fast with a retry window** — as (2), but allow one full attempt every N seconds. + + +### ~~D-E~~ — `PWRMGM_LOCK_TIMEOUT_MS` value — ⛔ **MOOT: P2-1 dropped** + +Derived above from `bbepWaitBusy`'s `iMaxTime = 30000`. If someone wants it tighter, the derivation +— not the intuition — has to change. Note that a `pwrmgmLockTake` timeout is *itself* a 60 s block +of `loop()`, which is fine under the 5 s TWDT only because of the yielding `delay(1)`. + +### ~~D-H~~ — Does P2-9 belong in Phase 2 at all? — ⛔ **ANSWERED BY THE DROP: no** + +It is arguably Phase 6's job — it is a supervisor. The case for Phase 2: Phase 6's supervisor runs +*inside* `loop()` and therefore cannot observe a blocked `loop()` on either target, so it is not +the same mechanism and it cannot subsume this one. The case against: Phase 2 is otherwise a set of +local edits with no new files or tasks, and P2-9 is neither. + +**Recommend keeping it in Phase 2** but landing it **last**, so it can be dropped without +disturbing the other eight items. If it moves to Phase 6, the parent plan's Phase 6 section must be +amended to say the supervisor has two arms — in-loop (state stalls) and out-of-loop (loop +blockage) — because as written it only has the first. + +### ~~D-I~~ — detect-and-log vs reboot for P2-9 — ⛔ **MOOT: P2-9 dropped** + +*Confirmed 2026-07-26.* **P2-9 is detect-and-log only.** It logs an ERROR with elapsed time and +phase breadcrumb (rate-limited to one line per 30 s), sets `g_loopStalled` for Phase 6 to report on +resume, and takes **no recovery action**. No reboot, no hardware WDT, on either target. + +Accepted consequence, stated plainly so review does not reopen it: **a genuinely blocked `loop()` +is not recovered by Phase 2.** The recovery story for cooperative blocking remains the per-wait +deadlines in P2-1 / P2-4 / P2-8; for a true hard hang there is none, and the device stays frozen — +still displaying the correct image, since e-paper holds it without power — until a power cycle. +What P2-9 buys is that the event stops being invisible. + +Option (3) below (rate-limited, flash-persisted reset) is **deferred, not rejected**. Revisit only +if hardware soak produces stall lines; if it does, the phase breadcrumb tells us where, which is +the input that proposal needs. Do not implement it speculatively. + +The full trade is retained below because the "no reboot" decision rests on a narrower argument than +it first appears — it rules out *unconditional* reboot, and a future reader will need to know that. + +--- + +Under "software-only, never reboot", an observer task has no safe recovery action: it cannot touch +panel/BLE/session state from another task context. So for the one fault class P2-9 exists to catch +— a genuinely blocked `loop()` — **log-only does nothing**. That makes the reboot option worth +arguing properly rather than dismissing by reference to the earlier decision. + +#### What a reboot actually costs (verified) + +The parent plan's justification is "a reboot wipes RTC incl. `displayed_etag` and forces a +boot-screen redraw." Both halves check out, and the mechanism is worse than the summary suggests: + +- **RTC does not survive a reset at all.** The bootloader reloads RTC memory segments from the app + image on every reset *except* a deep-sleep wake ([main.cpp:95-99](../src/main.cpp), captured on + hardware in `docs/FINDINGS_DEEP_SLEEP_WAKE_BOOT_SCREEN_2026-07-07.md`). Lost: `displayed_etag` + ([main.h:294](../src/main.h)), `deep_sleep_count`, `mloopcounter`, `rebootFlag`, and the cached + WiFi BSSID/channel ([wifi_service.cpp:511-513](../src/wifi_service.cpp)). +- **The redraw is automatic and unconditional.** `is_deep_sleep_wake` false → `rebootFlag = 1` → + `initDisplay()` → full boot-screen refresh ([main.cpp:120-129](../src/main.cpp)). On a Spectra + panel that is 30–60 s of powered refresh. +- **`displayed_etag = 0` forces a full re-upload.** The next partial update gets an ETAG mismatch + and falls back to a full push — so HA re-sends the whole image. +- **It is indistinguishable from a first boot.** The code comment says so explicitly: a hidden + mid-cycle reset "lands here with count 0, indistinguishable from a true first boot." + +#### The case FOR rebooting — stronger than the parent plan allows + +1. **It is the only thing that recovers a hard hang.** Every other bound in Phase 2 assumes the + fault is cooperative (the blocked task yields). If it does not, a reset is the sole remaining + mechanism. "Log and hope" is not a recovery story. +2. **The nRF hardware is already there and unused**, and nRF has *no* recovery mechanism of any + kind today. +3. **A frozen tag self-heals within one push cycle if it reboots.** HA pushes on a schedule; a + device that reboots, redraws a boot screen, and accepts the next push is functional again in + minutes. A frozen one is dead until someone physically intervenes. +4. **The physical fallback is weaker than it sounds.** The parent plan's own residual-risk section + establishes that a long-press cannot power off while `loop()` is blocked — the hold evaluation + runs in `processButtonEvents()`, called only from `loop()`/`idleDelay()`. On a + `DEVICE_FLAG_BATTERY_LATCH` unit the user's fallback is "remove the battery." Against that + baseline, an automatic reboot looks generous. + +#### The case AGAINST — and why it wins for *this* device + +The decisive argument is specific to e-paper, and it is not the etag: + +**A frozen tag still displays the correct image.** E-paper holds its image with no power. A wedge +costs you *updates*, not the display. So the trade is not "broken vs working" — it is: + +> **freeze** = correct image, no updates, silent · **reboot** = *wrong* image (boot screen) for a +> push cycle, updates work, silent + +And then the tail risk, which is what settles it: **the wedge this plan targets is loss-driven and +recurrent.** A reboot-on-stall device that hits it repeatedly gives you a tag that reboots every +few minutes, performs a full-panel refresh each time — the single largest energy cost on a battery +unit — and, because RTC is wiped, reports every one of those boots as a first boot. You get a +battery-destroying reboot loop that is *invisible in telemetry*. That is a worse failure than the +freeze, and it is a realistic one, not a hypothetical. + +The secondary argument: a WDT firing on a **false positive** during a legitimate 60 s refresh +interrupts the refresh mid-waveform, which violates the parent plan's "never interrupt a refresh" +rule and can leave the panel in a bad state. + +**So the original decision holds — but for a sharper reason than "reboots are bad": an unbounded, +unlogged reboot loop on a battery e-paper device is worse than the freeze it fixes.** Note that +this reasoning attacks *unconditional* reboot, not reboot as such. + +#### The positions + +1. **Log only** — ✅ **chosen.** ERROR with elapsed time and phase breadcrumb, rate-limited, + `g_loopStalled` for Phase 6 to report on resume. Closes the *diagnosis* gap; leaves the + *recovery* gap open for hard hangs. +2. **Unconditional reboot on stall** — rejected on the reboot-loop argument above. +3. **Rate-limited, persisted reboot** — the middle the plan does not currently offer, and the only + version of (2) that survives the objection. Reboot on stall **at most once per N hours**, with + the reason and a counter written to **flash, not RTC** (LittleFS is configured on the ESP32 envs + via `board_build.filesystem = littlefs`; the nRF equivalent needs checking) so the reboot is + visible afterwards and the loop is bounded. On exceeding the rate limit, stop rebooting and fall + back to (1). +4. **Log + monitor sets abort flags** for `loop()` to service on resume — this is (1) plus a Phase 3 + dependency, and does nothing a *stalled* `loop()` can act on. Not a distinct position. + +**Resolution: (1), with (3) as the documented follow-up.** Soak tells us the thing we actually need +and do not have: whether a blocked `loop()` ever happens once P2-1, P2-4 and P2-8 are in. If soak +produces zero stall lines, (3) is unnecessary complexity; if it produces them, we will have a phase +breadcrumb saying where, which is worth more than a blind reset. Choosing (3) now would be building +a recovery mechanism for a fault we have not yet observed. + +### ~~D-J~~ — `LOOP_STALL_WARN_MS` value — ⛔ **MOOT: P2-9 dropped** + +Derived: longest legitimate pass = `waitforrefresh(60)` = 60 s (real wall clock after P2-8) plus a +worst-case `pwrmgmLockTake` timeout = 60 s (D-E), so 150 s clears both with margin. Note the +dependency — **if D-E lowers the lock timeout, this can come down with it.** Must stay well under +Phase 6's 600 s supervisor so the two signals are distinguishable in a log. + +## Appendix — dropped items, retained for the record + +Everything below was specified for Phase 2 and then cut. It is kept because the *analysis* is the +expensive part and is what stops each item being re-proposed from scratch — `[C2]`'s do-not-steal +argument, P2-5's arithmetic showing the drain cap is powerless, and P2-9's rejected alternatives +(nRF hardware WDT, idle-hook heartbeat) in particular. + +**None of it is in scope.** Each section opens with what was dropped and the residual it leaves; +the residuals are collected in "Residual risk after Phase 2" above. + +--- + +## ~~P2-9~~ — Loop-liveness heartbeat + monitor task — ❌ **DROPPED** + +*Dropped 2026-07-26 by owner decision.* **Not implemented in Phase 2.** No `src/session_monitor.*`, +no monitor task, no `loop()` heartbeat, no `LOOP_STALL_WARN_MS`. `src/main.cpp` is therefore +**untouched by Phase 2 except for P2-6's comment**, which also removes the last point of contact +with Phase 3's drain-loop edit. + +**This is the most consequential of the three drops, so be explicit about what it costs.** P2-9 was +the only item satisfying **condition 3** — *a violation is observable by something other than the +blocked party*. Without it: + +- **A stalled `loop()` is silent on both targets.** ESP32's TWDT watches IDLE0 starvation at 5 s / + panic, but every long wait here yields, so IDLE0 is never starved and the TWDT will not fire on + any fault Phase 2 was about. nRF has no watchdog at all (`NRF_WDT` is never started). +- **nRF keeps zero out-of-`loop()` observers.** This matters more than it did when the plan was + written: Phase 1 demonstrated on hardware that nRF's `loop()` *is* starved mid-transfer (its + deferred link-drop never executed, forcing the inline disconnect in `23ecaed`). Phase 2 now + bounds several waits it cannot report on for that target. +- **Combined with the P2-1 drop**, a panel-lock holder that never releases is both unbounded and + undetected until Phase 6. + +The `~2 KB` stack and the `esp32-N4` headroom question go away with it; so do D-H, D-I and D-J. + +The original design is retained below for the record — including the rejected alternatives (nRF +hardware WDT, idle-hook heartbeat), which are the useful part if this is ever revived. + +--- + +### The gap *(retained for the record — not implemented)* + +Condition (3) — *a violation is observable by someone other than the blocked party* — is unmet on +**both** targets: + +- **nRF has no observer at all.** No task watchdog, no hardware WDT started, `vApplicationIdleHook` + empty. +- **ESP32's observer is the wrong one.** The TWDT watches IDLE0 starvation with a 5 s timeout and + `_PANIC 1` — it **reboots**, which the user's decision explicitly forbids ("reset state, never + reboot; a reboot wipes RTC incl. `displayed_etag`"). And because every Phase 2 wait yields, IDLE0 + is never starved, so the TWDT will not fire on any fault Phase 2 is about. +- **→ Phase 6's supervisor cannot fill this gap** on either target: it is specified to run *inside* + `loop()`, so a blocked `loop()` means the supervisor never executes. Phase 6 detects *state-machine + stalls*; nobody detects *loop blockage*. Worth flagging back into the parent plan. + +### Design: a heartbeat stamped by `loop()`, read by a higher-priority task + +```c +// session_monitor.h (new; deliberately NOT session_guard.* — that is Phase 3's file) +void sessionMonitorBegin(void); // create the task; call at the end of setup() +void sessionMonitorHeartbeat(const char* phase); // stamp from loop() +bool sessionMonitorLoopStalled(void); // -> Phase 3 / Phase 6 +``` + +- `loop()` calls `sessionMonitorHeartbeat("top")` as its first statement — **outside** every + `#ifdef TARGET_ESP32`, so it is one call on both targets. Optional extra stamps with a phase + breadcrumb (`"drain"`, `"refresh"`, `"wifi"`) make the stall log say *where*. +- The monitor task runs at **priority 2** and `vTaskDelay(1000)`s. Priority 2 is above the loop task + on both targets (ESP32 `loopTask` = 1; nRF `TASK_PRIO_LOW` = 1) and at or below the BLE tasks, so + it preempts a spinning or compute-bound `loop()` but never delays the radio. +- On `millis() - g_loopHeartbeatMs > LOOP_STALL_WARN_MS`: log one ERROR with the elapsed time and + the last phase breadcrumb, then **rate-limit to one line per 30 s** so a long stall does not flood + the log. Set `g_loopStalled`. +- On recovery: log one INFO with the total stall duration, clear `g_loopStalled`. + +Creation is the only platform-specific line: + +```c +#if defined(TARGET_ESP32) + xTaskCreatePinnedToCore(monitorTask, "odmon", 2048, NULL, 2, NULL, ARDUINO_RUNNING_CORE); +#else // TARGET_NRF — Adafruit core, cores/nRF5/rtos.h:59 TASK_PRIO_NORMAL == 2 + xTaskCreate(monitorTask, "odmon", 512 /*words = 2 KB*/, NULL, TASK_PRIO_NORMAL, NULL); +#endif +``` + +Note the stack-size unit differs: ESP32's `xTaskCreate` takes **bytes**, vanilla FreeRTOS (nRF) +takes **words**. Getting this wrong is a silent stack overflow — though nRF has +`configCHECK_FOR_STACK_OVERFLOW 1` (`FreeRTOSConfig.h:78`) to catch it in test. + +### What it can and cannot do + +**Deliberately detect-and-log only.** It must not touch panel, BLE, or session state — it runs on a +different task from every one of those subsystems, and `pwrmgmLock` is the only cross-task guard in +the codebase. Under the no-reboot decision there is no safe recovery action available to an +observer task; its value is that a field freeze stops being invisible and starts producing a +timestamped ERROR naming the phase it died in. `g_loopStalled` is a **→ Phase 6** input: the +supervisor, once `loop()` resumes, can report that a stall occurred and how long it lasted. + +This is an honest limit, not a hedge: it closes the *diagnosis* gap, not the *recovery* gap. The +recovery story for a blocked `loop()` remains P2-1/P2-4/P2-8's per-wait deadlines — P2-9 is what +tells you when one of them failed to hold. + +### `LOOP_STALL_WARN_MS` + +Must exceed the longest legitimate single `loop()` pass. That is a full refresh: `waitforrefresh(60)` +after P2-8 is a true 60 s wall-clock bound, and a `pwrmgmLockTake` timeout adds another 60 s. +**`LOOP_STALL_WARN_MS = 150000` (2.5 min)** clears 60 + 60 with margin and still fires long before +Phase 6's 10-minute supervisor. See D-J. + +### Cost + +One task, ~2 KB stack + TCB, plus two `uint32_t` and a `const char*`. nRF52840 has 256 KB RAM and is +not a concern. **`esp32-N4` is still the gate** — it needs `PIPE_SMALL_DRAM_WINDOW` to fit at all — +but the combined-headroom warning that stood here is obsolete in Phase 2's favour: Phase 1 shipped a +**32 B bitmap in place of the 512 B ring**, not the `replay_window[256]` this plan was written +against, so it *returned* 480 B rather than consuming 1,536. Measured on the as-built branch: +`esp32-N4` **81,468 B** vs the 81,940 B pre-Phase-1 baseline. + +So P2-9 starts with ~480 B more room than budgeted. Still measure the link rather than assume — the +figure that matters is the successful link, not the percentage — but the fallback (compile P2-9 out +on that env alone via `-DOPENDISPLAY_NO_LOOP_MONITOR`, rather than shrinking the stack) is now less +likely to be needed. + +### Rejected: the nRF hardware WDT + +`NRF_WDT` is available and unused, and would be the obvious "make it binding" answer. Rejected: + +- It **resets the chip**, which is the one outcome the user's decision rules out. +- Its `EVENTS_TIMEOUT` fires only ~2 LFCLK cycles (~61 µs) before the reset — not remotely enough to + run a state teardown, so it cannot be repurposed into a soft supervisor. +- Once started it **cannot be stopped** except by reset, which complicates DFU and any future + debugging session. + +Recorded here so the option is not rediscovered and re-argued. The same reasoning is why P2-6 +deletes the ESP32 TWDT flag rather than trying to make it work. + +**But note the limit of that rejection.** It rules out an *unconditional* WDT reset. A rate-limited +reset with the reason persisted to flash is a materially different proposal and is not covered by +the arguments above — see **D-I** for the full trade, including why an unlogged reboot loop on a +battery e-paper tag is worse than the freeze it fixes, and why the recommendation is nonetheless to +defer it until soak data exists. + +### Rejected: an idle-hook heartbeat + +`vApplicationIdleHook` is overridable on nRF (weak alias, `hooks.c:33`) and `esp_register_freertos_idle_hook()` +exists on ESP32, so a symmetric idle-hook stamp is cheap and needs no task. But the idle hook +answers *"did anything run?"*, not *"did `loop()` advance?"* — and every fault in scope leaves the +system busily yielding, so idle keeps running throughout. It would detect only total CPU +starvation, which is the fault class already accepted as unrecoverable. The extra task is what buys +the actual signal. + +--- + +## ~~P2-1~~ — Bound `pwrmgmLockTake`, do not steal — ❌ **DROPPED** + +*Dropped 2026-07-26 by owner decision.* **Not implemented in Phase 2.** `pwrmgmLockTake()` +([display_service.cpp:401-408](../src/display_service.cpp)) keeps its unbounded +`while (__atomic_exchange_n(...)) { delay(1); }` spin, unchanged. No deadline, no `bool` return, no +`panelStateUnknown` flag, and no change to any `epdSession*` signature. + +**What this leaves open — state it plainly.** The parent plan lists this spin as one of its five +unbounded waits. A holder that never releases still blocks its waiter forever. The two known +long-but-legitimate holds remain the reason a naive bound was risky in the first place +(`bbepWaitBusy` caps at 30 000 ms on 3/4/7-colour panels; `epdSessionForceOffLocked` holds across +`bbepSleep` → `bbepWaitBusy`), so the residual is specifically "a hold that never *ends*", not "a +hold that runs long". With P2-9 also dropped, **nothing in Phase 2 detects or reports that stall on +either target** — recovery rests entirely on Phase 6's supervisor, and on nRF (where the ESP32 +wall-clock watchdogs do not run) on nothing at all until Phase 6 lands. The `[C2]` reasoning stands +and is worth preserving: +if anyone revisits this, **do not steal the lock** — it is a bare 0/1 flag with no owner, so a steal +makes the true holder's later `Give` unlock it underneath the stealer, permanently destroying mutual +exclusion on the panel's SPI/CS lines. + +**Knock-on: five decisions become moot** — D-A, D-A2, D-B, D-C and D-E all existed only to shape +this item. See the Decisions section. + +The original specification is retained below for the record. + +--- + +### The rule *(retained for the record — not implemented)* + +> `pwrmgmLockTake()` gets a deadline and a `bool` return. On expiry it **does not acquire**. The +> caller skips its panel work, reports failure upward, and sets a sticky `panelStateUnknown` flag. +> Nothing is ever stolen. + +Stealing a bare 0/1 flag is unrecoverable: two tasks would drive the same SPI/CS lines, and the +original holder's eventual `pwrmgmLockGive()` ([:412](../src/display_service.cpp)) unlocks the +lock *out from under the stealer*, permanently killing mutual exclusion. There is no owner field +to detect it with. This is the whole point of `[C2]`. + +### Deadline: 60 000 ms — justification + +The bound must exceed the longest **legitimate** hold, or it converts a slow panel into a +spurious failure. Longest legitimate hold, measured from the sources: + +- `epdSessionAcquire` ([:437-489](../src/display_service.cpp)) holds across `bbepWakeUp` + + `bbepSendCMDSequence`, each of which can call `bbepWaitBusy` → up to **30 000 ms** on a + 3/4/7-colour panel (`bb_ep.inl:3966-3968`). +- `epdSessionForceOffLocked` ([:416-433](../src/display_service.cpp)) holds across `bbepSleep` → + `bbepWaitBusy` → another **30 000 ms**, plus a `delay(50)` loop. + +So a single hold can legitimately approach 30 s, and a queued acquire behind a force-off can +legitimately wait ~30 s more. **60 s = 2× worst-case single busy wait** is the smallest number +that cannot fire on healthy hardware. Do not tune it down without re-deriving from +`bbepWaitBusy`'s `iMaxTime`. + +### Code shape + +```c +// display_service.cpp — replace :401-408 +// Bounded acquire. Returns false on timeout WITHOUT acquiring; the caller must +// then skip all panel work. We deliberately do NOT steal: pwrmgmLock is a bare +// flag with no owner, so a steal lets two tasks drive the same SPI/CS and the +// true holder's later Give unlocks it under the stealer -- mutual exclusion +// permanently dead. Deadline is 2x bbepWaitBusy's 30 s multi-colour cap. +#define PWRMGM_LOCK_TIMEOUT_MS 60000u + +static bool pwrmgmLockTake(void) { + const uint32_t start = millis(); + while (__atomic_exchange_n(&pwrmgmLock, 1, __ATOMIC_ACQUIRE)) { + if ((uint32_t)(millis() - start) > PWRMGM_LOCK_TIMEOUT_MS) { + od_log_error("[EPD session] pwrmgm lock TIMEOUT after %u ms - panel state UNKNOWN", + PWRMGM_LOCK_TIMEOUT_MS); + panelStateUnknown = true; + return false; + } + delay(1); // vTaskDelay: must yield, see priority-inversion note below + } + return true; +} +``` + +Keep the existing priority-inversion comment verbatim — the `delay(1)` is load-bearing on nRF and +someone will otherwise "optimise" it back to a busy-spin. + +### The five call sites + +`pwrmgmLockTake()` has five callers today. Each needs an explicit failure branch. `pwrmgmLockGive()` +must be reachable on **every** path that took the lock and on **no** path that didn't. + +| Site | Function | Failure behaviour | +|---|---|---| +| [:439](../src/display_service.cpp) | `epdSessionAcquire(bool partialInit)` | **Signature change** — must report failure. See below. | +| [:496](../src/display_service.cpp) | `epdSessionRelease(bool)` | Return early. Panel is left however it is; `panelStateUnknown` is set. | +| [:513](../src/display_service.cpp) | `epdSessionForceOff(void)` | Return early. Log ERROR — this is the worst one to lose (rail stays up). | +| [:520](../src/display_service.cpp) | `epdSessionTick(void)` | Already `pwrmgmLockTryTake()` — **unchanged**. | +| — | `epdSessionForceOffLocked` | Caller-holds-lock; **unchanged**. | + +`epdSessionAcquire` currently returns `bool cold` — a *result*, not a status. Two options: + +- **(a)** `static bool epdSessionAcquire(bool partialInit, bool* outCold)` — returns success. +- **(b)** Keep `bool cold` and expose the failure via `panelStateUnknown`, checked by callers. + +**Recommend (a).** Option (b) makes every caller's happy path silently proceed to drive an +un-acquired panel, which is exactly the class of bug this phase exists to remove. (a) is a +mechanical change across the `epdSessionAcquire` call sites and the compiler finds them all. + +### `panelStateUnknown` → Phase 3 + +```c +// display_service.h +extern volatile bool panelStateUnknown; // sticky: a panel op was skipped on a lock timeout +``` + +- Set: only by `pwrmgmLockTake()` on expiry. +- Cleared: on the next **successful** `epdSessionForceOff()` — that is the one operation that + restores a known state (rail down). +- Consumed: **→ Phase 3.** `abortToKnownState()` reports it and skips `epdSessionForceOff()` + when set (retrying a lock that just timed out costs another 60 s inside the abort path). + Phase 2 only produces it; nothing reads it yet, which is intentional and should be noted in + the commit message so review does not flag it as dead code. + +### Deferred: the owner handle + +`volatile TaskHandle_t pwrmgmOwner` (so a stale `Give` becomes detectable) is **not** in Phase 2. +It is only needed if a forced take ever becomes necessary, and Phase 2's position is that it never +is. Recorded here so the option is not rediscovered from scratch. + +--- + +## ~~P2-2~~ — Bound the `powerOff` stuck-button wait — ❌ **DROPPED** + +*Dropped 2026-07-26 by owner decision.* **Not implemented in Phase 2.** +[power_latch.cpp:85-90](../src/power_latch.cpp) is unchanged; a stuck-low button pin still means the +device never powers off. Narrow residual: ESP32-only (the whole file is `#if defined(TARGET_ESP32)`), +requires a hardware fault in the button itself, and it removes a *recovery* path rather than adding a +freeze — the device is not wedged by it, the user's last-resort power-off is simply unavailable. It +compounds the parent plan's residual-risk note that "hold the button" is already a weak fallback +while `loop()` is blocked. + +The original specification is retained below for the record. + +--- + +*(retained for the record — not implemented)* Today a shorted or stuck-low button pin means the +device never powers off — the user's last-resort recovery is gone (see the parent plan's residual-risk +section, which already calls this out as the reason "hold the button" is a weak fallback). + +```c +void powerOff() { + const gpio_num_t latch = latchPin(); + if (hasButton()) { + pinMode(buttonPin(), INPUT_PULLUP); + // Bounded: a stuck-low button must not block power-off forever. After + // 10 s we drop the latch anyway -- worst case the rail cycles and the + // still-held button re-latches, which is indistinguishable to the user + // from a normal press-and-hold-too-long. + const uint32_t start = millis(); + while (digitalRead(buttonPin()) == LOW) { + if ((uint32_t)(millis() - start) > 10000u) { + od_log_warn("[power] button still held after 10 s - dropping latch anyway"); + break; + } + delay(20); + } + } + ... +``` + +**Why the wait exists at all:** it prevents the rail dropping while the button is still held, which +on a latching board would immediately re-latch and power the device back on. Bounding it +reintroduces exactly that possibility after 10 s — which is the correct trade (a power-cycle is +recoverable; a device that cannot be turned off is not). + +`power_latch.cpp` is inside `#if defined(TARGET_ESP32)`; **nRF is unaffected** by this item. + +--- + +## ~~P2-5~~ — Wall-clock cap on the loop command drain — ❌ **DROPPED** + +*Decided 2026-07-26.* **Not implemented in Phase 2.** No change to the drain loop at +[main.cpp:406-424](../src/main.cpp) — no `COMMAND_DRAIN_BUDGET_MS`, no cap, and no saturation WARN +either. The whole item is out. + +Two consequences worth carrying forward: + +- **Phase 3 owns the drain loop uncontested.** `[M5]`'s drain-trap fix is now the only edit to those + five lines; there is no `drainStart` in scope and no merge conflict to sequence around. +- **The saturation signal moves to Phase 7, where it belongs.** "The command ring ran full" is + `[H1]`'s subject matter, not a Phase 2 bound. If a diagnostic is wanted, Phase 7 should add it + alongside its overflow handling rather than Phase 2 bolting a WARN onto a loop it otherwise does + not touch. + +The analysis below is retained so this is not re-proposed. + +### The stated rationale does not survive checking + +The parent plan asks for a 2 s cap because "a full window of commands can hold `loop()` for +minutes." Walked through against the code, that cannot happen: + +| Scenario | Actual cost | Does a 2 s cap help? | +|---|---|---| +| Full window of 32 pipe DATA frames | Each is a zlib inflate + SPI write — single-digit ms. 32 of them ≈ **0.1–1 s** even on a slow target | **No** — never reaches 2 s | +| One END frame triggering a full refresh | **30–60 s**, the genuinely long case | **No** — the check is *between* commands and cannot interrupt one | +| Several refreshes stacked in one drain — the only case a cap would catch | **Unreachable.** A second `0x0072` short-circuits at [display_service.cpp:2366](../src/display_service.cpp) (`if (!directWriteActive) return;`); the pipe END paths are guarded by `pipeState.active` | **N/A** | + +So the cap fires in no realistic scenario, and in the one case where `loop()` really is blocked for +a minute it is powerless by construction. **The drain is already bounded** — 33 × (per-command +time), and per-command time is bounded by P2-1, P2-4 and P2-8. The real cost of a saturated drain +is ~1 s of unserviced touch/buttons, which is not the unbounded-wait class Phase 2 exists to fix. + +I had also flagged this item as a **merge hazard with Phase 3's `[M5]`** — it edits the same five +lines. Dropping it removes that conflict for free. + +### The one residual argument, and why it is not enough + +The drain is the one place where many non-yielding operations run back-to-back, and ESP32's TWDT +panics at 5 s of IDLE0 starvation. A 2 s cap would guarantee a yield point inside the drain and so +buy TWDT margin if commands ever got much slower. But at today's ~1 s worst case the margin is +already 5×, and adding a mechanism against a hypothetical future regression is exactly the +speculative engineering this plan rejects elsewhere (see D-I option 3). + +### Original proposal, retained for the record + +```c +{ + uint8_t drained = 0; + const uint32_t drainStart = millis(); + while (drained < COMMAND_QUEUE_SIZE) { + // Wall-clock cap alongside the count cap: a single command (a pipe END + // frame runs a full refresh inline) can take seconds, so 33 of them can + // hold loop() for minutes and starve disconnect cleanup / epdSessionTick / + // WiFi / buttons. Unconsumed commands stay queued and drain next pass. + if (drained > 0 && (uint32_t)(millis() - drainStart) > COMMAND_DRAIN_BUDGET_MS) { + od_log_warn("[drain] budget exceeded after %u commands - deferring rest", drained); + break; + } + ... +``` + +**`COMMAND_DRAIN_BUDGET_MS = 2000`** (parent plan's number). Note the `drained > 0` guard: the cap +must never prevent the *first* command from running, or a single slow command starves the queue +forever. + +Notes that would have applied: break-don't-drop (unconsumed entries stay in the ring, serviced next +pass); the check cannot fire mid-command; `flushResponseQueueToBle()` already runs between commands +([:422](../src/main.cpp)) so breaking early could not strand pipe ACKs. + +**Merge hazard (now avoided by dropping the item):** Phase 3's `[M5]` edits the same five lines — +it inserts a `commandDrainAbortPending` check between [:415](../src/main.cpp) and `:416` and deletes +the vestigial `pending` field. If P2-5 is kept after all, land it before Phase 3 and note there that +`drainStart` is already in scope. + +--- + +## ~~P2-6~~ — Delete the inert TWDT flag, document the real one — ❌ **DROPPED** + +*Dropped 2026-07-26 by owner decision.* **Not implemented in Phase 2.** +`-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` stays in all 9 ESP envs +(`platformio.ini:53, 83, 112, 140, 189, 209, 229, 253, 295`), and no comment is added recording the +real setting. + +**Residual: a misleading dead knob stays in the tree.** The symbol is not an IDF 5.x setting — the +real one is `CONFIG_ESP_TASK_WDT_TIMEOUT_S`, and the precompiled `sdkconfig.h` wins regardless, so +the true watchdog is **5 s / panic on IDLE0 starvation**, not the 120 s the flag implies. Today's +30–60 s waits survive only because every one of them yields. The next reader of `platformio.ini` +has no way to know that from the tree. Not a freeze risk — the flag has never done anything — but +it is a live source of wrong conclusions, and it is the cheapest item in the phase (a build-flag +deletion plus one comment). Worth revisiting whenever anything else touches `platformio.ini`. + +**Consequence:** Phase 2 now touches **no build configuration and no `src/main.cpp`** — the item was +the only reason for either. + +The original specification is retained below for the record. + +--- + +### Original specification *(retained for the record — not implemented)* + +Remove `-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` from all 9 ESP envs (`platformio.ini:53, 83, 112, +140, 189, 209, 229, 253, 295`). It is an IDF 4.x symbol name; IDF 5.x uses +`CONFIG_ESP_TASK_WDT_TIMEOUT_S`, which the precompiled `sdkconfig.h` fixes at 5 and which a +`build_flags` define cannot override anyway (same mechanism as the documented +`CONFIG_BT_NIMBLE_MAX_CONNECTIONS` trap). Delete rather than rename — renaming to the correct +symbol would be a no-op that *looks* effective, which is worse than the current dead knob. + +Replace with a comment at the top of `loop()` in `main.cpp`: + +```c +// Task watchdog reality check (do not "fix" this with a build flag): +// the precompiled IDF 5.5.4 sdkconfig.h fixes CONFIG_ESP_TASK_WDT_TIMEOUT_S=5 +// with _PANIC=1 and _CHECK_IDLE_TASK_CPU0=1, and a -D in build_flags cannot +// override it. So the real bound on this task is 5 s of IDLE0 starvation -> +// panic reboot. Today's 30-60 s panel waits survive only because every one of +// them yields (delay()/vTaskDelay()/bbepLightSleep()). Any new busy-spin in a +// panel or BLE path WILL reboot the device. A reboot is also the one outcome +// this whole effort is trying to avoid -- it wipes RTC state including +// displayed_etag and forces a boot-screen redraw. +``` + +Optionally add one line to `docs/TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md` recording that the +120 s entry was fiction. + +--- + diff --git a/docs/PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md b/docs/PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md new file mode 100644 index 0000000..60f9664 --- /dev/null +++ b/docs/PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md @@ -0,0 +1,278 @@ +# Phase 2 Implementation Plan — Bound the Refresh Waits + +**Branch:** `debug/freeze-fix-phase2` (cut from Phase 1 as-built) · **Date:** 2026-07-26 +**Parent plan:** [`PLAN_FREEZE_PROOFING_2026-07-26.md`](PLAN_FREEZE_PROOFING_2026-07-26.md) § "Phase 2" +**Supersedes:** [`PLAN_PHASE2_BOUND_WAITS_2026-07-26.md`](PLAN_PHASE2_BOUND_WAITS_2026-07-26.md) +— **obsolete**, retained for its analysis of the five cut items and the eight decisions they carried. + +--- + +## What Phase 2 is now + +**Three edits, two files, one subsystem: the e-paper refresh wait.** + +| # | Item | File | Targets | +|---|---|---|---| +| **P2-3** | `epdRefreshInProgress` around both boot-refresh paths | `display_service.cpp` | both (inert on nRF today) | +| **P2-8** | `waitforrefresh` → wall-clock deadline instead of an iteration count | `display_service.cpp` | both — **nRF-critical** | +| **P2-4** | `fastepd_wait_refresh` → a real bounded wait instead of a stub | `display_fastepd.cpp` | ESP32 + FastEPD only | +| *(P2-7)* | *optional, recommend defer* — `Wire.setTimeOut(25)` | `display_service.cpp` | ESP32 | + +Landing order **P2-3 → P2-8 → P2-4**. Independent of each other; each is separately revertable. + +**Not in scope, and deliberately so.** The earlier plan also proposed a `pwrmgmLockTake` deadline, a +`powerOff` stuck-button bound, a loop-drain cap, deleting the inert TWDT build flag, and a +loop-liveness monitor task. All five were cut. Their specifications and the reasoning behind them +live in the superseded document; the residual each leaves is recorded in the parent plan and +restated under "What Phase 2 does not do" below. + +**Constraints.** No new file. No new task. No change to `src/main.cpp`, `platformio.ini`, or +anything under `include/`. `src/session_guard.*` belongs to Phase 3 and is not referenced here. + +--- + +## The problem, stated precisely + +A refresh is the longest thing this firmware does — 30–60 s on a colour panel. Three defects mean +that wait is either unmeasured, unbounded, or invisible: + +### 1. `waitforrefresh` counts iterations, not time + +[display_service.cpp:747-776](../src/display_service.cpp): + +```c +for (size_t i = 0; i < (size_t)(timeout * 100); i++){ + delay(10); + ... + if(!bbepIsBusy(&bbep)){ ... return true; } +} +od_log_warn("Refresh timed out"); +``` + +`timeout * 100` iterations of `delay(10)` equals `timeout` seconds **only if each iteration really +takes 10 ms**. `delay()` is `vTaskDelay` on both targets — it yields, and it guarantees only a +*minimum*. Under contention each pass can take arbitrarily longer, so the "60 s" bound is really +"60 s of scheduled time for this task", with no ceiling in wall-clock terms. + +**This is worst exactly where it matters most.** Phase 1 established on hardware that nRF's `loop()` +task (priority 1, `rtos.h:58`) is starved mid-transfer by the callback task (2) and the Bluefruit +task (3) — its deferred link-drop never ran, forcing the inline disconnect in `23ecaed`. A refresh +wait that measures scheduled iterations is precisely the wrong instrument on a task that can be +descheduled for long stretches. + +### 2. On FastEPD panels there is no wait at all + +[display_fastepd.cpp:228-231](../src/display_fastepd.cpp) — the whole function: + +```c +bool fastepd_wait_refresh(int timeout_sec) { + (void)timeout_sec; + return !s_init_failed; +} +``` + +It ignores its timeout and returns immediately. And [display_service.cpp:749](../src/display_service.cpp) +short-circuits to it before any of the polling loop above: + +```c +if (fastepd_driver_used()) return fastepd_wait_refresh(timeout); +``` + +So on IT8951/E1004, `waitforrefresh(60)` returns `true` in microseconds. **The documented 60 s cap +does not exist on those panels, and neither does the wait** — callers proceed as though the panel +had finished. This is `[X3]` from the original review, confirmed against the current tree. + +### 3. Boot refreshes are invisible to every gate + +`epdRefreshInProgress` ([display_service.cpp:85](../src/display_service.cpp)) is set/cleared around +the two *transfer* refresh paths — [:2415](../src/display_service.cpp)/[:2436](../src/display_service.cpp) +and [:3284](../src/display_service.cpp)/[:3294](../src/display_service.cpp) — but **not** around +either boot path: + +| Boot path | Site | Sets the flag? | +|---|---|---| +| `refreshBootScreenFull()` | [:532-542](../src/display_service.cpp) — `bbepRefresh` then `waitforrefresh(60)` | ❌ | +| FastEPD boot | [:1587-1595](../src/display_service.cpp) — `fastepd_full_update()` then `waitforrefresh(60)` | ❌ | + +Its consumers all treat the flag as "a refresh is in flight, do not disturb": +[ble_init.cpp:236](../src/ble_init.cpp) (advertising restart), +[main.cpp:322](../src/main.cpp) (defer disconnect cleanup), +[main.cpp:482](../src/main.cpp) (`workInFlight` / deep-sleep gate), and Phase 6's supervisor rule +"never interrupt a refresh". During a 30–60 s boot refresh every one of them believes the device is +idle. + +--- + +## P2-3 — Set `epdRefreshInProgress` around both boot paths + +Wrap each boot refresh exactly as the transfer paths already do: set before the refresh call, clear +after the wait returns, on **every** exit path including the failure returns. + +- `refreshBootScreenFull()` [:532-542](../src/display_service.cpp) — note the early `return false` + when `writeBootScreenWithQr()` fails; the flag must not be left set on that path. +- FastEPD boot [:1587-1595](../src/display_service.cpp) — the flag must be cleared before + `epdSessionForceOff()`. + +**Scoped-guard pattern preferred** over paired assignments, so a future early return cannot leak the +flag. If a plain pair is used instead, say why in the commit message. + +**On nRF this is inert today, and that is fine.** All three current consumers are inside ESP32-only +code, so setting the flag on nRF changes nothing at runtime. It is still correct, it costs nothing, +and Phase 6 adds the nRF consumers. **Say so in the commit message** — otherwise the next reader +sees a flag set and never read, and "fixes" it. + +--- + +## P2-8 — Give `waitforrefresh` a real deadline + +Replace the iteration count with a `millis()` deadline. Keep everything else: the 10 ms poll, the +`i == 0` "never went busy" error, the progress dots, the completion log. + +```c +const uint32_t deadline = millis() + (uint32_t)timeout * 1000u; +bool first = true; +while ((int32_t)(millis() - deadline) < 0) { + delay(10); + ... + if (!bbepIsBusy(&bbep)) { /* first-pass error check, elapsed log, return true */ } + first = false; +} +od_log_warn("Refresh timed out after %u ms (deadline %d s)", elapsed, timeout); +return false; +``` + +Three details that matter: + +1. **Signed-difference comparison**, `(int32_t)(millis() - deadline) < 0`, not `millis() < deadline` + — correct across the 49.7-day `millis()` wrap. The codebase already uses this idiom + ([display_service.cpp:522](../src/display_service.cpp)). +2. **Keep the `i == 0` semantics** as a *first-iteration* check, not an index test. It catches "the + panel never asserted BUSY", i.e. the refresh never started, which is a different failure from a + timeout and is worth keeping distinct in the log. +3. **Report elapsed wall-clock on both exits.** The current success path logs `i / 100` — an + iteration count presented as seconds, which is exactly the confusion this item removes. + +**Timebase caveat, accepted (was D-K).** On nRF `millis()` is `tick2ms(xTaskGetTickCount())` — the +FreeRTOS tick at 1024 Hz with `configUSE_TICKLESS_IDLE 1` — not a hardware timer as on ESP32. It +advances while the task is descheduled, which is what this item needs, but it is not a +high-integrity clock: a fault that stops the scheduler also stops the deadline. That fault class is +already accepted as unrecoverable software-side. **Do not chase it here.** + +--- + +## P2-4 — Make `fastepd_wait_refresh` real + +Implement it as a bounded poll of the IT8951 LUT-busy state, honouring `timeout_sec` with the same +`millis()` deadline idiom as P2-8, returning `false` on expiry. + +**Wrap the path a real transfer takes.** [display_service.cpp:2422-2423](../src/display_service.cpp): + +```c +fastepd_direct_refresh(refreshMode); +refreshSuccess = waitforrefresh(60); +``` + +`fastepd_direct_refresh` is what a transfer calls — not only `fastepd_full_update`. Both must end up +covered; the original `[X3]` finding specifically called out wrapping the wrong one. + +**Blast radius is one env family.** Guarded by `TARGET_ESP32 && OPENDISPLAY_FASTEPD` and reached +only when `fastepd_driver_used()`, so `esp32-s3-E1004` is the build and behaviour gate. Every other +env keeps today's `bbepIsBusy` loop unchanged. + +**This changes observable timing.** Today the call returns immediately; afterwards it blocks until +the panel is genuinely idle. That is the point — callers currently believe a refresh finished when +it had not — but it means the E1004 upload regression test is not optional (see Verification). + +--- + +## P2-7 — *(optional, recommend defer)* + +`Wire.setTimeOut(25)` after each `Wire.begin()`, halving the ~50 ms Arduino default that a failing +GT911 transaction blocks for. Worst case today is ~250 ms of blocked `loop()` before the driver +disables the controller after 5 consecutive failures — bounded and acceptable, which is why `[X1]` +was downgraded. **ESP32 only: the API does not exist on nRF**, whose TWIM driver busy-spins with no +timeout at all (`Wire_nRF52.cpp:166-181`). That nRF gap needs a physical fault to trigger, is +outside this effort's freeze class, and is *not* addressed here — but note that `[X1]`'s +"the driver gives up after 5 failures" reasoning is ESP32-specific and does **not** transfer to nRF. + +--- + +## What Phase 2 does not do + +Recorded so nobody assumes Phase 2 covered it: + +- **`pwrmgmLockTake` stays unbounded** on both targets ([display_service.cpp:401-408](../src/display_service.cpp)). + A panel-lock holder that never releases blocks its waiter forever. No `panelStateUnknown` flag is + produced — **Phase 3 must not expect one**. +- **A stalled `loop()` is undetected on both targets.** ESP32's TWDT (5 s, panic on IDLE0) will not + fire, because every long wait here yields and IDLE0 is never starved. nRF has no watchdog at all. + Detection is entirely Phase 6's. +- **`powerOff`'s stuck-button wait stays unbounded** (ESP32-only, needs a hardware fault). +- **The inert `-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` stays** in 9 ESP envs, still implying a + 120 s watchdog that does not exist. + +**So Phase 2 bounds the refresh waits. It does not detect stalls, and it is not the "defensive +floor" the earlier plan described** — that framing depended on the monitor task and no longer holds. + +--- + +## Decisions + +None blocking. Four carried over, all with a default: + +| | Question | Default | +|---|---|---| +| **D-D** | Accept the `[X3]` downgrade (implement the real wait rather than a larger redesign)? | **yes** — this plan assumes it | +| **D-G** | Include P2-7? | **no**, defer | +| **D-K** | Accept nRF's tick-derived `millis()`? | **yes** — see P2-8 | +| **D-L** | nRF I2C busy-spins with no timeout: bound it or accept it? | **accept**, out of scope | + +--- + +## Verification + +### Build + +```bash +/home/davelee/.platformio/penv/bin/pio run # all 12 envs; pio is NOT on PATH +``` + +`esp32-s3-E1004` is the P2-4 gate; `nrf52840custom` is the P2-8 gate. CI also runs the `host-tests` +job added by Phase 1 — Phase 2 adds nothing to it. + +### Static + +- `git diff --stat` touches **only** `src/display_service.cpp` and `src/display_fastepd.cpp`. + Any new file, or any change to `src/main.cpp`, `platformio.ini` or `include/`, means scope crept. +- `pwrmgmLockTake` is **unchanged**, signature included. +- No `epdRefreshInProgress = true` without a matching clear on every path out, including failures. +- No remaining `timeout * 100` iteration arithmetic in `waitforrefresh`. + +### Hardware — both targets required + +1. **Boot refresh (P2-3)** — cold boot with a colour panel; confirm a BLE connect during the boot + refresh is deferred rather than acted on mid-refresh, and that deep sleep is not entered during it. +2. **Normal refresh (P2-8)** — full transfer + refresh completes; the completion log now reports + **elapsed milliseconds**, and it should be close to the real refresh duration. +3. **Timeout path (P2-8)** — force a panel that never clears BUSY; confirm the warning fires at + ~`timeout` seconds of *wall clock*, not later. +4. **Never-started path (P2-8)** — confirm the "not busy after refresh command" error still fires, + distinct from a timeout. +5. **E1004 regression (P2-4)** — a ~960 KB upload + refresh completes, and the wait now takes real + time instead of returning instantly. **This is the test most likely to surface a surprise**, since + callers have never previously waited on this path. +6. **nRF under load (P2-8)** — refresh during a BLE transfer, where the loop task is contended. The + deadline should hold in wall-clock terms; an iteration-counted wait would have overrun. + +--- + +## Residual risk after Phase 2 + +- A hard hang inside a library call below our waits (wedged SPI, stuck DMA) is still invisible — + `bbepWaitBusy`'s own 30 s cap and `it8951WaitForLUTReady`'s 30 s cap are the library's, not ours. + Consistent with the software-only decision. +- A single long refresh still owns `loop()` for its duration. By design: interrupting a refresh is + worse than waiting for it. P2-8 bounds it; it does not shorten it. +- On nRF, a scheduler-stopping fault stops `millis()` and therefore the deadline (D-K). +- Everything under "What Phase 2 does not do" — most consequentially, **nothing here detects a + stalled `loop()`**. That now rests entirely on Phase 6, on both targets. diff --git a/docs/PLAN_PHASE3_SESSION_GUARD_2026-07-26.md b/docs/PLAN_PHASE3_SESSION_GUARD_2026-07-26.md new file mode 100644 index 0000000..10ebc53 --- /dev/null +++ b/docs/PLAN_PHASE3_SESSION_GUARD_2026-07-26.md @@ -0,0 +1,1469 @@ +# Phase 3 Implementation Plan — `abortToKnownState()` + queue flushes + drain-trap fix + +> Companion to `PLAN_FREEZE_PROOFING_2026-07-26.md` (§ "Phase 3") and +> `FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md` (findings `[M3]`, `[M5]`, `[H4]`, +> build/portability check). +> Branch: `debug/ble-hardening`. Target: land after Phase 1 (nonce) and Phase 2 (bounded +> waits), before Phase 4 (owner token). +> +> **Bound by the parent plan's *"Hard constraint — NO wire protocol changes"* (§32-57 there). +> Its application to Phase 3 is §2 below; it settles Decision D3 and removes step 3 from the +> teardown.** + +--- + +## 1. What Phase 3 is, and what it deliberately is not + +Phase 3 builds the **recovery mechanism** — a single, ordered, idempotent teardown that +returns the device to a known-good state from any wedged transfer — plus the two +ring-buffer primitives it needs and one latent correctness bug in the command drain. + +It does **not** decide *when* to recover. Every trigger lives elsewhere: + +| Trigger | Phase | Calls | +|---|---|---| +| **BLE/LAN disconnect teardown** | **3 (D1b — see below)** | `abortToKnownState("disconnect", false)` | +| `integrity_failures >= 3` | 5 | `abortToKnownState(..., true)` | +| `reloadConfigAfterSave` | 5 | `abortToKnownState(..., true)` | +| 10-min no-progress supervisor | 6 | `abortToKnownState("supervisor", true)` | +| Recurring command-ring overflow | 7 | via supervisor, not directly | +| BLE idle timeout (5 min) | 7 | `abortToKnownState("idle", true)` | + +**Decided (D1 = b): Phase 3 wires the two existing disconnect teardowns to +`abortToKnownState()` now.** As originally scoped by the parent plan Phase 3 would have +shipped with zero callers — the review flags this ("`abortToKnownState` has no callers until +Phase 5/6, so it is dead code"). Instead, [main.cpp:343-347](../src/main.cpp) (ESP32) and +[device_control.cpp:237-239](../src/device_control.cpp) (nRF) are replaced with +`abortToKnownState("disconnect", false)`, which is a strict superset of what those sites do +today. The teardown therefore becomes the most-exercised path in the firmware immediately, +and the hardware tests in §12 test real behaviour rather than a synthetic trigger. + +This has three consequences that shape the rest of this plan; all are handled in **§9**: +1. The ESP32 `ownerStillUp` guard must stay **in front of** the call — a WiFi drop also routes + into `serviceBleDisconnectCleanup` ([wifi_service.cpp:812](../src/wifi_service.cpp)). +2. The nRF site runs on the Bluefruit Callback task, not `loop()`, and the teardown now + includes work ESP32 deliberately defers. nRF needs the same deferral — see §9.2. +3. Combined with **D6**, Phase 3 now delivers the "clear encryption session on BLE disconnect" + behaviour the parent plan assigned to Phase 5. See §9.3. + +Deliverables: + +1. `src/session_guard.h` / `src/session_guard.cpp` (new) — flags, progress stamp storage, + `abortToKnownState()`. +2. `flushCommandQueue()` / `flushResponseQueue()` in `main.cpp`. +3. Drain-trap fix `[M5]` in `main.cpp`, + removal of the vestigial `pending` field. +4. `g_commandInFlight` as a `volatile uint8_t` depth counter `[H4]`. +5. New helpers the teardown needs and that do not exist today: + `resetChunkedWriteState()`, `touchForceResume()` `[M3]`, `buzzerForceStop()`, + `ledForceStop()`. +6. **(D1b)** Both disconnect teardowns rewired to `abortToKnownState()`, plus a new + loop-serviced deferral on nRF (§9). +7. **(D6c)** `linkIsUp()` — portable per-target predicate defined in `main.cpp`, declared in + `session_guard.h` (§3a), gating the session clear. +8. **(D7)** `epdStreamInProgress` + `epdForceOffPending` + `serviceDeferredPanelOff()` — the + panel-safety pair that makes an ungated abort safe and guarantees a refresh always completes + (§8.3.1); and the invalidate/scrub split in `clearEncryptionSession()`. + +--- + +## 2. Hard constraint — NO wire protocol changes, applied to Phase 3 + +The parent plan's *"Hard constraint — NO wire protocol changes"* (§32-57) governs every phase. +Phase 3 is the phase least likely to bump into it — it is pure internal state management — +but it touches two things that *look* like protocol surface and one decision that genuinely +was. Recorded here so implementation does not have to re-derive the reasoning. + +**In bounds, and why:** + +| Phase 3 change | Why it stays inside the constraint | +|---|---| +| Deleting the `pending` field from `CommandQueueItem` / `ResponseQueueItem` (§5) | Both are **firmware-local runtime structs** — [esp32_ble_callbacks.h:25](../src/esp32_ble_callbacks.h) and [structs.h:86](../src/structs.h). Neither is in `include/opendisplay_structs.h`; neither appears on the wire. This is *not* the config-packet layout the constraint protects. | +| New flags, `g_commandInFlight`, `g_lastProgressMs` | Firmware-local scalars in a new `.cpp`. No client can observe them. | +| `flushCommandQueue()` discarding queued commands | Client-observably identical to the existing ring-full drop ([esp32_ble_callbacks.h:128](../src/esp32_ble_callbacks.h)) — a frame the device never answers. The pipe protocol is designed for exactly this: the zero bit in the next SACK triggers a client retransmit (`docs/pipe-write-protocol.md` §5.2). No new behaviour, no new code on the wire. | +| `flushResponseQueue()` discarding queued responses | Same shape as the existing full-ring drop at [communication.cpp:113-116](../src/communication.cpp), and as the unconditional drain-to-nowhere when no central is connected ([main.cpp:307-312](../src/main.cpp)). | +| `abortToKnownState(..., dropLink=true)` terminating the link | Explicitly in bounds: *"Dropping a link … is always a legal outcome; clients already handle it and reconnect."* | +| `resetChunkedWriteState()` | Internal bookkeeping for a transfer the device has already abandoned. The client's own timeout is what it reacts to. | +| Drain-trap fix, depth counter, `main.h:365` comment fix | Correctness and documentation only. | + +**Out of bounds for Phase 3 — do not do these:** + +- **Do not invent a new abort/NACK frame.** A generic `{RESP_NACK, 0x00, reason}` was a + candidate for the teardown's client notification; it is a new response shape and therefore + forbidden. This is now settled in **Decision D3**, not an open option. +- **Do not send an existing `RESP_*`/NACK code in a situation that changes its documented + meaning.** Reuse is permitted *"as long as the code's documented meaning is unchanged"* — + so a `{0xFF,0x81}` pipe NACK may only be sent for a genuine pipe failure, never as a + general-purpose "I aborted" signal for a chunked-config or direct-write abort. +- **Do not touch** `include/opendisplay_protocol.h` or `include/opendisplay_structs.h`, and + do not push anything through `../opendisplay-protocol`. +- **Do not change** SACK semantics, discard rules, or NACK meaning as documented in + `docs/pipe-write-protocol.md`. Phase 3 adds no note to that file; the §5.1 documentation + note the parent plan permits belongs to **Phase 5** (the pipe error-release deadline). + +**Escalation rule:** if any part of Phase 3 appears to need a protocol change to work, stop +and escalate rather than pushing a header change. Nothing in the scope above should reach +that point — the only candidate was D3's notification frame, and it is resolved by sending +nothing. + +The constraint's own verification (§12) must pass before Phase 3 is called done. + +--- + +## 3. Current-state facts this plan is built on (verified) + +| Fact | Location | +|---|---| +| Command ring is ESP32-only, SPSC: producer = NimBLE host task (`onWrite`), consumer = loop | [esp32_ble_callbacks.h:118-128](../src/esp32_ble_callbacks.h), [main.cpp:406-423](../src/main.cpp) | +| `COMMAND_QUEUE_SIZE 33` is defined **twice** — `main.h:371` and `esp32_ble_callbacks.h:19` (guarded `#ifndef`) | [main.h:371](../src/main.h), [esp32_ble_callbacks.h:18-19](../src/esp32_ble_callbacks.h) | +| Response ring is ESP32-only (10 slots), head **and** tail both written on the loop task | [communication.cpp:112-120](../src/communication.cpp), [main.cpp:276-312](../src/main.cpp) | +| nRF has **neither** ring — `imageDataWritten` runs inline on the Bluefruit Callback task; `sendResponse` notifies directly | [communication.cpp:130-132](../src/communication.cpp) | +| Drain caches `tail` at [main.cpp:409](../src/main.cpp), stores `tail+1` at [:417](../src/main.cpp) — a flush from handler context is clobbered | [main.cpp:408-421](../src/main.cpp) | +| `CommandQueueItem.pending` / `ResponseQueueItem.pending` are written but never read | grep: only assignments | +| Existing disconnect teardown (the closest thing to `abortToKnownState` today) | [main.cpp:339-350](../src/main.cpp) ESP32, [device_control.cpp:237-239](../src/device_control.cpp) nRF | +| `transferActive() == directWriteActive \|\| pipeState.active \|\| partialCtx.active` | [display_service.cpp:2502-2504](../src/display_service.cpp) | +| Touch suspend is a **counter** `s_epd_refresh_suspend` (file-static in touch_input.cpp) paired with a **bool** `directWriteTouchSuspended` (file-static in display_service.cpp) | [touch_input.cpp:115-119](../src/touch_input.cpp), [display_service.cpp:2008](../src/display_service.cpp), [:2035-2038](../src/display_service.cpp) | +| Buzzer stop is `static buzzer_stop_internal()` — no public stop entry point | [buzzer_control.cpp:147](../src/buzzer_control.cpp) | +| LED stop is `static led_stop_internal(bool)` — no public stop entry point | [device_control.cpp:341](../src/device_control.cpp) | +| `chunkedWriteState` has no reset function; cleared field-by-field at 4 sites | [communication.cpp:496-513](../src/communication.cpp), [:550](../src/communication.cpp), [:558](../src/communication.cpp), [:574-576](../src/communication.cpp) | +| `esp32-N4` is ESP32 **without** WiFi → LAN code must be `#ifdef OPENDISPLAY_HAS_WIFI`, never `TARGET_ESP32` | platformio.ini:284 | +| nRF sets `lib_ignore = NimBLE-Arduino` → no NimBLE types may leak into shared headers | platformio.ini:36 | + +--- + +## 3a. Header placement — `main.h` is NOT a header `[C1]` + +**Read this before writing any declaration.** `src/main.h` looks like a header and is not one: +it has **no include guard** and it **defines** globals rather than declaring them — +[main.h:91](../src/main.h) `BBEPDISP bbep;`, [:165](../src/main.h) `bool directWriteActive = false;`, +[:283](../src/main.h) `chunked_write_state_t chunkedWriteState = {...};`, [:284](../src/main.h) +`globalConfig`, [:289](../src/main.h) `encryptionSession`, [:374-391](../src/main.h) the rings, +`pServer`, and the callback objects. + +``` +$ grep -rn '#include "main.h"' src/ +src/main.cpp:1:#include "main.h" +``` + +**Exactly one translation unit includes it, and that is load-bearing.** A second includer gets +`multiple definition of 'bbep'` at link time; on nRF it additionally drags in `` +and the NimBLE-aliased `BLE*` types that §10 forbids in shared headers. Note also that +`communication.cpp` does **not** include `main.h` — it re-declares what it needs itself +(`extern chunked_write_state_t chunkedWriteState;` at [communication.cpp:83](../src/communication.cpp)). + +So every new declaration goes in a real guarded header. All of these exist and are correctly +guarded already: + +| New symbol | Declared in | Defined in | +|---|---|---| +| `resetChunkedWriteState()` | `communication.h` | `communication.cpp` | +| `flushCommandQueue()` / `flushResponseQueue()` | `session_guard.h` | `main.cpp` (ESP32 real, nRF empty — **both out-of-line**, no `static inline` in a header) | +| `linkIsUp()` | `session_guard.h` | `main.cpp`, per-target `#ifdef` | +| `serviceLinkDrop()` / `g_linkDropPending` | `session_guard.h` | `main.cpp` / `session_guard.cpp` | +| `serviceDeferredPanelOff()` / `epdForceOffPending` | `session_guard.h` | `main.cpp` / `session_guard.cpp` | +| `nrfDisconnectCleanupPending` | `session_guard.h` | `session_guard.cpp` | +| `epdStreamInProgress` | `display_service.h` (next to `epdRefreshInProgress`, [:77](../src/display_service.h)) | `display_service.cpp` | +| `pipeWriteActive()` / `partialWriteActive()` | `display_service.h` | `display_service.cpp` | +| `touchForceResumeAll()` | `touch_input.h` | `touch_input.cpp` | +| `touchForceResume()` | `display_service.h` | `display_service.cpp` | +| `buzzerForceStop()` | `buzzer_control.h` | `buzzer_control.cpp` | +| `ledForceStop()` | `device_control.h` | `device_control.cpp` | + +**`session_guard.h` may include** ``, ``, `structs.h`. **Never** `main.h`, +`ble_init.h`, ``, ``, or ``. +**`session_guard.cpp` may additionally include** `display_service.h`, `communication.h`, +`encryption.h`, `od_log.h`. **Never `main.h`.** + +The nRF no-op flushes must be **out-of-line functions in `main.cpp`**, not `static inline` in a +header — a `static inline` defined in `main.h` is invisible to `session_guard.cpp`, which is the +whole point of the exercise. + +`main.h` edits in this plan are therefore limited to what is *already* there: the `pending` +field removal (step 4) and the [main.h:365-370](../src/main.h) capacity comment (D5). Nothing +new is added to it. + +--- + +## 4. Step 1 — Public stop/reset helpers (prerequisites) + +`abortToKnownState()` needs four entry points that do not exist. Do these first; each is +independently mergeable and independently testable. + +### 4.1 `resetChunkedWriteState()` + +New in `communication.cpp`, declared in **`communication.h`** (§3a — *not* `main.h`, which +cannot be included twice; `communication.cpp` already re-declares `chunkedWriteState` itself at +[communication.cpp:83](../src/communication.cpp)). + +```c +void resetChunkedWriteState(void) { + chunkedWriteState.active = false; + chunkedWriteState.receivedSize = 0; + chunkedWriteState.expectedChunks = 0; + chunkedWriteState.receivedChunks = 0; + chunkedWriteState.totalSize = 0; + // buffer intentionally NOT zeroed: MAX_CONFIG_SIZE memset on every abort is + // wasted work; `active=false` makes the contents unreachable. +} +``` + +Then replace the four ad-hoc clear sites ([:550](../src/communication.cpp), +[:558](../src/communication.cpp), [:574-576](../src/communication.cpp)) with calls to it, so +a future field addition cannot leave a partial reset behind. (The [:496-513](../src/communication.cpp) +*start* path stays as-is — it initialises rather than resets.) + +**Note:** [:574-576](../src/communication.cpp) currently clears only 3 of the 5 fields — +`expectedChunks` and `totalSize` survive a completed config write. Harmless today because +`active=false` gates every reader, but the consolidation fixes it for free. + +### 4.2 `touchForceResume()` `[M3]` + +Two statics in two translation units, so this is two functions: + +```c +// touch_input.cpp / touch_input.h +void touchForceResumeAll(void) { + s_epd_refresh_suspend = 0; +} +``` + +```c +// display_service.cpp / display_service.h — the one abortToKnownState calls +void touchForceResume(void) { + directWriteTouchSuspended = false; + touchForceResumeAll(); +} +``` + +Rationale (review `[M3]`): zeroing the counter alone leaves `directWriteTouchSuspended` +`true`, so the *next* `cleanupDirectWriteState` calls `touchResumeAfterEpdRefresh()` against +an already-zero counter (early-returns at [touch_input.cpp:418](../src/touch_input.cpp)) and +the bool is consumed against a resume that never happened — the two drift apart. Clearing +both keeps them coupled. + +Ordering is load-bearing and matches the parent plan: `cleanupDirectWriteState(true)` runs +**first** (it does the correct paired decrement for the normal case), `touchForceResume()` +runs **later** as the belt-and-braces reset. Do not reorder. + +Add a debug-only assertion after the call that `s_epd_refresh_suspend == 0`. + +### 4.3 `buzzerForceStop()` / `ledForceStop()` + +Thin public wrappers, no behaviour change: + +```c +// buzzer_control.cpp / buzzer_control.h +void buzzerForceStop(void) { buzzer_stop_internal(); } +``` +```c +// device_control.cpp / device_control.h +void ledForceStop(void) { led_stop_internal(false); } // false: leave configured mode intact +``` + +`clear_mode=false` matches [device_control.cpp:394](../src/device_control.cpp) and +[:562](../src/device_control.cpp) — an abort stops the *sequence*, it does not reconfigure +the LED. `clear_mode=true` is reserved for an explicit `0x0075` stop. + +Both are no-ops when nothing is playing, so they are safe to call unconditionally. + +--- + +## 5. Step 2 — The drain-trap fix `[M5]` + +The single live-on-merge correctness fix. Today: + +```c +imageDataWritten(NULL, NULL, commandQueue[tail].data, commandQueue[tail].len); // :415 +commandQueue[tail].pending = false; // :416 +__atomic_store_n(&commandQueueTail, (tail + 1) % COMMAND_QUEUE_SIZE, RELEASE); // :417 +``` + +`imageDataWritten` can (after Phase 5/6) call `abortToKnownState` → `flushCommandQueue()`, +which sets `commandQueueTail := commandQueueHead`. Line 417 then **overwrites** that with a +stale `tail+1`, resurrecting every command the flush just discarded. + +**Exact placement — between `:415` and `:416`**, breaking *without* the tail store: + +```c +{ + // [C6] Clear any flag left over from an abort that ran OUTSIDE a drain -- + // serviceBleDisconnectCleanup() calls abortToKnownState() at main.cpp:370 and + // :428, neither of which is inside this loop. A stale flag would break the + // FIRST command of the next drain out without storing the tail, and that same + // slot would then be dispatched AGAIN on the following pass. See below. + commandDrainAbortPending = false; + + uint8_t drained = 0; + while (drained < COMMAND_QUEUE_SIZE) { + uint8_t tail = __atomic_load_n(&commandQueueTail, __ATOMIC_RELAXED); + uint8_t head = __atomic_load_n(&commandQueueHead, __ATOMIC_ACQUIRE); + if (tail == head) break; + + imageDataWritten(NULL, NULL, commandQueue[tail].data, commandQueue[tail].len); + + // [M5] Must sit HERE -- after the dispatch, before the tail store. A flush + // from handler context already did tail := head; storing tail+1 now would + // resurrect everything it discarded. + if (commandDrainAbortPending) { + commandDrainAbortPending = false; + break; // flushCommandQueue() already advanced the tail + } + __atomic_store_n(&commandQueueTail, (uint8_t)((tail + 1) % COMMAND_QUEUE_SIZE), __ATOMIC_RELEASE); + drained++; + flushResponseQueueToBle(); + } +} +``` + +Placing the check *after* the tail store would be wrong twice over: the store already +clobbered the flush, and the slot at `tail` may have been re-filled by the producer. + +#### `[C6]` The flag must not survive a pass — double-dispatch hazard + +`commandDrainAbortPending` is set by `flushCommandQueue()`, which is called from +`abortToKnownState()`, which D1b calls from `serviceBleDisconnectCleanup()` — and that runs at +[main.cpp:370](../src/main.cpp) (deep-sleep-wake branch, **before** the drain, then `return`s) +and [main.cpp:428](../src/main.cpp) (**after** the drain). Neither is inside the drain loop. + +So without the reset above, with a non-empty ring at that moment: + +1. Abort sets the flag. No drain consumes it this pass. +2. Next pass the drain dispatches `commandQueue[tail]`, *then* sees the stale flag, clears it, + and `break`s **without storing the tail**. +3. The pass after that dispatches the **same slot again**. + +For `CMD_CONFIG_WRITE`, `CMD_POWER_OFF`, `CMD_DEEP_SLEEP` or `CMD_REBOOT` a double dispatch is +not benign — and it would ship in the same commit as the `[M5]` fix it accompanies. Clearing at +the top of the drain block closes it: the flag then only ever spans the dispatch it was raised +during. + +No race: `commandDrainAbortPending` is written only by `flushCommandQueue()` and this reset, +both loop-task-only. The producer never touches it. + +**Alternative considered:** scope the flag to an active drain with a separate `commandDrainActive` +bool, so `flushCommandQueue()` raises the abort flag only when a drain is actually running. It is +more explicit but adds a second flag and a second invariant to keep true; the top-of-block reset +achieves the same thing in one line. **Take the reset; note the alternative if a future caller +ever needs to know whether it interrupted a drain.** + +**Minor, worth a comment not a fix:** when `flushCommandQueue()` runs from *inside* the drain, +its `dropped` count over-reports by one — the currently-dispatching slot has not had its tail +store yet, so it is still counted as queued. Log-only. + +**Delete the `pending` field** from both `CommandQueueItem` and `ResponseQueueItem` in +`structs.h`/`main.h` while here — it has no readers in either ring +(`grep -n 'pending' src/` shows assignments only, at +[esp32_ble_callbacks.h:125](../src/esp32_ble_callbacks.h), +[communication.cpp:119](../src/communication.cpp), +[main.cpp:291](../src/main.cpp), [:309](../src/main.cpp), [:416](../src/main.cpp)). Removing +it saves a byte per slot × 43 slots and, more importantly, removes a field that *looks* like +it participates in the SPSC protocol but does not. Update `tools/od-device-cli.py` only if +these structs are mirrored there — they are not (that file mirrors config packets, not +runtime rings), so no CLI change. + +**Also fold in the parent plan's `[H1]` comment fix while in `main.h`** — the +[main.h:365-370](../src/main.h) comment claims 33 slots hold "a full W=32 window + END", +but the producer refuses at `nextHead == tail`, so usable capacity is 32. Fix the comment +here (a comment-only change, safe in Phase 3); the actual `COMMAND_QUEUE_SIZE` bump to 34 +stays in Phase 7 where the DRAM decision belongs. **Decision D5.** + +--- + +## 6. Step 3 — `g_commandInFlight` depth counter `[H4]` + +Declared in `session_guard.h`, defined in `session_guard.cpp`: + +```c +extern volatile uint8_t g_commandInFlight; +``` + +Incremented/decremented around the `imageDataWritten` body — **inside** `imageDataWritten` +itself (`communication.cpp`), not at each call site, so every dispatcher (ESP32 drain, nRF +inline callback, LAN frame dispatch) is covered by construction. Use an RAII guard or a +single-exit `goto done` — `imageDataWritten` has multiple `return`s, so verify every path +decrements. + +Why a counter and not a bool (review `[H4]`): Bluefruit's `ada_callback_invoke()` falls back +to invoking the write callback **inline on the BLE event task** when `rtos_malloc` fails +(`BLECharacteristic.cpp:538-542`). Heap pressure during a large nRF52840 transfer is exactly +when that happens, so "one task on nRF" is not an invariant. With a bool, whichever +invocation nests out first clears it and the supervisor could abort under a live handler. + +Phase 3 only *maintains* the counter. Phase 6 consumes it ("abort only when depth is 0"). +Phase 5 consumes it for `nrfSessionClearPending`. + +Increment/decrement need not be atomic RMW on either target (single writer per nesting +level, and the nested case is same-core), but mark it `volatile` and add a comment saying +so, rather than leaving the reader to work it out. + +--- + +## 7. Step 4 — Queue flushes + +Both defined in `main.cpp` (where the rings live), declared in **`session_guard.h`** (§3a), +bodies guarded `#ifdef TARGET_ESP32`. `session_guard.cpp` calls them through that declaration — +it must never touch the rings directly, or it drags NimBLE types into a nRF build. + +```c +#ifdef TARGET_ESP32 +// Loop-task only. SPSC-safe: commandQueueTail has exactly one writer (the consumer, +// i.e. this task), so snapshotting head into tail cannot race the producer's head +// store. Discards payloads without dispatching them. +void flushCommandQueue(void) { + uint8_t head = __atomic_load_n(&commandQueueHead, __ATOMIC_ACQUIRE); + uint8_t tail = __atomic_load_n(&commandQueueTail, __ATOMIC_RELAXED); + if (tail == head) return; + uint8_t dropped = (head - tail + COMMAND_QUEUE_SIZE) % COMMAND_QUEUE_SIZE; + __atomic_store_n(&commandQueueTail, head, __ATOMIC_RELEASE); + // Breaks an in-progress drain (§5). Harmless when no drain is running: the drain + // block clears this at its top, so a flag set from serviceBleDisconnectCleanup() + // cannot leak into the next pass and double-dispatch a slot -- see [C6] in §5. + commandDrainAbortPending = true; + od_log_warn("Command queue flushed (%u dropped)", dropped); +} + +// Both head and tail are loop-task-only, so this is trivially safe. +void flushResponseQueue(void) { + if (responseQueueTail == responseQueueHead) return; + uint8_t dropped = (responseQueueHead - responseQueueTail + RESPONSE_QUEUE_SIZE) % RESPONSE_QUEUE_SIZE; + responseQueueTail = responseQueueHead; + od_log_warn("Response queue flushed (%u dropped)", dropped); +} +#endif +``` + +On nRF both are **empty out-of-line definitions in `main.cpp`** — `void flushCommandQueue(void) {}` — +so `session_guard.cpp` stays free of `#ifdef` clutter at the call sites. Deliberately *not* +`static inline` in a header: `main.h` cannot be included by `session_guard.cpp` at all (§3a), and +a `static inline` there would be invisible to it. + +**Callable only from the loop task — documented, not asserted (D4).** No `configASSERT`, no +`g_loopTaskHandle` capture, no `OD_DEBUG_ASSERTS` block. The invariant holds structurally today +(every ESP32 handler runs on the loop task), so the enforcement is a doc comment carrying the +full reasoning. **D4 specifies the exact comment text — use it verbatim; it is the only thing +protecting this property.** + +--- + +## 8. Step 5 — `src/session_guard.h` / `.cpp` + +### 8.1 Header surface + +```c +#pragma once +#include +#include + +// --- flags, serviced on the loop task --- +extern volatile bool commandDrainAbortPending; // set by flushCommandQueue(), consumed by the drain +extern volatile bool commandQueueOverflowAbort; // set by onWrite on ring-full (Phase 7 consumes) +extern volatile bool responseQueueOverflowAbort; // set by sendResponse on ring-full (Phase 7 consumes) +extern volatile bool epdForceOffPending; // deferred panel off — a refresh/stream must finish first (8.3.1) + +// --- in-flight depth (H4) --- +extern volatile uint8_t g_commandInFlight; + +// --- progress accounting (Phase 6 consumes) --- +extern volatile uint32_t g_lastProgressMs; +void markSessionProgress(void); + +// --- the recovery --- +void abortToKnownState(const char* reason, bool dropLink); +``` + +No NimBLE, no `WiFiClient`, no Arduino `String`. The link drop is delegated (§8.4) so the +header stays portable across all eleven envs. + +### 8.2 `markSessionProgress()` + +```c +void markSessionProgress(void) { g_lastProgressMs = millis(); } +``` + +Phase 3 defines the storage and the setter. **Whether Phase 3 also installs the six stamp +call sites is Decision D2.** The sites, per `[C1]` (recorded here so they are not re-derived +in Phase 6): + +1. `pipeState.expected_seq` advancing — inside the in-order accept, `display_service.cpp` +2. `directWriteBytesWritten` increasing +3. `chunkedWriteState.receivedChunks` incrementing — `communication.cpp:565` +4. `partialCtx` byte counter advancing +5. Refresh completion +6. `handleAuthenticate` success + +**Never** on command dispatch, notify, or LAN frame dispatch — that is the defect `[C1]` +exists to prevent (a post-`clearEncryptionSession` `RESP_AUTH_REQUIRED` retry flood is a +dispatch *and* a notify per retry, and would keep the stamp fresh forever in exactly the +wedge the supervisor exists to catch). + +### 8.3 `abortToKnownState()` + +```c +void abortToKnownState(const char* reason, bool dropLink) { + // 1. LOG FIRST — before any state is destroyed, so the log line describes the + // wedge and not the aftermath. + od_log_error("ABORT: %s (dropLink=%d) transfer=%d direct=%d pipe=%d partial=%d " + "chunked=%d refresh=%d inflight=%u", + reason, (int)dropLink, (int)transferActive(), (int)directWriteActive, + (int)pipeState.active, (int)partialCtx.active, + (int)chunkedWriteState.active, (int)epdRefreshInProgress, + (unsigned)g_commandInFlight); + + // 2. Discard queued responses. No client-facing abort frame is sent — sending one + // would need either a new response shape (forbidden, §2) or repurposing an + // existing code (changes its documented meaning, also forbidden). The client's + // own timeout is the notification. See Decision D3. + flushResponseQueue(); + + // 3. Discard undispatched commands (also raises commandDrainAbortPending) + flushCommandQueue(); + + // 4. Transfer state, in dependency order + cleanupDirectWriteState(true); // paired touch resume + panel release + cleanupPartialWriteOnDisconnect(); // 0x76 / pipe-partial session bookkeeping + resetPipeWriteState(); // pipe + reorder queue + resetChunkedWriteState(); // NEW (§4.1) + + // 5. Belt-and-braces peripheral reset + touchForceResume(); // NEW (§4.2) — after cleanupDirectWriteState + buzzerForceStop(); // NEW (§4.3) + ledForceStop(); // NEW (§4.3) + + // 6. Panel power. HARD INVARIANT: a refresh in flight ALWAYS runs to completion, + // and an in-flight controller stream is never cut mid-write (D7 hazard 1). + // Deferred, NOT abandoned -- serviceDeferredPanelOff() completes it. + if (!epdRefreshInProgress && !epdStreamInProgress) { + epdSessionForceOff(); + } else { + epdForceOffPending = true; + od_log_warn("ABORT: %s in progress — panel force-off deferred, not skipped", + epdRefreshInProgress ? "refresh" : "stream"); + } + + // 7. Crypto + link (D6c). The condition is the invariant, not the caller's + // opinion: a cleared session under a LIVE link is invisible to the client -- + // it keeps sending encrypted frames that all bounce 0xFE and never + // re-authenticates mid-stream. Clear only when the link is going or gone. + if (dropLink || !linkIsUp()) { + clearEncryptionSession(); + } + if (dropLink) { + odLinkDropRequest(); // deferred; serviced in loop() + } + + // 8. Owner token release — Phase 4 fills this in (no-op stub in Phase 3) + linkReleaseIfHeld(); + + markSessionProgress(); // clean slate: do not re-fire immediately +} +``` + +**The ordering defect in the parent plan, and how D3 dissolves it.** The parent plan orders +the teardown "optional client NACK → … → flush response ring". On ESP32 `sendResponse` +*enqueues* ([communication.cpp:112-120](../src/communication.cpp)) and `flushResponseQueue()` +*discards*, so NACK-then-flush would throw the NACK away and the client would learn nothing. +The fix was going to be an inversion (flush, then NACK, then `flushResponseQueueToBle()`). + +D3 removes the notification entirely, so the ordering question disappears with it: the +response flush is unconditional and nothing is queued after it. Recorded because the defect is +real and will resurface if the pipe-only NACK follow-up in D3 is ever implemented — that +sender must run **after** the flush, not before. + +**Idempotence.** Every callee is already idempotent (`cleanupDirectWriteState` no-ops when +`!directWriteActive`, `epdSessionForceOff` is documented idempotent at +[display_service.h:21](../src/display_service.h), the flushes early-return on empty). + +**Re-entrancy guard must be ATOMIC (per D7).** A plain `static bool inAbort` is not enough: +D7 settles that `abortToKnownState` is **never gated**, so on nRF two tasks can enter it +concurrently and both read `false`. Use a test-and-set: + +```c +static volatile uint8_t inAbort = 0; +if (__atomic_exchange_n(&inAbort, 1, __ATOMIC_ACQ_REL)) { + od_log_warn("ABORT: re-entered (%s) — first pass owns the teardown", reason); + return; +} +// ... teardown ... +__atomic_store_n(&inAbort, 0, __ATOMIC_RELEASE); +``` + +The guard is also needed for the plain nested case: step 4 can, through +`cleanupDirectWriteState`, reach code that could later grow an abort call. + +**Never gated.** `abortToKnownState` runs whenever it is called, at any in-flight depth, on +either task. The `g_commandInFlight` check lives in nRF `loop()`-side **callers** (§9.2), never +here — see D7 for the research behind that, and copy D7's header comment onto the function so +Phase 6 does not reintroduce a gate. + +#### 8.3.1 Invariant: a panel refresh always completes + +**`abortToKnownState` must never interrupt an e-paper refresh.** This ranks above the teardown's +own urgency, and it is the one place where the abort defers rather than acts. Three reasons it +is not negotiable: + +1. **An interrupted refresh damages the image, and on some panels the panel.** Cutting the rail + or sleeping the controller mid-waveform leaves a partially-driven frame — ghosting, or a + latched pixel state that the next full refresh has to clear. On 3/4/7-colour Spectra the + waveform runs 30-60 s and `bbepWaitBusy` caps at 30 000 ms (`bb_ep.inl:3959-3975`); there is + no safe abort point inside it. +2. **The device is not wedged during a refresh — it is working.** A refresh is the successful + end of a transfer, not a symptom. Aborting one converts a completing operation into a failed + one and forces the client to re-push the whole image. +3. **`displayed_etag` correctness.** A refresh that is cut partway leaves the panel showing + neither the old nor the new image while RTC state may already claim the new etag, so the next + push can be skipped as a no-op against an image that was never actually drawn. + +**Deferred means deferred, not skipped.** The abort sets `epdForceOffPending` and returns; the +rest of the teardown (queues, transfer state, touch, buzzer/LED, session) completes immediately, +because none of it touches the panel. A loop-serviced tick then finishes the job the moment the +refresh does: + +```c +// main.cpp — called from loop() on both targets, next to serviceBleDisconnectCleanup() +static void serviceDeferredPanelOff(void) { + if (!epdForceOffPending) return; + if (epdRefreshInProgress || epdStreamInProgress) return; // still busy — try next pass + epdForceOffPending = false; + od_log_info("Deferred panel force-off completing (refresh/stream finished)"); + epdSessionForceOff(); +} +``` + +Note the ESP32 disconnect path defers the **whole** teardown while a refresh is in flight +([main.cpp:322](../src/main.cpp)) and, per §9.2, nRF now does the same. So on the D1b disconnect +path the refresh is protected twice over. `epdForceOffPending` covers the callers that do *not* +defer wholesale — Phase 6's supervisor and Phase 7's idle timeout — where the abort itself must +run promptly but the panel must still be left alone. + +**Do not add a timeout to this deferral in Phase 3.** The refresh is already bounded: Phase 2 +`[X3]` makes `fastepd_wait_refresh` honour its timeout, `waitforrefresh(60)` bounds the bbep +path, and `bbepWaitBusy` caps at 30 s. If those bounds hold, `epdForceOffPending` clears within +a minute; if they do not, the bug is in the bound, not here. A timeout here would reintroduce +exactly the mid-refresh cut this invariant exists to prevent. + +**Re-entrancy from the drain.** `abortToKnownState` will (Phase 5/6) be reached from +*inside* `imageDataWritten`, i.e. mid-drain. That is precisely why step 3 sets +`commandDrainAbortPending` rather than relying on the tail store — see §5. + +### 8.4 `odLinkDropRequest()` — deferred, not inline + +Dropping the link from inside `abortToKnownState` would mean calling +`pServer->disconnect()` (NimBLE) or `Bluefruit.disconnect()` from a header shared with an +env that `lib_ignore`s NimBLE. Instead: + +```c +// session_guard.cpp — portable +void odLinkDropRequest(void) { g_linkDropPending = true; } +``` + +and a `serviceLinkDrop()` in `main.cpp` under the existing per-target `#ifdef`, called from +`loop()` next to `serviceBleDisconnectCleanup()`. Phase 4 extends it with the owner token +and the `BLE_ERR_REM_USER_CONN_TERM` (0x13) reason code + return check `[C3]`. + +This also fixes a subtler problem: an inline disconnect from the NimBLE host task while +`abortToKnownState` is mid-teardown would fire `onDisconnect` → `bleDisconnectCleanupPending` +→ a *second* teardown pass. Deferring keeps it to one. + +--- + +## 9. Step 6 — Wiring the two disconnect teardowns (D1b) + +### 9.1 ESP32 — `serviceBleDisconnectCleanup()` + +Replace [main.cpp:343-347](../src/main.cpp): + +```c + if (directWriteActive) cleanupDirectWriteState(true); + cleanupPartialWriteOnDisconnect(); + resetPipeWriteState(); +``` + +with a single call: + +```c + abortToKnownState("disconnect", /*dropLink=*/false); +``` + +Three things must hold, and each is a review point: + +- **The `ownerStillUp` early-return at [main.cpp:328-338](../src/main.cpp) stays strictly in + front of the call.** This is not optional. `disconnectWiFiServer()` raises the *same* + `bleDisconnectCleanupPending` flag ([wifi_service.cpp:812](../src/wifi_service.cpp)), and it + is called from the WiFi-lost tick at [main.cpp:447](../src/main.cpp) — so on a WiFi-enabled + env, losing WiFi routes into this function while a BLE client is mid-transfer. The guard is + what stops that from tearing the BLE transfer down. Wiring the abort in front of the guard + would create the exact bug Phase 4 `[C4]` exists to fix. +- **`dropLink=false`, always.** The link is already gone by definition on this path; requesting + a drop would queue a disconnect against a dead handle. +- **Do not move the guard out of `#ifdef OPENDISPLAY_HAS_WIFI` yet.** That is Phase 4 `[C4]` + and it is tied to the refused-gatecrasher handle discrimination, which does not exist in + Phase 3. On `esp32-N4` (no WiFi) the guard is absent, but so is the LAN path that raises the + flag spuriously — the flag is only ever set by a genuine BLE disconnect there, so Phase 3 is + safe without it. **Recheck this the moment Phase 4 lands.** + +Net behavioural delta on ESP32: the teardown additionally resets chunked-config state, force- +resumes touch, stops buzzer/LED, and force-offs the panel when no refresh is in flight. All are +either no-ops or strictly correct on a link that just went away. + +### 9.2 nRF — needs a new deferral, not a direct substitution + +`disconnect_callback` ([device_control.cpp:227-240](../src/device_control.cpp)) runs on the +Bluefruit **Callback** task, not `loop()`. Today it calls three cleanup functions inline from +that task. A naive substitution would additionally run `epdSessionForceOff()` (bbepSleep → +`bbepWaitBusy` → SPI teardown → rail cut) and — via **D6** — `clearEncryptionSession()` from +the callback task. + +Both are unsafe there, for reasons already established in this plan family: + +- `epdSessionForceOff()` is precisely the heavyweight, SPI-touching work ESP32 defers to + `loop()` for ("the session teardown below … is heavyweight, state-mutating work that races + loop()'s SPI streaming", [esp32_ble_callbacks.h:62-68](../src/esp32_ble_callbacks.h)). nRF has + no equivalent excuse to run it inline. +- `clearEncryptionSession()`'s `memset(session_key, 0, 16)` from a non-loop task is the `[H4]` + race verbatim: Bluefruit's `ada_callback_invoke()` falls back to invoking the write callback + inline on the BLE task when `rtos_malloc` fails, so `aes_ccm_decrypt` can be running + concurrently. + +**So nRF gets the same flag-and-defer shape ESP32 already has:** + +```c +// device_control.cpp — disconnect_callback, now flag-only +void disconnect_callback(uint16_t conn_handle, uint8_t reason) { + od_log_info("=== BLE CLIENT DISCONNECTED === reason: %u", reason); + nrfDisconnectCleanupPending = true; // NEW +} +``` + +```c +// main.cpp, nRF arm of loop() — next to the other loop-serviced work +if (nrfDisconnectCleanupPending && !epdRefreshInProgress && g_commandInFlight == 0) { + nrfDisconnectCleanupPending = false; + abortToKnownState("disconnect", /*dropLink=*/false); +} +``` + +The `g_commandInFlight == 0` term is what makes the deferral actually close `[H4]` rather than +just move it: it guarantees no `imageDataWritten` is on the stack (on either task) when the +session key is zeroed. This is the one place in Phase 3 where the depth counter from §6 is +*read* rather than merely maintained — everywhere else it is Phase 5/6's to consume. + +**This pulls part of `[H4]` forward from Phase 5 into Phase 3.** That is a deliberate +consequence of D1b: wiring the caller early means the safety property the caller depends on has +to come with it. Phase 5's `nrfSessionClearPending` becomes redundant — record that there so it +is not implemented twice. + +Secondary benefit: nRF gains the `epdRefreshInProgress` deferral it does not have today. A +disconnect mid-refresh currently runs `cleanupDirectWriteState(true)` straight through the +refresh on nRF; after this it waits, matching ESP32. + +### 9.3 D1b × D6 — Phase 3 now delivers the BLE-disconnect session clear + +With D6(c)'s `dropLink || !linkIsUp()` condition, `abortToKnownState("disconnect", false)` +evaluates `!linkIsUp()` → true (the link is gone) → clears the session. That is the parent +plan's confirmed user decision *"clear encryption session on BLE disconnect"*, which the parent +plan scheduled for **Phase 5**. + +**One documented exception:** on a WiFi-enabled env with a LAN client connected, `linkIsUp()` +returns true and the session is not cleared — `EncryptionSession` records no origin, so Phase 3 +cannot tell whose session it is. Deliberate and safe (erring the other way would destroy a live +LAN session from a BLE event); Phase 4's owner token closes it. Full reasoning in D6. + +This is a scope gain, not a scope creep — the behaviour is required, it is one condition, and +D1b makes it fall out for free. But it must be recorded so Phase 5 does not re-implement it: + +| Parent-plan Phase 5 item | Status after Phase 3 + D1b | +|---|---| +| Clear session on BLE disconnect | **Delivered here.** Phase 5 verifies, does not re-add. | +| nRF deferred session clear `[H4]` | **Delivered here** (§9.2). `nrfSessionClearPending` is redundant. | +| `[H2]` clear placed after the `ownerStillUp` guard | **Satisfied here** by §9.1's ordering rule. Phase 5 re-checks it under the owner token. | +| Guard *inside* `clearEncryptionSession()` (dead-session-with-live-link) | **Still Phase 5.** Phase 3 relies on the call-site condition; Phase 5 makes it un-regressable. | +| `session_timeout_seconds` expiry disabled | **Still Phase 5.** Untouched here. | +| Pipe NACK latch / `error_since_ms` | **Still Phase 5.** Untouched here. | + +**Risk this introduces:** the session clear goes live one phase earlier than planned, without +Phase 5's in-function guard as a backstop. The mitigation is that both call sites pass +`dropLink=false` on a path where the link is provably down, so the "dead session under a live +link" failure mode is unreachable from Phase 3's callers. Any *new* caller added before Phase 5 +must be checked against that by hand. + +--- + +## 10. Build guards — the eleven-env matrix + +`session_guard.cpp` compiles into every env. The rules: + +| Symbol | Guard | +|---|---| +| `commandQueue` / `responseQueue` / `pServer` | `#ifdef TARGET_ESP32`, and reached **only** through `session_guard.h`-declared functions defined in `main.cpp` — never named in `session_guard.cpp` (§3a) | +| Any LAN call (`wifiLanClientConnected`, `opendisplay_lan_*`) | `#ifdef OPENDISPLAY_HAS_WIFI` — **never** `TARGET_ESP32`; `esp32-N4` is ESP32 without WiFi | +| Any NimBLE type | must not appear in `session_guard.h` at all (nRF sets `lib_ignore = NimBLE-Arduino`) | +| FastEPD-only symbols | `#ifdef OPENDISPLAY_FASTEPD` | + +RAM cost: `commandDrainAbortPending` + 2 overflow flags + `g_commandInFlight` + +`g_linkDropPending` + `epdForceOffPending` + `epdStreamInProgress` + `sessionScrubPending` +(8 bytes) + `g_lastProgressMs` (4 bytes) = **12 bytes `.bss`**, minus the `pending`-field +removal (−43 bytes across both rings). Net negative. `esp32-N4` — the +DRAM-tight env that already needs `PIPE_SMALL_DRAM_WINDOW` ([structs.h:45-48](../src/structs.h)) +— is not at risk from Phase 3. (It *is* the gate for Phase 1's `replay_window[256]`; unrelated.) + +--- + +## 11. Implementation order (each step independently buildable) + +| # | Step | Files | Live on merge? | +|---|---|---|---| +| 1 | `resetChunkedWriteState()` + consolidate 4 clear sites | `communication.cpp`, `communication.h` | yes (fixes the partial reset at :574) | +| 2 | `touchForceResume()` / `touchForceResumeAll()` | `touch_input.*`, `display_service.*` | not yet | +| 3 | `buzzerForceStop()` / `ledForceStop()` wrappers | `buzzer_control.*`, `device_control.*` | not yet | +| 4 | Delete `pending` from both ring structs; fix the `main.h:365` capacity comment | `structs.h`, `main.h`, `main.cpp`, `communication.cpp`, `esp32_ble_callbacks.h` | yes | +| 5 | `g_commandInFlight` depth counter | `session_guard.*`, `communication.cpp` | yes — read by step 9 | +| 6 | `flushCommandQueue()` / `flushResponseQueue()` + nRF no-ops | `main.cpp`, `session_guard.h` | not yet | +| 7 | Drain-trap fix `[M5]` | `main.cpp` | **yes** | +| 8 | `session_guard.h/.cpp`: flags, `markSessionProgress`, `abortToKnownState`, `odLinkDropRequest` | new | not yet | +| 9 | `serviceLinkDrop()` + **(D6c) `linkIsUp()`** per-target | `main.cpp`, `session_guard.h` | not yet | +| 9a | **(D7) `epdStreamInProgress`** set/cleared at the two streaming choke points | `display_service.cpp/.h` | not yet | +| 9b | **(D7/§8.3.1) `epdForceOffPending` + `serviceDeferredPanelOff()` in `loop()`** | `session_guard.*`, `main.cpp` | not yet | +| 9c | **(D7) `clearEncryptionSession()` invalidate/scrub split** + `sessionScrubPending` service | `encryption.cpp`, `main.cpp` | **yes — changes existing behaviour** | +| 10 | **(D1b) Wire ESP32 `serviceBleDisconnectCleanup` → `abortToKnownState` (§9.1)** | `main.cpp` | **yes — steps 1-9 all go live here** | +| 11 | **(D1b) nRF: flag-only `disconnect_callback` + loop-serviced abort (§9.2)** | `device_control.cpp`, `main.cpp`, `session_guard.h` | **yes** | + +**Commit split.** Steps 1–9 as one commit (mechanically inert: new helpers, new file, one +correctness fix — nothing calls the teardown yet). Steps 10–11 as a second commit, which is the +one that changes runtime behaviour on every disconnect and is therefore the one to review +hardest and bisect to if a soak regresses. + +Step 11 is deliberately last: it is the only step that changes *which task* work runs on, and +it depends on step 5's depth counter existing. Do not merge 11 without 5. + +Per D2, the six `markSessionProgress()` stamp sites are **not** in this list — they land in +Phase 6 with the rest of `[C1]`. + +Build after each: + +```bash +pio run -e nrf52840custom -e esp32-s3-N16R8 -e esp32-c3-N16 -e esp32-c6-N4 -e esp32-N4 +``` + +CI builds all eleven on push — `esp32-N4` (no WiFi) and `nrf52840custom` (no NimBLE, no +rings) are the two that catch guard mistakes. + +--- + +## 12. Verification + +**Wire-protocol constraint (§2) — run first, before anything else is called done:** + +```bash +cd ../opendisplay-protocol && tools/sync_protocol_header.py --check --only Firmware # must pass, unchanged +cd ../Firmware && git diff main --stat -- include/opendisplay_protocol.h include/opendisplay_structs.h # must be empty +``` + +Phase 3 touches neither file, so both must be clean on the first run — if either reports a +diff, something in the implementation drifted out of scope. Two Phase-3-specific additions to +the check: + +```bash +git diff main -- src/ | grep -nE '^\+.*(RESP_|CMD_)[A-Z_]+ *=' # must be empty: no new opcode/response values +git diff main --stat -- docs/pipe-write-protocol.md # must be empty in Phase 3 (§5.1 note is Phase 5) +``` + +**Build:** all eleven envs. Specifically confirm `esp32-N4` compiles `session_guard.cpp` +with no LAN symbols and `nrf52840custom` with no NimBLE symbols. + +**Static:** `grep -n 'pending' src/` returns nothing in ring context after step 4. + +**Client compatibility (the constraint's actual purpose):** an unmodified `py-opendisplay` +and an unmodified HA integration must both drive a full transfer against Phase 3 firmware +with no change and no new warning. Since Phase 3 sends no new frame and removes no existing +one, a passing pre-Phase-3 transfer must pass identically — any behavioural difference +visible to the client is a constraint violation, not a Phase 3 feature. + +**Bench (no hardware needed):** +- Force `abortToKnownState("test", false)` from a debug command with no transfer active → + logs once, every subsystem no-ops, device still serves commands afterwards. +- Call it twice back to back → second call is a clean no-op (idempotence + re-entrancy guard). + +**D1b — the disconnect path is now the primary test surface.** Every one of these is a +connect/disconnect cycle, so they are cheap to run and must all pass on **both** targets: + +| Case | Expected | +|---|---| +| Connect, do nothing, disconnect | One `ABORT: disconnect` line, all state flags false, advertising resumes | +| Disconnect **mid-pipe-transfer** | Pipe + reorder queue cleared, panel rail down, touch responsive, next transfer clean | +| Disconnect **mid-chunked-config-write** | `chunkedWriteState.active` false (this is new — the old teardown left it set) | +| Disconnect **during a refresh** | Teardown **deferred**, refresh completes intact, teardown runs on a later pass. On nRF this is new behaviour (§9.2) | +| **Supervisor/idle abort during a refresh** (Phase 6/7 trigger, forced here with a debug opcode) | Refresh completes **untouched**; abort tears down queues/state immediately; `epdForceOffPending` logs, then `serviceDeferredPanelOff()` powers the panel down after the refresh ends. **This is the §8.3.1 invariant test — the panel must never be cut mid-waveform** | +| Abort during PIPE streaming (mid-`bbepWriteData`) | Rail stays up (`epdStreamInProgress`), no SPI/CS activity against a dead panel, force-off completes after the stream unwinds | +| Abort mid-decrypt on nRF (heap-pressure double-handler) | `isAuthenticated()` false immediately; in-flight decrypt completes with an intact key; scrub lands on a later loop pass. No `integrity_failures` bump from a half-zeroed key | +| Disconnect with buzzer/LED active | Both stop immediately (new) | +| Disconnect, then reconnect and re-auth | Succeeds — session cleared via `!linkIsUp()` (D6c), client gets a fresh challenge | +| Disconnect, reconnect, replay a captured pre-disconnect frame | **Rejected.** Session was cleared, so there is no key to validate against. Depends on Phase 1 — run it on the merged Phase 1+3 tree | +| **WiFi env only:** LAN client connected, BLE disconnects, then BLE reconnects | Session **not** cleared (documented D6 gap) → the reconnecting BLE client re-authenticates and gets a fresh session anyway. Confirm no `0xFE` bounce loop | +| **WiFi env only:** BLE client transferring, WiFi drops | BLE transfer **survives** — `ownerStillUp` guard held (§9.1). This is the regression test for the guard-ordering rule | +| **WiFi env only:** LAN client transferring, BLE client disconnects | LAN transfer survives | +| **`esp32-N4` only:** BLE disconnect | Teardown runs (no guard present, none needed — no LAN path raises the flag) | + +**nRF-specific, for §9.2:** confirm via log timestamps that the teardown runs on the loop task +and not the callback task — the `ABORT:` line must appear after a `loop()` boundary marker, not +interleaved with the disconnect callback's own logging. Then repeat the disconnect-mid-transfer +case under heap pressure (large transfer) to exercise the `g_commandInFlight` gate. + +**Bench — `[C6]` double-dispatch regression test.** Queue several commands, then trigger an +abort from *outside* the drain (a disconnect is the natural way, since D1b wires it). Confirm +from the log that after the flush **no command is dispatched twice** across the following two +loop passes. Without the top-of-block reset, the first command of the next drain runs on two +consecutive passes. Use a command with a visible side effect (LED or buzzer) rather than a +silent one. + +**Hardware — the drain-trap regression test (step 7's reason for existing):** start a +PIPE_WRITE with a deep in-flight window (W=32), trigger an abort from *inside* a dispatched +command (temporary debug opcode that calls `abortToKnownState`), and confirm via log that no +queued command is dispatched after the flush line. Without the fix, `drained` continues and +stale commands execute; with it, the drain breaks immediately. + +Note that D1b does **not** exercise this: the disconnect path calls `abortToKnownState` from +`serviceBleDisconnectCleanup`, which runs *outside* the drain loop, so the tail-store clobber +never arises there. The drain trap still needs its own deliberate trigger. + +**Hardware — teardown completeness:** mid-transfer abort, then verify by observation: +touch responds again (`s_epd_refresh_suspend == 0`), buzzer silent, LED off, panel rail +down, `transferActive()` false, a fresh transfer starts clean. + +**Hardware — refresh protection:** abort during a Spectra full refresh → log shows "panel +force-off deferred", refresh completes intact, panel powers down normally afterwards. This +depends on Phase 2's `[X2]` (`epdRefreshInProgress` set around the boot-refresh paths) being +landed first. + +--- + +## 13. Decisions + +**All decisions settled — no blockers remain.** + +| # | Decision | Where it lands | +|---|---|---| +| D1 | (b) wire both disconnect teardowns now | §9 (whole section exists for it) | +| D2 | (b) all of `[C1]` in one Phase 6 commit | §8.2 defines storage only | +| D3 | (c) send nothing — no client-facing abort frame | §2, §8.3 (step 3 deleted) | +| D4 | document only, no assert | §7 comment text | +| D5 | fix the `main.h:365` comment now; size bump stays Phase 7 | step 4 | +| D6 | (c) derive as `dropLink \|\| !linkIsUp()` | §8.3 step 7 | +| D7 | never gate `abortToKnownState` | §8.3 atomic guard, §9.2 caller-side check | + +### D1 — Land `abortToKnownState()` with no callers, or wire one now? `(SETTLED — (b))` + +**Decision: (b) — wire the two existing disconnect teardowns now.** Replace +[main.cpp:343-347](../src/main.cpp) and [device_control.cpp:237-239](../src/device_control.cpp) +with `abortToKnownState("disconnect", false)`. This is the same set of calls plus the new +chunked/touch/buzzer/LED/panel resets — a strict superset of today's behaviour — and it gets +the teardown exercised on every disconnect immediately rather than leaving it untested in the +tree until Phase 5. + +Rejected: **(a) land dead** (parent plan as written) — clean phase boundaries, but an +unexercised teardown is exactly the kind of code that is wrong on first use; **(c) debug-only +trigger** — tests a synthetic path and then deletes the only caller. + +**Full implementation consequences are in §9**, which exists because of this decision. In +brief: the ESP32 `ownerStillUp` guard must stay in front of the call (§9.1); the nRF site needs +a new loop-serviced deferral rather than a direct substitution, which pulls part of `[H4]` +forward from Phase 5 (§9.2); and combined with D6 this delivers the BLE-disconnect session +clear one phase early (§9.3). + +### D2 — Do the six `markSessionProgress()` stamp sites land in Phase 3 or Phase 6? `(SETTLED — (b))` + +**Decision: (b) — everything `[C1]` lands in one Phase 6 commit.** `[C1]` is the +highest-consequence finding in the review ("progress means the state machine advanced — never +'a command arrived' or 'a notify succeeded'"), and it reads and reviews far better as a single +self-contained change than as storage in one phase and semantics in another. + +Rejected: **(a) stamps in Phase 3** — mechanically harmless (unread stamps change no +behaviour), but it splits one finding across two phases and two reviews, and a reviewer looking +at the Phase 6 diff would not see the stamp placement that is the entire point of `[C1]`. + +**What Phase 3 still owes Phase 6**, so the split is clean: +- `g_lastProgressMs` storage and the `markSessionProgress()` setter (§8.2) — defined, callable, + unused. +- The six stamp sites are *enumerated* in §8.2 so Phase 6 does not re-derive them from `[C1]`. +- One live call: `abortToKnownState()` ends with `markSessionProgress()` (§8.3) so a completed + teardown leaves a clean slate and the Phase 6 supervisor cannot re-fire immediately on a + wedge it just cleared. + +**Consequence to accept:** Phase 3 as merged has `g_lastProgressMs` written by exactly one +caller and read by none. That is intentional dead storage, not an oversight — do not "clean it +up" before Phase 6, and do not let a linter strip it. + +### D3 — What does the "optional client NACK" actually send? `(SETTLED by the hard constraint — §2)` + +The parent plan says "optional client NACK (skip when dropping link)" without specifying the +frame. The wire-protocol constraint removes most of the option space: + +- ~~**(b) A generic `{RESP_NACK, 0x00, reason_code}`**~~ — **forbidden.** A new response shape + is a protocol change, and it would need a py-opendisplay change to interpret. Ruled out by + §2, not by preference. +- **(a) Reuse `sendPipeNack(err)`** when `pipeState.active`, nothing otherwise. Permitted in + principle — reusing an existing code is in bounds *"as long as the code's documented meaning + is unchanged"* — but only for a genuine pipe failure. It may **not** be sent for a + chunked-config or direct-write abort, which would repurpose `0x81` and change its documented + meaning. It also has side effects: it sets `pipeState.error` and calls + `cleanupDirectWriteState`/`cleanup_partial_write_state` itself + ([display_service.cpp:2564-2578](../src/display_service.cpp)), duplicating step 5. Would need + a payload-only variant. +- **(c) Send nothing.** The client already treats silence as a timeout + (`TIMEOUT_PIPE_DATA_COMPRESSED = 5.0` × `MAX_PTO = 3` ≈ 15 s) and every `0x81` NACK as + immediately fatal — it never re-reads the ACK position after one (established in `[L2]`). + +**Decision: (c). Step 3 of `abortToKnownState` sends nothing, and the `dropLink` parameter no +longer gates a notification.** The client derives no benefit it does not already get from its +own timeout, and (c) is the only option with zero protocol surface. This means **step 3 does +not exist** — the teardown is flush → flush → state reset, with no client-facing frame. + +If a fast-fail is later shown to matter on hardware, the follow-up is (a) narrowed to the pipe +case only: factor a `pipeBuildAckPayload`-only sender out of `sendPipeNack` so it has no side +effects, and call it **only** when `pipeState.active && !pipeState.error`. That stays inside +the constraint. It is explicitly not Phase 3 work. + +**Simplification this unlocks:** with no notification, `dropLink` now controls exactly two +things — the session clear (D6) and `odLinkDropRequest()`. Update the `abortToKnownState` +sketch in §8.3 to drop step 3 and its `flushResponseQueueToBle()`; the response-ring flush in +step 2 becomes unconditional and final. + +### D4 — Enforce "loop task only" with an assert, or document it? `(SETTLED — document only, no code)` + +**Decision: document the invariant, add no assert and no task-handle capture.** No +`configASSERT`, no `g_loopTaskHandle`, no `OD_DEBUG_ASSERTS` block — §7's sketch drops its +assert line entirely. + +Rationale: the invariant currently holds *structurally* rather than by enforcement. On ESP32 +every handler runs on the loop task (`onWrite` only enqueues, +[esp32_ble_callbacks.h:118-128](../src/esp32_ble_callbacks.h); the drain dispatches, +[main.cpp:408-421](../src/main.cpp); LAN dispatch is also in `loop()`), so there is no existing +caller that could violate it — an assert would guard against a caller that does not exist. On +nRF both flushes are empty stubs, so the invariant is vacuous there. Adding a task handle and a +debug-only branch to protect a structurally-guaranteed property is cost without a current +benefit. + +**What this costs, stated honestly:** the property becomes convention-enforced. D7 makes that +slightly sharper — `abortToKnownState` is now explicitly callable from any task, and it calls +`flushCommandQueue()`. Today that is still safe on ESP32 (every ESP32 abort caller is on the +loop task, because every ESP32 handler is), but the *function* no longer advertises a +task restriction that its *caller* does not. The doc comment has to carry that weight. + +**Required comment on `flushCommandQueue()`** — it must state the invariant, why it holds, and +what breaks if it stops holding, because nothing else will: + +```c +// LOOP TASK ONLY. Not asserted -- enforced by structure, not by code (plan D4). +// +// SPSC safety: commandQueueTail has exactly ONE writer (the consumer). Snapshotting +// head into tail is safe only from that consumer. Today every ESP32 caller is on the +// loop task because every ESP32 command handler is -- onWrite() only enqueues, the +// drain and LAN dispatch both run in loop(). abortToKnownState() is callable from any +// task by design (D7), but on ESP32 it is only ever REACHED from loop-task contexts. +// +// If a future change dispatches commands from the NimBLE host task, or calls +// abortToKnownState() from one, this becomes a genuine two-writer race on +// commandQueueTail -- silently resurrecting or double-dispatching queued commands. +// Add a task assert then; do not assume this comment still describes reality. +``` + +Mirror a shorter version on `flushResponseQueue()` (both head and tail are loop-task-only, so +its invariant is stronger and simpler). + +**Escalation trigger for a future phase:** if Phase 4 or later adds any cross-task +`abortToKnownState` caller on ESP32, reopen D4 and add the assert — that is the condition under +which the structural guarantee lapses. + +### D5 — Fix the `main.h:365` capacity comment in Phase 3, or leave it to Phase 7? `(SETTLED — fix now)` + +**Decision: fix the comment in Phase 3 (step 4); the `COMMAND_QUEUE_SIZE` → 34 bump stays in +Phase 7.** Phase 3 already edits `CommandQueueItem` two lines below to remove `pending`, so +leaving a false capacity claim in the block being touched is the worst of both options. + +The comment at [main.h:365-370](../src/main.h) currently claims 33 slots hold "a full W=32 +in-flight window + END". They do not: the producer refuses at `nextHead == tail` +([esp32_ble_callbacks.h:121-122](../src/esp32_ble_callbacks.h)), so usable capacity is +`COMMAND_QUEUE_SIZE - 1 = 32` — a full window with **no** room for END. Finding `[H1]`. + +Rewrite it to state the ring-capacity rule explicitly, the true usable depth, and that the +shortfall is deliberate-for-now with the fix scheduled: + +```c +// Usable capacity is COMMAND_QUEUE_SIZE - 1 = 32: the SPSC producer refuses at +// nextHead == tail, so one slot is always reserved to distinguish full from empty. +// 32 holds a full W=32 in-flight PIPE_WRITE window but leaves NO room for the END +// frame -- a sustained full window can drop END, which the client recovers from via +// SACK retransmit (pipe-write-protocol.md 5.2). Phase 7 bumps this to 34 on envs with +// DRAM to spare (NOT esp32-N4). Sized for a 60 s Spectra SPI stall (loop blocked in +// bbepWriteData). OD_BLE_MAX_FRAME (256) covers pipe <=244, legacy <=232, HA <=244. +``` + +**Do not change the value to 34 here.** That is a DRAM decision per env and `esp32-N4` — which +already needs `PIPE_SMALL_DRAM_WINDOW` ([structs.h:45-48](../src/structs.h)) — must be excluded. +It belongs with Phase 7's queue-full handling, where the overflow policy that makes the extra +slot meaningful also lands. + +**The duplicate definition is a trap here.** `COMMAND_QUEUE_SIZE 33` is defined in **two** +places — [main.h:371](../src/main.h) and [esp32_ble_callbacks.h:19](../src/esp32_ble_callbacks.h) +(under `#ifndef`). Phase 3 only touches the comment so no drift is possible now, but Phase 7 +must change both or the `#ifndef` will silently keep 33 depending on include order. Flag it in +the comment so Phase 7 cannot miss it. + +### D6 — Should `dropLink=false` ever clear the encryption session? `(SETTLED — (c) derive it)` + +**Decision: (c) — derive the clear from `dropLink || !linkIsUp()`.** It encodes the actual +invariant ("never leave a dead session under a live link") in one place instead of making every +caller re-derive it, and it composes with Phase 5's guard inside `clearEncryptionSession()` +itself rather than duplicating it. + +Rejected: **(a) a third `clearSession` parameter** — pushes a safety-critical judgement onto +every future caller, and the one thing D1b showed is that callers get added faster than the +plan expects; **(b) leave the clear out entirely** — same problem, plus it silently drops the +confirmed user decision on the disconnect path. + +Amend §8.3 step 7: + +```c + // 7. Crypto + link. The condition is the invariant, not the caller's opinion: + // a cleared session under a LIVE link is invisible to the client -- it keeps + // sending encrypted frames that all bounce 0xFE and never re-authenticates + // mid-stream. So clear only when the link is going away or already gone. + if (dropLink || !linkIsUp()) { + clearEncryptionSession(); + } + if (dropLink) { + odLinkDropRequest(); // deferred; serviced in loop() + } +``` + +#### `linkIsUp()` — scope, and the gap it leaves + +Portable predicate, declared in **`session_guard.h`** (§3a — *not* `main.h`), +`#ifdef`-implemented per target in `main.cpp` like `serviceLinkDrop()` (§8.4). The declaration +must not pull NimBLE or WiFi types into the shared header; both live in the `main.cpp` body. + +```c +// main.cpp, TARGET_ESP32 +bool linkIsUp(void) { + if (pServer != nullptr && pServer->getConnectedCount() > 0) return true; +#ifdef OPENDISPLAY_HAS_WIFI + if (wifiLanClientConnected()) return true; // wifi_service.cpp:355 +#endif + return false; +} +``` +```c +// main.cpp, TARGET_NRF +bool linkIsUp(void) { return Bluefruit.connected() > 0; } +``` + +**This is "any transport", not "the session's transport", and that is forced.** +`EncryptionSession` ([encryption_state.h:11-30](../src/encryption_state.h)) has **no origin +field** — there is nothing recording which transport a session belongs to. `g_commandOrigin` +([communication.cpp:37](../src/communication.cpp)) is per-dispatch, not per-session, and +`transferSessionOrigin()` ([display_service.cpp:2116](../src/display_service.cpp)) tracks the +*transfer*, not the session. So in Phase 3 the question "is the link that owns this session +still up?" is genuinely unanswerable. This is the parent plan's wedge mechanism #4 +(cross-transport session clobber), and Phase 4's owner token is its fix. + +**The resulting gap, stated plainly:** on a WiFi-enabled env, if BLE disconnects while a LAN +client is connected, `linkIsUp()` returns true and the session is **not** cleared — so the +confirmed user decision "clear encryption session on BLE disconnect" is missed in that one +case. This is the deliberate, safe direction to err: + +- Erring toward *not* clearing risks a stale session surviving a BLE disconnect. Bounded: a + reconnecting client resets its counter to 0 and is rejected as out-of-window, and Phase 1 + closes the `counter_diff == 0` replay hole that would otherwise make the surviving session + exploitable. **Phase 3 must land after Phase 1** — already the stated order — and this is now + a second reason why. +- Erring toward *clearing* would destroy a live LAN client's session from a BLE event, which is + wedge mechanism #4 firing in the opposite direction — the exact bug Phase 4 exists to fix, and + strictly worse than a stale session. + +**Phase 4 closes the gap** by scoping `linkIsUp()` to the session's owner +(`linkOwner() == OWNER_BLE ? bleUp() : lanUp()`). Record it there as a required follow-up, not +as an optional refinement. + +**Rejected shortcut:** adding an origin byte to `EncryptionSession` now. It is firmware-local +(`src/encryption_state.h`) so it is *not* a wire-protocol change and would be in bounds — but +it is Phase 4's owner token wearing a different hat, and building half of it here guarantees +two half-mechanisms to reconcile later. Wait for the token. + +### D7 — Does `abortToKnownState` respect `g_commandInFlight`, or is that Phase 6's job? `(RESEARCHED — ungating is safe, with two conditions)` + +**Desired behaviour: `abortToKnownState` is never gated — when called, it runs.** This section +is the adversarial check on that: what was searched for, what was found, and what it costs. + +Phase 6 says "abort only when the in-flight depth counter is 0". Taken literally as a +precondition *inside* `abortToKnownState`, that makes it un-callable from its most important +callers: Phase 5's `integrity_failures >= 3` and `reloadConfigAfterSave` triggers both fire from +*inside* `imageDataWritten`, where depth is ≥1 by construction. A recovery mechanism that +refuses to run precisely when a command is wedged is not a recovery mechanism. + +#### The key reframing + +**Depth is a proxy for the wrong thing.** The hazards below are all *cross-task concurrency* +hazards, not *stack depth* hazards. The distinction decides the question: + +- **ESP32: depth ≥ 1 always means same-task.** Handlers run only on the loop task — `onWrite` + merely enqueues ([esp32_ble_callbacks.h:118-128](../src/esp32_ble_callbacks.h)), the drain + dispatches ([main.cpp:408-421](../src/main.cpp)), and LAN dispatch is also in `loop()`. A + nested abort is therefore strictly sequential with the handler that called it. **No + concurrency exists, so there is nothing for a depth gate to protect.** +- **nRF: depth ≥ 1 may mean another task.** `imageDataWritten` runs on the Bluefruit Callback + task, and `[H4]`'s `rtos_malloc`-failure fallback can run a *second* copy inline on the BLE + task. Here concurrency is real — but it is real between **loop() and a handler**, which is a + property of the *caller's* context, not of the callee. + +#### Hazards found (reasons not to ungate), ranked + +**1. CRITICAL, nRF only — rail cut mid-SPI-write.** `pwrmgmLock` protects state *transitions* +only, not streaming: `epdSessionAcquire` releases at [display_service.cpp:488](../src/display_service.cpp) +before returning, and every `bbepWriteData` call ([:1990](../src/display_service.cpp), +[:2003](../src/display_service.cpp), [:2309](../src/display_service.cpp), +[:2624](../src/display_service.cpp), [:3223](../src/display_service.cpp)) runs unlocked. So +`abortToKnownState` → `cleanupDirectWriteState(true)` → `epdSessionForceOff()` acquires a *free* +lock and executes `bbepSleep` + `pwrmgm(false)` — dropping the rail while another task drives +the same SPI bus and CS. This is exactly the hazard ESP32's deferral comment cites +([esp32_ble_callbacks.h:62-68](../src/esp32_ble_callbacks.h)). **Real, not theoretical.** + +**2. HIGH, nRF only — session key zeroed mid-decrypt.** `[H4]` verbatim: `clearEncryptionSession()`'s +`memset(session_key, 0, 16)` ([encryption.cpp:205](../src/encryption.cpp)) landing inside a +concurrent `aes_ccm_decrypt`. Produces a spurious tag failure → `integrity_failures++` → possibly +a second clear. Incorrect, not memory-unsafe. + +**3. MEDIUM, both targets — the calling handler continues on reset state.** A nested abort +returns into its caller, which keeps running against zeroed state. Audited for the two Phase 5 +triggers and both are benign: `handleWriteConfigChunk`'s completion path +([communication.cpp:566-576](../src/communication.cpp)) calls `reloadConfigAfterSave()`, then +`sendResponse()` (queues into a just-flushed ring — the response still goes out, which is +correct) and re-clears three `chunkedWriteState` fields that abort already cleared (idempotent). +`decryptCommand`'s failure path returns straight out to a `sendResponseUnencrypted` + `return`. +**This must be re-audited per trigger in Phase 5/6, not assumed.** + +**4. LOW, nRF only — loop stall, not deadlock.** If the Callback task holds `pwrmgmLock` inside +`epdSessionAcquire`'s `bbepSendCMDSequence`, an abort from `loop()` spins in `pwrmgmLockTake` +with `delay(1)`. Bounded by Phase 2 `[C2]`'s 60 s deadline — and abort must then handle the +`false` return, which is a Phase 2 dependency, not a reason to gate. + +#### Hazards searched for and NOT found + +These are the ones that would have forced a gate. They are absent, and that is what makes +ungating defensible: + +- **No use-after-free anywhere in the teardown.** Every buffer it touches is static or + file-static: `pipeReorder` ([display_service.cpp:576](../src/display_service.cpp)), + `pipeState` ([:575](../src/display_service.cpp)), `partialCtx` ([:556](../src/display_service.cpp)), + `chunkedWriteState` ([main.h:283](../src/main.h)). `grep -n 'free(\|delete ' src/display_service.cpp` + returns nothing. **Worst case is stale or inconsistent state — never memory unsafety.** A + corrupted transfer is recoverable by the very mechanism doing the corrupting; a heap fault is + not. This is the single strongest argument for ungating. +- **No self-deadlock on `pwrmgmLock`.** All four take/give pairs + ([:439-488](../src/display_service.cpp), [:496-509](../src/display_service.cpp), + [:513-515](../src/display_service.cpp), [:520-526](../src/display_service.cpp)) are contained + within a single function; the lock is never held across a return into handler code. A nested + abort can always acquire it. Checked specifically because the lock is non-recursive. +- **No new corruption in the double-handler case.** Where two `imageDataWritten` copies run + concurrently on nRF, `plaintext[512]` and `decrypted_data[512]` are **`static`** + ([communication.cpp:680](../src/communication.cpp), [:704](../src/communication.cpp)) and + already race, independent of abort. That scenario is pre-existing and unsafe on its own terms; + abort neither creates nor meaningfully worsens it. **Out of Phase 3's scope — do not try to + fix it here, but do not let it be cited as a cost of ungating either.** + +#### Accommodating hazard 1 (rail cut mid-SPI-write) + +The caller-side depth check (condition 2 below) covers the *common* case, but it is not +sufficient on its own, and relying on it alone would be the weak point of this design. Two gaps: + +- A **handler-context** caller (`integrity_failures`, `reloadConfigAfterSave`) does not check + depth — correctly, since it is on the handler's own task. But in `[H4]`'s double-handler case + (two `imageDataWritten` copies on nRF after an `rtos_malloc` failure), handler A can abort + while handler B streams. Depth is 2 and nobody is checking. +- The check is a *caller* discipline. Phase 6 adds another caller, Phase 7 adds two more. One + omission reintroduces the hazard silently. + +**So do not rely on the gate. Close it structurally, at the panel.** + +`abortToKnownState` already refuses to force the panel off during a refresh (§8.3 step 6). The +gap is that `epdRefreshInProgress` covers only the *refresh* — it is set at +[display_service.cpp:2415](../src/display_service.cpp) and cleared at +[:2436](../src/display_service.cpp), around `bbepRefresh` + `waitforrefresh` — and **not** the +streaming that precedes it. Extend the same idea to streaming: + +```c +// display_service.cpp — new, next to epdRefreshInProgress +volatile bool epdStreamInProgress = false; +``` + +Set and clear it around the two choke points every controller write funnels through, so one flag +covers pipe, partial, compressed, legacy, FastEPD and E1004: + +| Choke point | Covers | +|---|---| +| `pipeConsumePayload()` ([display_service.cpp:2600](../src/display_service.cpp)) | all PIPE paths — partial (`partial_consume_bytes`), compressed (`handleDirectWriteCompressedData`), raw (`streamGray4Bytes` / `directWriteSinkBytes` / `fastepd_direct_write_chunk`) | +| the legacy `0x0071` data handler | legacy direct-write streaming | + +Then §8.3 step 6 becomes: + +```c + // 6. Panel power — NEVER interrupt a refresh OR an in-flight controller stream. + // Cutting the rail under bbepWriteData drives SPI/CS against a dead panel. + // pwrmgmLock does NOT protect this: epdSessionAcquire releases at :488 and all + // streaming runs unlocked, so the lock would be free and the force-off would + // succeed -- which is exactly the bug. + if (!epdRefreshInProgress && !epdStreamInProgress) { + epdSessionForceOff(); + } else { + epdForceOffPending = true; // completed later by serviceDeferredPanelOff() + od_log_warn("ABORT: %s in progress — panel force-off deferred, not skipped", + epdRefreshInProgress ? "refresh" : "stream"); + } +``` + +**What happens to the deferred force-off.** It is **completed, not abandoned** — +`serviceDeferredPanelOff()` (§8.3.1) retries from `loop()` every pass until the refresh or +stream ends. The rest of the teardown does not wait on it: queues, transfer state, touch, +buzzer/LED and the session are all torn down immediately, since none of them touch the panel. + +Three existing mechanisms remain as backstops if the pending flag were somehow lost: the +keep-alive deadline (`epdSessionTick`, [display_service.cpp:517-526](../src/display_service.cpp)), +the direct-write watchdog ([main.cpp:436-442](../src/main.cpp)), and the next transfer's +`epdSessionAcquire`. + +> ⚠️ **Updated 2026-07-26 — Phase 2 scope cut.** This previously said "reuse Phase 2 `[C2]`'s +> `panelStateUnknown` flag rather than adding a second one." **P2-1 was dropped, so no such flag +> exists.** `pwrmgmLockTake()` keeps its unbounded spin and its `void` signature, and produces no +> signal for `abortToKnownState` to report. Phase 3 must either define its own flag if it wants one, +> or — preferably — drop panel-state reporting from `abortToKnownState`'s remit entirely, since the +> condition it was to report can no longer be detected. The three backstops above are unaffected. + +**Cost:** one `volatile bool`, two set/clear pairs, one extra term in an existing condition. No +locking, no serialization, no change to the streaming hot path. + +#### Accommodating hazard 2 (session key zeroed mid-decrypt) + +Same principle: do not gate the abort, make the operation safe. + +`clearEncryptionSession()` ([encryption.cpp:201-219](../src/encryption.cpp)) does two different +jobs in one function, and only one of them is racy: + +| Job | Racy? | +|---|---| +| `authenticated = false`, `nonce_counter`/`last_seen_counter`/`integrity_failures`/`session_start_time`/`last_activity`/`auth_attempts`/`server_nonce_time` = 0 | **No.** Scalar stores. A concurrent reader sees old or new, both coherent. | +| `memset` of `session_key`, `client_nonce`, `server_nonce`, `pending_server_nonce`, `replay_window`; `ccm_session_free()` | **Yes.** A 16-byte buffer half-zeroed under `aes_ccm_decrypt` yields garbage plaintext; `ccm_session_free` frees an mbedTLS context that may be in use. | + +**Split it: invalidate now, scrub later.** + +```c +void clearEncryptionSession(void) { + // Invalidate IMMEDIATELY -- this is the security-relevant half and it is + // race-free (plain scalar stores). isAuthenticated() goes false at once, so + // every subsequent command is refused regardless of when the scrub lands. + encryptionSession.authenticated = false; + encryptionSession.session_start_time = 0; + encryptionSession.nonce_counter = 0; + encryptionSession.last_seen_counter = 0; + encryptionSession.integrity_failures = 0; + encryptionSession.last_activity = 0; + encryptionSession.auth_attempts = 0; + encryptionSession.server_nonce_time = 0; + + // Scrub the key material + CCM context. Safe inline whenever no command can be + // executing concurrently; deferred otherwise. See sessionScrubPending below. + if (sessionScrubIsSafeNow()) sessionScrubNow(); + else sessionScrubPending = true; + od_log_info("Encryption session cleared (scrub %s)", + sessionScrubPending ? "deferred" : "done"); +} +``` + +```c +// ESP32: always safe -- all handlers run on the loop task, so a caller of +// clearEncryptionSession() is never concurrent with a decrypt. +// nRF: safe only at depth 0. +static inline bool sessionScrubIsSafeNow(void) { +#ifdef TARGET_ESP32 + return true; +#else + return g_commandInFlight == 0; +#endif +} +``` + +`sessionScrubPending` is serviced from `loop()` when `g_commandInFlight == 0` — the same +condition and the same place as §9.2's nRF disconnect service, so it is one mechanism, not two. + +**Why deferring the scrub is not a security regression.** Three reasons, in order of weight: + +1. **`isAuthenticated()` is already false.** [communication.cpp:663-670](../src/communication.cpp) + gates on it *before* `decryptCommand` is ever reached, so no new command can use the key — + the window is closed the instant the invalidate half runs. +2. **The one in-flight decrypt completes correctly rather than incorrectly.** It already passed + the auth gate before the clear, so it was going to run either way. With an intact key it + produces valid plaintext and dispatches a slightly stale command; with a half-zeroed key it + produces garbage, fails the CCM tag, and increments `integrity_failures` — which under + Phase 5 triggers *another* clear. **The racing memset is the worse outcome, not the safer + one.** +3. **The scrub is anti-forensic hygiene, not access control.** It bounds how long key bytes sit + in `.bss`. Deferring it by one loop pass (microseconds to milliseconds) does not change the + threat model. Note also that deep sleep wipes `encryptionSession` outright — it is plain + `.bss` at [main.h:289](../src/main.h), not `RTC_DATA_ATTR`. + +**Phase 5 interaction:** Phase 5 adds a guard *inside* `clearEncryptionSession()` (raise a flag +when clearing under a live link). That guard goes in the **invalidate** half, which always runs +inline — it must not end up behind the deferred scrub. + +#### Decision + +**Ungate `abortToKnownState` — it always runs when called — subject to two conditions:** + +1. **The re-entrancy guard must be atomic, not a plain `static bool`.** §8.3 specifies + `static bool inAbort`; ungated cross-task entry means two tasks can both read `false` and + both proceed. Use `__atomic_exchange_n(&inAbort, 1, __ATOMIC_ACQ_REL)` and return if it was + already 1. **This is a required amendment to §8.3.** + + Plus the two structural accommodations above, which are what actually make ungating safe: + `epdStreamInProgress` gating the panel force-off (hazard 1), and the invalidate/scrub split + in `clearEncryptionSession()` (hazard 2). **Neither is optional** — with them, the + caller-side check below is defence in depth rather than the only defence. +2. **Every `loop()`-side caller on nRF carries the depth check itself**, as defence in depth. + The hazard is loop-aborting-under-a-live-handler, so the check belongs where that context is + known — exactly the shape §9.2 already specifies for the nRF disconnect service + (`nrfDisconnectCleanupPending && !epdRefreshInProgress && g_commandInFlight == 0`). Phase 6's + supervisor arm on nRF must carry the same term. ESP32 callers need no such check, because + ESP32 has no cross-task handler execution. + +Handler-context callers (Phase 5's `integrity_failures`, `reloadConfigAfterSave`) call +**unconditionally on both targets** — they are on the handler's own task, so there is no +concurrency for a gate to prevent. + +Record this in `abortToKnownState`'s header comment so Phase 6 does not "restore" the gate: + +```c +// NEVER gated. Callable from any context, at any in-flight depth, on either task. +// Re-entrancy is handled internally by an atomic guard. The g_commandInFlight +// check is the CALLER's responsibility and ONLY on nRF, ONLY from loop()-side +// callers (disconnect service, supervisor) -- because the hazard is cross-task +// concurrency, not stack depth. ESP32 runs all handlers on the loop task, so a +// nested abort there is strictly sequential and needs no check. Do not move the +// depth check in here: it would disable abort for the in-handler triggers +// (integrity_failures, reloadConfigAfterSave) that need it most. See plan D7. +``` + +--- + +## 14. Out of scope for Phase 3 (recorded so review does not re-litigate) + +- Owner token / `linkClaim()` / `linkRelease()` — Phase 4. `linkReleaseIfHeld()` is a no-op + stub here. +- The `ownerStillUp` guard move out of `#ifdef OPENDISPLAY_HAS_WIFI` `[C4]` — Phase 4. +- Command-ring overflow *policy* (`[H1]`: log-and-drop, escalate only via the supervisor) — + Phase 7. Phase 3 only declares the two overflow flags. +- The supervisor predicate and the wall-clock backstops `[H3]` — Phase 6. +- `COMMAND_QUEUE_SIZE` 33 → 34 — Phase 7. +- Any change to `sendPipeNack`'s latch semantics or the `error_since_ms` field — Phase 5. +- The `docs/pipe-write-protocol.md` §5.1 documentation note the parent plan permits — Phase 5; + it describes the pipe error-release deadline, which Phase 3 does not implement. +- The pipe-only abort NACK (D3's follow-up) — deferred indefinitely, pending hardware evidence + that the client's own timeout is insufficient. +- Anything that would edit `include/opendisplay_protocol.h`, `include/opendisplay_structs.h`, + or push through `../opendisplay-protocol` — out of bounds for **every** phase (§2), not just + this one. diff --git a/docs/TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md b/docs/TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md new file mode 100644 index 0000000..ab45de9 --- /dev/null +++ b/docs/TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md @@ -0,0 +1,1182 @@ +# Timer, Watchdog and Timeout Inventory — `Firmware` + +**Date:** 2026-07-26 +**Scope:** everything tracked in this repo — `src/`, `include/`, `platformio.ini`, `scripts/`. +**Explicitly out of scope:** `.pio/libdeps/**` (bb_epaper, NimBLE-Arduino, FastEPD, Adafruit +Bluefruit), `~/.platformio/packages/**`, the precompiled `sdkconfig.h`, mbedTLS, FreeRTOS/IDF, +and every sibling repo (`py-opendisplay`, …). Where firmware hands control to one of those, the +entry is recorded as **bounded by library — not analyzed (out of scope)** with the firmware-side +call site and the argument the firmware passes. + +Written as the pre-implementation map for +[PLAN_FREEZE_PROOFING_2026-07-26.md](PLAN_FREEZE_PROOFING_2026-07-26.md) and its review, +[FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md](FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md). +Every claim below was verified against source; nothing is carried over from those documents +without re-checking. Corrections to them are in the final section. + +**Target shorthand.** `nRF` = `TARGET_NRF` (nRF52840, Bluefruit; commands run inline on the +Bluefruit Callback task, no queues). `ESP32` = `TARGET_ESP32` (S3/C6/C3/classic; SPSC command +ring drained by `loop()`). `OPENDISPLAY_HAS_WIFI` is `TARGET_ESP32 && OPENDISPLAY_ENABLE_WIFI` — +`esp32-N4` is ESP32 **without** WiFi, `nrf52840custom` has neither. + +--- + +## 1. Watchdog-related build flags and code in this repo + +### 1.1 `-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` + +- **Mechanism:** PlatformIO build flag, present in every ESP env. +- **file:line:** [platformio.ini:53](../platformio.ini), [:83](../platformio.ini), + [:112](../platformio.ini), [:140](../platformio.ini), [:189](../platformio.ini), + [:209](../platformio.ini), [:229](../platformio.ini), [:253](../platformio.ini), + [:295](../platformio.ini). `esp32-s3-E1004` ([:156](../platformio.ini)) and + `esp32-s3-N16R8-extuart-debug` ([:278](../platformio.ini)) inherit it through + `${env:.build_flags}`, so **all ten ESP envs carry it**; `nrf52840custom` does not. +- **Target:** ESP32 only. +- **Duration / bound:** nominally 120 s. +- **What it monitors:** nothing. **The flag is inert.** Two independent reasons, both verifiable + from inside this repo: (a) it is not an IDF 5.x symbol — the real one is + `CONFIG_ESP_TASK_WDT_TIMEOUT_S` — and (b) the firmware never calls any WDT API, so nothing in + this codebase reads it. Verified firmware-side: `grep -rn "esp_task_wdt\|rtc_wdt\|NRF_WDT" src/` + returns **zero** hits. +- **Action on expiry:** none. +- **Context:** n/a. +- **Can it fire while connected / transferring / mid-refresh?** It cannot fire at all. + +### 1.2 Firmware-armed watchdogs + +**There are none, on either target.** No `esp_task_wdt_add/init/reset`, no `NRF_WDT`, no +`rtc_wdt_*` anywhere in `src/`. Whatever hardware watchdog the platform enables by default is +outside this repo's control and out of scope. The only "watchdogs" this firmware owns are the +`millis()` deadlines in §2. + +### 1.3 Reset-reason handling (observability only) + +- **Mechanism:** `resetReasonName()` + `esp_reset_reason()` logging at boot. +- **file:line:** [main.cpp:29-44](../src/main.cpp) (name table), + [main.cpp:82-84](../src/main.cpp) (read + log). +- **Target:** ESP32 (`#ifdef TARGET_ESP32`). +- **Bound:** n/a — one-shot at boot. +- **What it monitors:** whether the previous boot ended in `PANIC` / `INT_WDT` / `TASK_WDT` / + `WDT` / `BROWNOUT`. Purely a log line; nothing branches on it. +- **Action:** logs. The adjacent comment at [main.cpp:95-100](../src/main.cpp) records the + hardware-proven consequence that a non-deep-sleep reset wipes RTC memory (so `deep_sleep_count` + reads 0 and the boot screen redraws). +- **Context:** `setup()`. + +--- + +## 2. `millis()`-based deadlines in `src/` + +Every one, grouped by subsystem. + +### 2.1 Direct-write 15-minute watchdog + +- **file:line:** [main.cpp:436-442](../src/main.cpp); stamp set at + [display_service.cpp:2077](../src/display_service.cpp) (`directWriteActivatePanel`), cleared at + [display_service.cpp:2022](../src/display_service.cpp) (`cleanupDirectWriteState`). +- **Target:** ESP32 only — the block sits inside the `#ifdef TARGET_ESP32` region of `loop()`. + **nRF has no equivalent**; its `loop()` body is the `#else` arm + ([main.cpp:518-530](../src/main.cpp)). +- **Duration:** `900000UL`, an inline literal at [main.cpp:438](../src/main.cpp). No named + constant. +- **Monitors:** wall-clock age of a direct-write session, measured from START. **Nothing + refreshes the stamp** — it is a hard cap on the whole upload + refresh window, not an + inactivity timer. +- **Action on expiry:** `od_log_error` + `cleanupDirectWriteState(true)` → clears all + `directWrite*` state and force-powers the panel off. +- **Context:** loop task, once per pass. +- **Fires while connected?** Yes. **During a transfer?** That is its only purpose. **Mid-EPD + refresh?** No — `directWriteFinishAndRefresh` blocks the loop task across + `bbepRefresh`/`waitforrefresh` ([display_service.cpp:2415-2436](../src/display_service.cpp)), + so the check simply does not run until the refresh returns. +- **Gap:** keys on `directWriteActive`. `sendPipeNack` clears that flag via + `cleanupDirectWriteState(true)` ([display_service.cpp:2564-2578](../src/display_service.cpp)) + while leaving `pipeState.active = true` — after a fatal pipe NACK this watchdog no longer + applies to the latched pipe. + +### 2.2 `checkPartialWriteTimeout()` — partial-write 15-minute watchdog + +- **file:line:** [display_service.cpp:578-587](../src/display_service.cpp); called from + [main.cpp:443](../src/main.cpp). Stamp set at + [display_service.cpp:2249](../src/display_service.cpp) (legacy `0x76` START) and + [display_service.cpp:2762](../src/display_service.cpp) (pipe-partial START). +- **Target:** ESP32 only (same `#ifdef` region as §2.1). +- **Duration:** `900000UL`, inline literal at [display_service.cpp:580](../src/display_service.cpp). +- **Monitors:** `partialCtx.active` age from START. Again a start-stamp, never refreshed. +- **Action:** `cleanup_partial_write_state()`, plus `resetPipeWriteState()` when + `pipeState.partial` — so a pipe-partial transfer *is* cleared here. +- **Context:** loop task, once per pass. +- **Fires while connected/transferring?** Yes. Mid-refresh: same answer as §2.1 — the loop task + is inside the refresh, so the check is deferred, not suppressed. + +### 2.3 `waitforrefresh()` — panel busy-wait bound + +- **file:line:** [display_service.cpp:747-775](../src/display_service.cpp); the loop bound is + [display_service.cpp:760](../src/display_service.cpp). +- **Target:** both. +- **Duration:** `timeout * 100` iterations of `delay(10)`. Every call site passes `60` → + **60 s**: [display_service.cpp:541](../src/display_service.cpp) (boot), + [:1592](../src/display_service.cpp) (FastEPD boot), [:2423](../src/display_service.cpp) + (FastEPD direct refresh), [:2432](../src/display_service.cpp) (bb_epaper direct refresh), + [:3241](../src/display_service.cpp) and [:3244](../src/display_service.cpp) (partial refresh). +- **Monitors:** the panel BUSY line via `bbepIsBusy()`. +- **Action:** `od_log_warn("Refresh timed out")`, returns `false`. Callers treat `false` as + refresh failure → `epdSessionRelease(false)` powers the panel down. +- **Context:** whichever task ran the refresh — loop task on ESP32, Bluefruit Callback task on + nRF for a client-driven refresh. +- **Two short-circuits that make the 60 s cap not apply:** + 1. **FastEPD (IT8951 / E1004 driver builds):** + [display_service.cpp:749](../src/display_service.cpp) returns `fastepd_wait_refresh(timeout)`, + which is a stub — [display_fastepd.cpp:228-231](../src/display_fastepd.cpp) does + `(void)timeout_sec; return !s_init_failed;`. **No wait, no bound.** The actual blocking + happens inside the library (`fastepd_full_update` / `fastepd_direct_refresh`) — + *bounded by library — not analyzed (out of scope)*, firmware call sites + [display_service.cpp:1592](../src/display_service.cpp) and + [:2422](../src/display_service.cpp), no timeout argument passed. + 2. **E1004 panel:** [display_service.cpp:752-756](../src/display_service.cpp) returns `true` + immediately when the panel is already idle, on the grounds that `bbepRefresh` already waited + — *bounded by library — not analyzed (out of scope)*. +- The 60 s loop itself yields (`delay(10)`), which is why a long refresh does not trip whatever + platform WDT exists. + +### 2.4 EPD keep-alive (`pwrmgmOffDeadlineMs` / `epdSessionTick`) + +- **file:line:** deadline armed [display_service.cpp:505](../src/display_service.cpp) + (`epdSessionRelease`); expiry checked [display_service.cpp:520-527](../src/display_service.cpp) + (`epdSessionTick`); window computed [display_service.cpp:383-395](../src/display_service.cpp). +- **Target:** both. +- **Duration — config-driven:** `power_option.screen_timeout_seconds` + ([opendisplay_structs.h:504](../include/opendisplay_structs.h), `uint8_t`, documented `@min 0 + @max 30`), clamped to `EPD_KEEPALIVE_MAX_S = 30` + ([display_service.h:16](../src/display_service.h)) at + [display_service.cpp:393](../src/display_service.cpp). **0 = power off immediately** (also the + factory/old-blob default). **Forced to 0 unconditionally when an AXP2101 PMIC is in the sensor + list** ([display_service.cpp:385-391](../src/display_service.cpp)), logging once per call when + it overrides a nonzero value. +- **Monitors:** how long a `PWR_WARM` (post-successful-refresh) panel keeps its rail up. +- **Action:** `epdSessionForceOffLocked()` — `bbepSleep` + `delay(50)` + rail cut, or + `fastepd_direct_sleep()` on FastEPD builds. +- **Context:** loop task via [main.cpp:353](../src/main.cpp), **and** inside `idleDelay()` at + [main.cpp:545](../src/main.cpp) so a long `idleDelay` still expires it. +- **Fires while connected?** Yes — WARM survives disconnect by design. **During a transfer?** No: + the tick early-returns unless `pwrmgmState == PWR_WARM` + ([display_service.cpp:521](../src/display_service.cpp)) and a transfer is `PWR_ACTIVE`, and it + additionally `TryTake`s the lock and skips the pass if held. **Mid-refresh?** No, same reasons. +- On battery ESP32 the effective window is `min(configured window, idle-hold)`, because + `enterDeepSleep()` calls `epdSessionForceOff()` unconditionally at + [main.cpp:609](../src/main.cpp). + +### 2.5 Encryption session timeout + +- **file:line:** [encryption.cpp:221-232](../src/encryption.cpp) + (`checkEncryptionSessionTimeout`), reached from `isAuthenticated()` + [encryption.cpp:194-198](../src/encryption.cpp). +- **Target:** both. +- **Duration — config-driven:** `securityConfig.session_timeout_seconds` + ([opendisplay_structs.h:916](../include/opendisplay_structs.h), `uint16_t` LE, documented + `0 = no timeout (persists until disconnect)`). **0 short-circuits to "still valid"** at + [encryption.cpp:223](../src/encryption.cpp) — there is no firmware fallback constant. +- **Monitors:** age since `session_start_time`, in whole seconds + ([encryption.cpp:224-225](../src/encryption.cpp)). +- **Action:** `clearEncryptionSession()` + return false → the command that triggered the check is + answered `RESP_AUTH_REQUIRED` ([communication.cpp:664-670](../src/communication.cpp)). +- **Context:** whichever task dispatches the command — loop task (ESP32) or Bluefruit Callback + task (nRF). It is a *query with side effects*: `isAuthenticated()` mutates session state. +- **Fires while connected?** Yes, by construction. **During a transfer?** **Yes** — it is + evaluated on every command dispatch, including every pipe DATA frame, so on a nonzero + configured value it deterministically fires mid-upload once the transfer outlives the window. + **Mid-refresh?** No — no commands dispatch while the loop task is inside the refresh. + +### 2.6 Encryption auth challenge validity — 30 s + +- **file:line:** stamped [encryption.cpp:588](../src/encryption.cpp) + (`server_nonce_time = currentTime`), checked + [encryption.cpp:604](../src/encryption.cpp). +- **Target:** both. +- **Duration:** `30000` ms, inline literal at [encryption.cpp:604](../src/encryption.cpp). +- **Monitors:** how long an issued `AUTH_STATUS_CHALLENGE` server nonce stays acceptable. +- **Action:** the 32-byte response arm rejects the stale challenge (client must re-request). +- **Context:** command dispatch task. +- **Fires mid-transfer?** Only during the authenticate handshake, which precedes any transfer. + +### 2.7 LAN read / idle timeout — 30 s + +- **file:line:** [wifi_service.cpp:957-960](../src/wifi_service.cpp). Stamps at + [:891](../src/wifi_service.cpp) (accept), [:915](../src/wifi_service.cpp) (TLS handshake + complete), [:951](../src/wifi_service.cpp) (bytes read), [:977](../src/wifi_service.cpp) + (valid frame about to dispatch). +- **Target:** ESP32 with `OPENDISPLAY_HAS_WIFI` only. +- **Duration:** `OD_LAN_READ_TIMEOUT_S = 30u`, from the canonical protocol header + [opendisplay_protocol.h:984](../include/opendisplay_protocol.h) — the **only** timeout in this + firmware that comes from the wire protocol rather than being firmware-local. +- **Monitors:** silence on an accepted LAN TCP/TLS session. Note the guard is + `else if (drainedBytes == 0)` — the check only runs on a tick where **nothing** was read. +- **Action:** `disconnectWiFiServer()`. +- **Context:** loop task, inside `handleWiFiServer()`. +- **Fires while a BLE client is connected?** Yes — the two transports are independent, and + `disconnectWiFiServer()` raises `bleDisconnectCleanupPending` + ([wifi_service.cpp:812](../src/wifi_service.cpp)), which is why the `ownerStillUp` guard in + [main.cpp:328-338](../src/main.cpp) exists. **During a LAN transfer?** Only if the client has + been silent 30 s. **Mid-refresh?** No — `handleWiFiServer()` runs on the loop task, which is + inside the refresh. + +### 2.8 `wifiClient.setTimeout(30000)` + +- **file:line:** [wifi_service.cpp:888](../src/wifi_service.cpp). +- **Target:** ESP32 + WiFi. +- **Bound:** 30 000 ms passed to the Arduino `WiFiClient` — *bounded by library — not analyzed + (out of scope)*. Recorded because the firmware chooses the value. + +### 2.9 WiFi link supervisor poll — 10 s + +- **file:line:** [main.cpp:448-464](../src/main.cpp). +- **Target:** ESP32 + WiFi. +- **Duration:** `10000` ms, inline literal at [main.cpp:449](../src/main.cpp). +- **Monitors:** `WiFi.status()` vs. the cached `wifiConnected` flag. +- **Action:** on loss → `disconnectWiFiServer()`; on regain → `restartWiFiLanAfterReconnect()`. +- **Context:** loop task. +- **Fires while a BLE client is connected / transferring?** Yes to both — and via + `disconnectWiFiServer()` it raises the shared BLE-disconnect-cleanup flag. + +### 2.10 Initial blocking WiFi connect — 3 × 10 s + 2 × 2 s + +- **file:line:** [wifi_service.cpp:762-787](../src/wifi_service.cpp). +- **Target:** ESP32 + WiFi, and only on the `waitForConnection == true` path (setup). +- **Duration:** `maxRetries = 3` ([:762](../src/wifi_service.cpp)) × + `timeoutPerRetry = 10000` ms ([:763](../src/wifi_service.cpp)), with `delay(2000)` between + attempts ([:786](../src/wifi_service.cpp)) → **worst case ~34 s of blocking**. Inner poll is + `delay(500)` ([:769](../src/wifi_service.cpp)); early-aborts on `WL_CONNECT_FAILED` / + `WL_NO_SSID_AVAIL`. +- **Action on expiry:** `wifiConnected = false`, log, continue booting. +- **Context:** whichever task called `initWiFi(true)`. + +### 2.11 mDNS TXT MSD update throttle — 400 ms + +- **file:line:** [wifi_service.cpp:377-383](../src/wifi_service.cpp). +- **Target:** ESP32 + WiFi. +- **Duration:** `400` ms, inline literal at [:378](../src/wifi_service.cpp). +- **Monitors:** re-publish rate of the `msd` TXT record when the payload is unchanged. +- **Action:** skip the update. + +### 2.12 Deep-sleep idle hold + +- **file:line:** [main.cpp:486-499](../src/main.cpp) (idle branch); + [main.cpp:374-390](../src/main.cpp) (post-wake advertising branch). `lastActivityMs` is + refreshed by `pollActivity()` [main.cpp:257](../src/main.cpp), at end of setup + [main.cpp:175](../src/main.cpp), and on an aborted sleep [main.cpp:590](../src/main.cpp). +- **Target:** ESP32 only. +- **Duration — config-driven:** `power_option.sleep_timeout_ms` + ([opendisplay_structs.h:490](../include/opendisplay_structs.h), `uint16_t` LE ms — so the + maximum expressible hold is 65 535 ms). **Fallback when 0: `DEFAULT_IDLE_HOLD_MS = 10000`** + ([main.h:322](../src/main.h)), applied at [main.cpp:488-490](../src/main.cpp) and + [main.cpp:375-377](../src/main.cpp). +- **Monitors:** quiet time since the last detected activity. +- **Action:** `enterDeepSleep()`. +- **Context:** loop task. Gated on `power_mode == 1` **and** `deep_sleep_time_seconds > 0`. +- **Fires while connected?** No — `pollActivity()` treats `connCount > 0` as activity in itself + ([main.cpp:254](../src/main.cpp)), so a connected client pins the device awake indefinitely. + `enterDeepSleep()` re-checks `getConnectedCount() > 0` at [main.cpp:588](../src/main.cpp). + **During a transfer with the link already dropped?** `workInFlight` + ([main.cpp:474-479](../src/main.cpp)) does **not** include `transferActive()`, so a latched + `pipeState.active` with `connCount == 0` does not block the idle branch. **Mid-refresh?** No — + `epdRefreshInProgress` is in `workInFlight` ([main.cpp:478](../src/main.cpp)). + +### 2.13 Minimum-wake hold + +- **file:line:** `minWakeTimeMs()` [main.cpp:197-200](../src/main.cpp); `minWakeHoldActive()` + [main.cpp:202-210](../src/main.cpp); armed at [main.cpp:162-163](../src/main.cpp) (button wake) + and [main.cpp:171-172](../src/main.cpp) (first boot / hidden reset). +- **Target:** ESP32 only. +- **Duration — config-driven:** `power_option.min_wake_time_seconds` + ([opendisplay_structs.h:503](../include/opendisplay_structs.h), `uint16_t` LE seconds). + **Fallback when 0: `DEFAULT_MIN_WAKE_TIME_SECONDS = 120`** ([main.h:312](../src/main.h)). +- **Monitors:** a floor on awake time after a button wake or a first boot, layered *under* the + idle hold — sleep requires both conditions. +- **Action on expiry:** clears `minWakeWindowActive`, logs, permits sleep. +- **Context:** loop task; also re-checked defensively inside `enterDeepSleep()` + ([main.cpp:598-601](../src/main.cpp)), which the comment notes must stay ahead of the + advertising stop. + +### 2.14 Post-wake advertising window + +- **file:line:** [main.cpp:374-390](../src/main.cpp); start stamp + [main.cpp:158](../src/main.cpp). +- **Target:** ESP32 only. +- **Duration:** same source as §2.12 (`sleep_timeout_ms`, fallback `DEFAULT_IDLE_HOLD_MS`). + Measured from `lastActivityMs`, not from `advertising_start_time` — a connect-then-drop re-arms + the whole window. `advertising_start_time` is used only for the log line + ([main.cpp:384](../src/main.cpp)). +- **Action:** `enterDeepSleep()`. +- **Context:** loop task, `woke_from_deep_sleep && advertising_timeout_active` branch; the branch + `return`s every pass after `idleDelay(50)` ([main.cpp:396](../src/main.cpp)), so the command + drain and everything below it are **not reached** until a client connects. + +### 2.15 MSD refresh cadence — 60 s + +- **file:line:** [main.cpp:509-513](../src/main.cpp). +- **Target:** ESP32 only. Note it sits in the **`else` (not-`workInFlight`) branch**, so it does + not run while a client is connected or a refresh is in flight. +- **Duration:** `60000` ms, inline literal at [main.cpp:510](../src/main.cpp). +- **Action:** `updatemsdata()` — re-polls sensors and re-pushes the BLE advertisement. +- **Interlock:** `MyBLEServerCallbacks::onConnect` sets `msdUpdatePending` + ([esp32_ble_callbacks.h:56](../src/esp32_ble_callbacks.h)) rather than calling `updatemsdata()` + inline, precisely because this 60 s path also drives it from the loop task. + +### 2.16 nRF advertising boost — 3 s + +- **file:line:** constant [ble_init.cpp:44](../src/ble_init.cpp) (`NRF_ADV_BOOST_MS = 3000`); + armed [:47](../src/ble_init.cpp); consulted [:51](../src/ble_init.cpp) and + [:61](../src/ble_init.cpp); serviced by `ble_nrf_advertising_tick()` + [:59-76](../src/ble_init.cpp). +- **Target:** nRF only. +- **Duration:** 3 000 ms. Boost interval 20–30 ms (`NRF_ADV_BOOST_MIN/MAX`, + [:42-43](../src/ble_init.cpp)); steady interval 160–1000 ms + (`NRF_ADV_INTERVAL_MIN/MAX`, [:40-41](../src/ble_init.cpp)). +- **Action on expiry:** restore the slow interval, `Advertising.stop()` + `start(0)`. +- **Context:** loop task via [main.cpp:526](../src/main.cpp), and inside `idleDelay()` at + [main.cpp:540](../src/main.cpp). + +### 2.17 nRF link-diagnostic one-shot + +- **file:line:** [ble_init.cpp:136-145](../src/ble_init.cpp). +- **Target:** nRF only. +- **Duration:** `s_link_diag_timer.begin(500, …, /*repeating=*/false)` + ([ble_init.cpp:141](../src/ble_init.cpp)); `reset()` restarts it from now + ([:144](../src/ble_init.cpp)). **The literal is 500; the adjacent comment says "fires ~2.5 s + later".** See §8. +- **Action:** logs negotiated PHY/MTU/DLE if still connected. Diagnostics only — no state change. +- **Context:** FreeRTOS timer task (not the Callback task). + +### 2.18 Buzzer sequencing + +- **file:line:** step deadline `s_buzzer.step_until_ms`, armed + [buzzer_control.cpp:203](../src/buzzer_control.cpp) and + [:209](../src/buzzer_control.cpp), consumed + [buzzer_control.cpp:243](../src/buzzer_control.cpp); global cap checked + [buzzer_control.cpp:168](../src/buzzer_control.cpp) against `play_start_ms` + ([:341](../src/buzzer_control.cpp)). +- **Target:** both. +- **Durations:** `kBuzzerMaxTotalMs = 30000u` + ([buzzer_control.cpp:17](../src/buzzer_control.cpp)) — a hard 30 s cap on any playback; + `kBuzzerDurationUnitMs = 5u` ([:15](../src/buzzer_control.cpp)) — note duration units; + `kBuzzerInterPatternGapMs = 20u` ([:16](../src/buzzer_control.cpp)). + Remaining budget is recomputed per step at [:182](../src/buzzer_control.cpp), so no single note + can overshoot the cap. +- **Action on expiry:** `buzzer_stop_internal()` — tone off, drive off, state zeroed. +- **Context:** `buzzerService()`, called from `loop()` at [main.cpp:354](../src/main.cpp), + [:483](../src/main.cpp), [:516](../src/main.cpp), [:529](../src/main.cpp), and from + `idleDelay()` [main.cpp:546](../src/main.cpp). +- **Fires while connected/transferring?** The cap is honest wall-clock and fires whenever + `buzzerService()` runs — but during a blocking refresh or SPI stream `buzzerService()` does not + run, so a tone can sound past 30 s until the loop task is released. Non-blocking design: the + handler ACKs immediately after starting ([:347-348](../src/buzzer_control.cpp)). +- **Blocking exception:** `passiveBuzzerPowerOffAlert()` uses three raw `delay(80)` + ([buzzer_control.cpp:369-373](../src/buzzer_control.cpp)) — ~240 ms of unconditional blocking, + reached from the power-off hold path. + +### 2.19 LED flash sequencing + +- **file:line:** `led_schedule_delay_ms()` [device_control.cpp:356-363](../src/device_control.cpp); + deadline consumed [device_control.cpp:529](../src/device_control.cpp) in `processLedFlash()`. +- **Target:** both. +- **Durations:** per-step, from the config's packed `LedFlashPattern`, scaled by + `LED_DELAY_FACTOR_MS = 100u` and floored at `LED_MIN_STEP_DELAY_MS = 1u` + ([device_control.cpp:279-280](../src/device_control.cpp)). +- **Bound:** **none.** There is no global cap equivalent to the buzzer's `kBuzzerMaxTotalMs`; a + long loop count in the pattern runs to completion. See §5.8. +- **Context:** `processLedFlash()` from `loop()` [main.cpp:352](../src/main.cpp) and `idleDelay()` + [main.cpp:544](../src/main.cpp). + +### 2.20 Touch poll interval / I²C backoff + +- **file:line:** global floor [touch_input.cpp:588-592](../src/touch_input.cpp); per-controller + interval [touch_input.cpp:600](../src/touch_input.cpp); timed-poll comparisons + [:612](../src/touch_input.cpp) and [:625](../src/touch_input.cpp); I²C-fail backoff + [:609-611](../src/touch_input.cpp). +- **Target:** both (the `transferActive()` skip at [:584-586](../src/touch_input.cpp) is + `#ifdef TARGET_ESP32` only). +- **Durations:** `TOUCH_PROCESS_MIN_INTERVAL_MS = 100` + ([touch_input.cpp:38](../src/touch_input.cpp)) — a hard floor on how often + `processTouchInput()` does any work at all; per-controller + `TouchController.poll_interval_ms` ([opendisplay_structs.h:951](../include/opendisplay_structs.h), + `uint8_t` ms) with **firmware fallback `TOUCH_PROCESS_MIN_INTERVAL_MS` (100)** when 0 — the + header documents the default as 25 ms (see §8); `TOUCH_I2C_FAIL_BACKOFF_MS = 100` + ([:37](../src/touch_input.cpp)) suppresses level-triggered INT-low reads after a failure. +- **Action:** skip this pass. +- **Context:** loop task and `idleDelay()`. +- **Fires mid-transfer?** On ESP32 the whole function early-returns while + `transferActive() || s_epd_refresh_suspend > 0` ([:584-586](../src/touch_input.cpp)). On nRF + there is no such gate, but the transfer runs on a different task. + +### 2.21 GT911 reset/settle delays + +- **file:line:** `GT911_PRE_RESET_DELAY_MS = 300` / `GT911_POST_RESET_SETTLE_MS = 200` + ([touch_input.cpp:29-30](../src/touch_input.cpp)), used at + [:317-321](../src/touch_input.cpp), [:331-333](../src/touch_input.cpp), + [:337-339](../src/touch_input.cpp), [:429](../src/touch_input.cpp). + The address-select pulse train at [:250-269](../src/touch_input.cpp) adds + `delay(10)+delay(60)+delay(1)+delay(11)+delayMicroseconds(110)+delay(6)+delay(51)`. +- **Target:** both. +- **Bound:** fixed, unconditional blocking — a full re-init can block ~1 s. +- **Context:** whichever task calls `touchResumeAfterEpdRefresh()` / init, i.e. the loop task. + +### 2.22 Power-off button hold — `power_latch` + +- **file:line:** `POWER_OFF_HOLD_MS = 3000` ([power_latch.cpp:21](../src/power_latch.cpp)); + stamped [:139](../src/power_latch.cpp); compared [:142](../src/power_latch.cpp). +- **Target:** ESP32 (the whole file's active body is ESP32; the nRF build gets the stubs at + [power_latch.cpp:196](../src/power_latch.cpp)). +- **Duration:** 3 000 ms, firmware constant. Not config-driven on this path. +- **Action:** `powerOff()` → latch release + `esp_deep_sleep_start()`. +- **Context:** `powerButtonPoll()`, called from `processButtonEvents()` + ([device_control.cpp:587](../src/device_control.cpp)). +- **Fires while connected / transferring / mid-refresh?** `processButtonEvents()` runs from the + loop task and `idleDelay()`, so it can fire while a client is connected and while a transfer is + latched — but not while the loop task is inside a blocking refresh or SPI stream. + +### 2.23 Power-off button hold — config-driven (`device_control`) + +- **file:line:** stamped [device_control.cpp:75](../src/device_control.cpp); compared + [device_control.cpp:78](../src/device_control.cpp). +- **Target:** both. +- **Duration — config-driven:** `BinaryInputs.power_off_hold_sec` + ([opendisplay_structs.h:862](../include/opendisplay_structs.h), `uint8_t` seconds). + **Fallback when 0: 3 000 ms**, computed at + [device_control.cpp:746](../src/device_control.cpp) into `ButtonState.power_off_hold_ms` + ([structs.h:181](../src/structs.h), `uint16_t` ms). +- **Action:** `passiveBuzzerPowerOffAlert()` (≈240 ms of blocking `delay`) then + `powerLatchTriggerOff()`. +- **Context:** `processButtonEvents()` on the loop task. +- **Note:** this is a *second, independent* power-off-hold mechanism from §2.22, with a different + default source. Both can be armed on the same device. See §6. + +### 2.24 ADC-ladder button poll and press-count window + +- **file:line:** [device_control.cpp:173-200](../src/device_control.cpp). +- **Target:** ESP32 only (`registerAdcLadder` is `#ifdef TARGET_ESP32`, + [device_control.cpp:738-742](../src/device_control.cpp)). +- **Durations:** `ADC_LADDER_POLL_MS = 5` ([:96](../src/device_control.cpp)) — poll floor; + `5000` ms inline at [:193](../src/device_control.cpp) — press-count reset window; + `ADC_LADDER_DEBOUNCE = 3` ([:97](../src/device_control.cpp)) — consecutive equal samples + required. +- **Context:** loop task via `processButtonEvents()`. + +### 2.25 Sensor / battery read caches (TTL, not timeouts) + +| Cache | file:line | TTL | Target | +|---|---|---|---| +| SHT40 MSD poll | [sensor_sht40.cpp:239](../src/sensor_sht40.cpp), used [:245](../src/sensor_sht40.cpp) | `kSht40MsdPollTtlMs = 30000u` | both | +| BQ27220 MSD poll | [sensor_bq27220.cpp:142](../src/sensor_bq27220.cpp), used [:151](../src/sensor_bq27220.cpp) | `kBq27220MsdPollTtlMs = 30000u` | both | +| Battery voltage | [display_service.cpp:1707](../src/display_service.cpp), used [:1712](../src/display_service.cpp) | `kBatteryVoltageTtlMs = 30000u` | both | + +These suppress I²C/ADC work rather than bounding it. Action on expiry is "do the read". The +uncached battery read itself blocks `delay(10) + 10 × delay(2)` ≈ 30 ms +([display_service.cpp:1690-1698](../src/display_service.cpp)). + +### 2.26 Transfer start stamps (inputs to §2.1/§2.2) + +| Stamp | Set | Cleared | Read by | +|---|---|---|---| +| `directWriteStartTime` | [display_service.cpp:2077](../src/display_service.cpp) | [:2022](../src/display_service.cpp), [:2375](../src/display_service.cpp) | [main.cpp:437](../src/main.cpp) | +| `partialCtx.start_time` | [display_service.cpp:2249](../src/display_service.cpp), [:2762](../src/display_service.cpp) | `cleanup_partial_write_state()` | [display_service.cpp:580](../src/display_service.cpp) | +| `imgLogStartMs` | [display_service.cpp:1862](../src/display_service.cpp) | — | [:1901](../src/display_service.cpp) — throughput logging only, no deadline | +| `partial_prepare_panel_ram` `t0` | [display_service.cpp:3249](../src/display_service.cpp) | — | debug logging only, no deadline | + +`pipeState` has **no timestamp field at all** ([structs.h:106-126](../src/structs.h)) — this is +the structural reason a latched pipe has no bound of its own. + +--- + +## 3. Retry / attempt bounds acting as implicit timeouts + +### 3.1 Authentication rate limit — 10 attempts per 60 s + +- **file:line:** [encryption.cpp:568-578](../src/encryption.cpp). +- **Target:** both. +- **Bound:** `auth_attempts >= 10` within `timeSinceLastAuth < 60` seconds (both inline literals, + [:570-571](../src/encryption.cpp)). The counter resets when a request arrives ≥60 s after the + previous one ([:576](../src/encryption.cpp)). +- **Action:** respond `AUTH_STATUS_RATE_LIMIT`, return false. The link is **not** dropped. +- **Context:** command dispatch task. +- **Note:** the counter increments on *every* authenticate attempt including the challenge + request ([:579](../src/encryption.cpp)), so a client that re-handshakes more than 10 times a + minute rate-limits itself. + +### 3.2 Integrity-failure threshold — 3 + +- **file:line:** [encryption.cpp:692-696](../src/encryption.cpp) (nonce/replay path) and + [:729-733](../src/encryption.cpp) (CCM tag path); reset on success + [:725](../src/encryption.cpp) and at session start [:657](../src/encryption.cpp). +- **Target:** both. +- **Bound:** 3 (inline literal, twice). +- **Action:** `clearEncryptionSession()`. The link stays up; every subsequent command is answered + `RESP_AUTH_REQUIRED`, unencrypted, forever. +- **Fires mid-transfer?** **Yes — this is the primary field wedge.** Ordinary packet loss trips + the nonce arm, which increments the same counter as genuine tamper evidence. + +### 3.3 Nonce replay window — ±32, 64-entry ring + +- **file:line:** window test [encryption.cpp:131](../src/encryption.cpp); ring scan + [:137-141](../src/encryption.cpp); ring store [:152-154](../src/encryption.cpp); ring + declaration `uint64_t replay_window[64]` + ([encryption_state.h:21](../src/encryption_state.h)). +- **Target:** both. +- **Bound:** `counter_diff < -32 || counter_diff > 32` → reject. The ring index is a **function + static** at [encryption.cpp:152](../src/encryption.cpp), so `clearEncryptionSession()` memsets + the ring ([:217](../src/encryption.cpp)) but leaves the index — a real bug today. +- **Action:** reject the frame; via §3.2 three rejections kill the session. +- **Ordering hazard:** `last_seen_counter` and the ring are committed at + [:149-154](../src/encryption.cpp) **before** `aes_ccm_decrypt` runs at + [:714](../src/encryption.cpp). + +### 3.4 nRF notify retry — 4 attempts × `delay(5)` + +- **file:line:** [communication.cpp:345-349](../src/communication.cpp). +- **Target:** nRF only. +- **Bound:** up to 4 retries, 5 ms apart → **≤20 ms of blocking** per response on backpressure. +- **Action:** give up silently (no error path if all 5 attempts fail). +- **Context:** Bluefruit Callback task (or, per the inline-fallback hazard, the BLE task). + +### 3.5 ESP32 BLE notify drain cap — 16 per call + +- **file:line:** [main.cpp:279](../src/main.cpp) (`bleDrain < 16`). +- **Target:** ESP32. +- **Bound:** at most 16 notifies per `flushResponseQueueToBle()` call; also breaks early when + `notify()` returns false (mbuf exhaustion), deliberately leaving the entry queued + ([main.cpp:288-290](../src/main.cpp)). +- **Action:** return; the remainder drains next call. Called once per loop pass **and between + every command in the drain** ([main.cpp:421](../src/main.cpp)) and between config-read chunks + ([communication.cpp:473](../src/communication.cpp)). + +### 3.6 Response ring — 10 slots, drop-newest + +- **file:line:** `RESPONSE_QUEUE_SIZE 10` ([structs.h:83](../src/structs.h)); full check + [communication.cpp:113-116](../src/communication.cpp). +- **Target:** ESP32. +- **Action on full:** `od_log_error("Response queue full, dropping response")` and drop **the + newest**. Backlog warning at depth ≥2 ([:126](../src/communication.cpp)). +- **Flush:** drained to empty every pass when no central is connected + ([main.cpp:307-312](../src/main.cpp)). + +### 3.7 Command ring — 33 slots, 32 usable, drop-newest + +- **file:line:** `COMMAND_QUEUE_SIZE 33` ([main.h:371](../src/main.h), mirrored + [esp32_ble_callbacks.h:19](../src/esp32_ble_callbacks.h)); producer refuses at + `nextHead == tail` ([esp32_ble_callbacks.h:122-129](../src/esp32_ble_callbacks.h)). +- **Target:** ESP32. +- **Bound:** usable capacity is `COMMAND_QUEUE_SIZE - 1 = 32`, not 33. `PIPE_MAX_W = 32` + ([structs.h:51](../src/structs.h); 16 under `PIPE_SMALL_DRAM_WINDOW`, + [structs.h:47](../src/structs.h) — `esp32-N4` only). +- **Action on full:** `od_log_error("Command queue full, dropping command")`, drop the newest. +- **Flush:** **never.** There is no code path anywhere that resets `commandQueueHead/Tail`; + stale commands survive a disconnect. + +### 3.8 Loop command drain cap — 33 iterations + +- **file:line:** [main.cpp:406-423](../src/main.cpp) (`while (drained < COMMAND_QUEUE_SIZE)`). +- **Target:** ESP32. +- **Bound:** a count cap only — **no wall-clock cap**. Each iteration can block for the full + duration of the command it dispatches (up to a 60 s refresh), so the drain's true worst case is + unbounded in time. +- **Drain-loop trap:** `tail` is cached at [main.cpp:409](../src/main.cpp) and the incremented + value stored at [:417](../src/main.cpp) *after* dispatch — a flush performed from handler + context would be clobbered by that store. + +### 3.9 Config chunked write + +- **file:line:** START [communication.cpp:495-517](../src/communication.cpp); chunks + [communication.cpp:541-581](../src/communication.cpp). +- **Target:** both. +- **Bounds:** `MAX_CONFIG_CHUNKS = 20u`, `CONFIG_CHUNK_SIZE = 200u`, + `CONFIG_CHUNK_SIZE_WITH_PREFIX = 202u` + ([opendisplay_protocol.h:882-884](../include/opendisplay_protocol.h)); enforced at + [communication.cpp:558](../src/communication.cpp). +- **Action on violation:** `chunkedWriteState.active = false` + NACK. +- **Gap:** `chunkedWriteState.active` is cleared **only** on completion + ([:574](../src/communication.cpp)), a malformed chunk ([:558](../src/communication.cpp)), or an + auth failure ([:550](../src/communication.cpp)). **No timer.** A client that sends `0x0040` + START and vanishes latches it forever — and it is not covered by §2.1 or §2.2. + +### 3.10 Config read chunk cap + +- **file:line:** [communication.cpp:447-448](../src/communication.cpp). +- **Bound:** `maxChunks = (MAX_CONFIG_SIZE + 93) / 94`, a derived compile-time bound guaranteeing + the loop terminates regardless of payload arithmetic. `MAX_RESPONSE_DATA_SIZE = 100u` + ([opendisplay_protocol.h:885](../include/opendisplay_protocol.h)). + +### 3.11 Pipe ACK cadence and reorder bounds + +- **file:line:** in-order cadence [display_service.cpp:2854](../src/display_service.cpp); + gap/duplicate rate limit [:2877-2881](../src/display_service.cpp) and + [:2888-2893](../src/display_service.cpp); reorder overflow guard + [:2874](../src/display_service.cpp); over-size frame guard + [:2817](../src/display_service.cpp); out-of-window NACK [:2896](../src/display_service.cpp). +- **Target:** both. +- **Bounds:** `pipeState.ack_every` (negotiated, `PIPE_MAX_N = 32` / 16 on `esp32-N4`, + [structs.h:52](../src/structs.h)/[:48](../src/structs.h)); `PIPE_REORDER_SLOTS = 33` / 17 + ([structs.h:50](../src/structs.h)/[:46](../src/structs.h)); `PIPE_REORDER_SLOT_SIZE = 248` + ([structs.h:54](../src/structs.h)); `PIPE_ACK_MASK_BITS` from the protocol header. +- **Action on violation:** `sendPipeNack()` → sets `pipeState.error = true` and calls + `cleanupDirectWriteState(true)` ([display_service.cpp:2564-2578](../src/display_service.cpp)). + **`pipeState.active` stays true**, so `transferActive()` latches and the §2.1 watchdog no + longer applies. This is the "pipe fatal-NACK latch". +- All of these are *count* bounds. There is no time bound anywhere in the pipe state machine. + +### 3.12 LAN drain byte budget + +- **file:line:** [wifi_service.cpp:936-992](../src/wifi_service.cpp), loop condition + [:992](../src/wifi_service.cpp). +- **Target:** ESP32 + WiFi. +- **Bound:** `drainedBytes < sizeof(tcpReceiveBuffer)` = **16 384 bytes** per `handleWiFiServer()` + tick ([wifi_service.cpp:40](../src/wifi_service.cpp)). Frame length is separately bounded by + `OD_LAN_MAX_PAYLOAD = 4094u` + ([opendisplay_protocol.h:982](../include/opendisplay_protocol.h)), enforced at + [wifi_service.cpp:966-970](../src/wifi_service.cpp). +- **Action:** return; resume next loop pass. Like §3.8 this is a byte cap, not a time cap — each + dispatched frame can block arbitrarily. + +### 3.13 GT911 I²C retries and disable threshold + +- **file:line:** `GT911_I2C_RETRIES = 3` ([touch_input.cpp:35](../src/touch_input.cpp)), used at + [:180](../src/touch_input.cpp) and [:202](../src/touch_input.cpp) with + `delayMicroseconds(GT911_I2C_RETRY_DELAY_US)` = 500 µs + ([:36](../src/touch_input.cpp), used [:196](../src/touch_input.cpp), [:207](../src/touch_input.cpp), + [:212](../src/touch_input.cpp)); `TOUCH_I2C_FAIL_DISABLE_THRESHOLD = 5` + ([:39](../src/touch_input.cpp)), enforced [:642](../src/touch_input.cpp) and + [:677](../src/touch_input.cpp). +- **Target:** both. +- **Action:** after 5 consecutive failures, `touch_disable_controller()` + ([touch_input.cpp:97-112](../src/touch_input.cpp)) permanently disables that controller for the + rest of the boot (cleared only by a re-init at [:378-379](../src/touch_input.cpp) / + [:519-520](../src/touch_input.cpp)). +- **Underlying `Wire` transaction bound:** *bounded by library — not analyzed (out of scope)*. + **The firmware never calls `Wire.setTimeOut()`** — verified: `grep -rn "setTimeOut" src/` + returns nothing. `wireBeginForOpenDisplay()` + ([display_service.cpp:785-805](../src/display_service.cpp)) sets only clock, with a 100 kHz + fallback on a failed `begin`. + +### 3.14 Boot-screen refresh retry — 1 retry, battery only + +- **file:line:** [display_service.cpp:1622-1636](../src/display_service.cpp). +- **Target:** both (`nrfVbusPresent()` gates it). +- **Bound:** exactly one retry, and only when `!nrfVbusPresent()`. Retry costs + `pwrmgm(false) + delay(200) + prepareEpdRailForBoot() + initBbepPanelSession()` plus a second + `refreshBootScreenFull()` → a second 60 s `waitforrefresh`. Worst case ≈ **2 × 60 s of + blocking in `setup()`**. +- **Action if both fail:** `od_log_warn("Boot screen refresh did not complete")` and continue. +- **Note:** neither boot-refresh path sets `epdRefreshInProgress` — see §4 and §5. + +### 3.15 Fixed-count hardware loops + +| Loop | file:line | Bound | +|---|---|---| +| External-flash bit-bang power-down (nRF) | [main.cpp:846-853](../src/main.cpp), [:872-882](../src/main.cpp) | 8 bits × `delayMicroseconds(1)`; JEDEC read 3 bytes | +| Battery ADC averaging | [display_service.cpp:1694-1698](../src/display_service.cpp) | `numSamples = 10`, `delay(2)` each | +| E1004 half-plane byte sink | [display_service.cpp:278-294](../src/display_service.cpp) | bounded by `e1004HalfPlaneBytes`; returns on `!e1004StreamOpen` | +| Buzzer note-index folding | [buzzer_control.cpp:87-91](../src/buzzer_control.cpp) | converges into `[kBuzzerMinNoteIdx, kBuzzerMaxNoteIdx]` = [117, 234] by ±1 octave steps, with a defensive clamp at [:93-95](../src/buzzer_control.cpp) | +| Boot-screen font autosizing | [boot_screen.cpp:424](../src/boot_screen.cpp), [:439](../src/boot_screen.cpp), [:448](../src/boot_screen.cpp) | monotonically decrement a scale bounded below by 1 | +| Secure-erase zero-fill | [encryption.cpp:817](../src/encryption.cpp), [:834](../src/encryption.cpp) | bounded by `fileSize` | +| Config block walk | [config_parser.cpp:314](../src/config_parser.cpp) | bounded by `configLen` | + +--- + +## 4. Periodic ticks + +Everything below runs from `loop()` and/or `idleDelay()`. **All of it starves whenever the loop +task blocks** — the two long blockers are `waitforrefresh` (up to 60 s) and the SPI/zlib streaming +inside a direct/pipe/partial write. On nRF the command handlers run on the Bluefruit Callback +task instead, so `loop()` keeps ticking during a client-driven transfer; the loop-blocking +analysis below is ESP32-specific except where noted. + +| Tick | file:line | Cadence | Target | Starves when loop blocks | +|---|---|---|---|---| +| `processLedFlash()` | [main.cpp:352](../src/main.cpp), [:544](../src/main.cpp) | every pass; internally gated by `delay_until_ms` | both | LED pattern freezes mid-sequence | +| `epdSessionTick()` | [main.cpp:353](../src/main.cpp), [:545](../src/main.cpp) | every pass; acts only in `PWR_WARM` past the deadline | both | keep-alive expiry deferred (benign — a transfer is `PWR_ACTIVE` anyway) | +| `buzzerService()` | [main.cpp:354](../src/main.cpp), [:483](../src/main.cpp), [:516](../src/main.cpp), [:529](../src/main.cpp), [:546](../src/main.cpp) | every pass; gated by `step_until_ms` | both | a tone sounds past its step, and past the 30 s cap | +| `pollActivity()` | [main.cpp:356](../src/main.cpp) → [main.cpp:218-265](../src/main.cpp) | every pass | ESP32 | `lastActivityMs` not refreshed — but sleep gates are downstream and also blocked | +| Command drain | [main.cpp:406-423](../src/main.cpp) | every pass, ≤33 commands | ESP32 | n/a (it *is* the blocker) | +| `flushResponseQueueToBle()` | [main.cpp:421](../src/main.cpp), [:424](../src/main.cpp) | between every drained command + once per pass | ESP32 | ACKs stall → the client's PTO fires. Explicitly pre-flushed before the refresh at [display_service.cpp:2412](../src/display_service.cpp) to mitigate | +| `serviceBleDisconnectCleanup()` | [main.cpp:370](../src/main.cpp), [:428](../src/main.cpp) | every pass; deferred while `epdRefreshInProgress` | ESP32 | disconnect teardown deferred | +| `esp32_restart_ble_advertising()` | [main.cpp:372](../src/main.cpp), [:434](../src/main.cpp), [display_service.cpp:2439](../src/display_service.cpp) | on `bleRestartAdvertisingPending` | ESP32 | radio stays dark. Contains a hard `delay(100)` at [ble_init.cpp:241](../src/ble_init.cpp) | +| §2.1 direct-write watchdog | [main.cpp:436](../src/main.cpp) | every pass | ESP32 | deferred | +| `checkPartialWriteTimeout()` | [main.cpp:443](../src/main.cpp) | every pass | ESP32 | deferred | +| `handleWiFiServer()` | [main.cpp:447](../src/main.cpp) | every pass | ESP32+WiFi | LAN idle timeout and TLS handshake progress both stall | +| `serviceLanRoam()` | [wifi_service.cpp:856](../src/wifi_service.cpp), inside `handleWiFiServer()` | every pass; self-gated on `!wifiClient.connected() && !transferActive()` | ESP32+WiFi | roam deferred (by design) | +| WiFi link supervisor | [main.cpp:448-464](../src/main.cpp) | 10 s | ESP32+WiFi | link-loss detection deferred | +| `processButtonEvents()` | [main.cpp:481](../src/main.cpp), [:514](../src/main.cpp), [:527](../src/main.cpp), [:542](../src/main.cpp) | every pass (workInFlight branch) / every `idleDelay` chunk | both | **power-off hold cannot complete** — the user's only manual recovery is dead during a wedged refresh | +| `processTouchInput()` | [main.cpp:482](../src/main.cpp), [:515](../src/main.cpp), [:528](../src/main.cpp), [:543](../src/main.cpp) | ≥100 ms floor | both | touch dead (already suppressed during transfers on ESP32 by design) | +| MSD refresh | [main.cpp:509-513](../src/main.cpp) | 60 s, **idle branch only** | ESP32 | deferred | +| `ble_nrf_advertising_tick()` | [main.cpp:526](../src/main.cpp), [:540](../src/main.cpp) | every pass / every `idleDelay` chunk | nRF | advertising stays at the boosted 20–30 ms interval | +| `idleDelay(ms)` | [main.cpp:535-551](../src/main.cpp) | services the above every `CHECK_INTERVAL_MS = 100` ([:536](../src/main.cpp)) | both | n/a | + +**`idleDelay` call sites and their arguments:** `idleDelay(50)` in the post-wake advertising +window ([main.cpp:396](../src/main.cpp)); `idleDelay(5)` on the battery idle-hold path +([:495](../src/main.cpp)) and the USB idle path ([:507](../src/main.cpp)); on nRF, +`idleDelay(globalConfig.power_option.sleep_timeout_ms)` or `idleDelay(500)` +([main.cpp:519-525](../src/main.cpp)) — i.e. **on nRF the loop cadence is itself config-driven**, +up to 65 535 ms, though the 100 ms internal chunking keeps every tick above serviced. + +--- + +## 5. Unbounded waits inside this project + +This section is the inventory's most important half: the absence of a bound. + +### 5.1 `pwrmgmLockTake()` — infinite spin + +- **file:line:** [display_service.cpp:401-408](../src/display_service.cpp); the spin is + [:407](../src/display_service.cpp). +- **Target:** both (the cross-task hazard it exists for is nRF-specific: Bluefruit Callback task + vs. loop task). +- **Bound:** **none.** `while (__atomic_exchange_n(&pwrmgmLock, 1, ACQUIRE)) { delay(1); }` — it + yields (deliberately, per the comment at [:402-406](../src/display_service.cpp), to avoid + priority-inversion livelock) but never gives up. +- **Callers:** `epdSessionAcquire` ([:438](../src/display_service.cpp)), `epdSessionRelease` + ([:496](../src/display_service.cpp)), `epdSessionForceOff` + ([:514](../src/display_service.cpp)). +- **Held across:** panel I/O including `bbepSleep`, `bbepWakeUp`, `bbepSendCMDSequence`, plus a + raw `delay(50)` at [:430](../src/display_service.cpp) — *the busy-wait inside those is bounded + by library — not analyzed (out of scope)*. +- **Note:** `pwrmgmLockTryTake()` ([:409-411](../src/display_service.cpp)) is the safe variant and + is used only by `epdSessionTick` ([:520](../src/display_service.cpp)). The lock is a bare 0/1 + flag with **no owner field**, so a hung holder is indistinguishable from a busy one. + +### 5.2 `powerOff()` stuck-button spin + +- **file:line:** [power_latch.cpp:87-90](../src/power_latch.cpp). +- **Target:** ESP32. +- **Bound:** **none.** `while (digitalRead(buttonPin()) == LOW) { delay(20); }` — a shorted or + stuck-low button pin holds the device here forever, with the latch still engaged. +- **Context:** called from `powerButtonPoll()` on the loop task, i.e. this hangs `loop()`. + +### 5.3 FastEPD refresh — no firmware-side bound at all + +- **file:line:** [display_fastepd.cpp:228-231](../src/display_fastepd.cpp). +- **Target:** ESP32 with `OPENDISPLAY_FASTEPD` (IT8951 / E1004 class). +- **Bound:** `fastepd_wait_refresh()` discards its `timeout_sec` argument entirely and returns + `!s_init_failed`. `waitforrefresh(60)` short-circuits to it at + [display_service.cpp:749](../src/display_service.cpp), so **the 60 s cap does not exist on + these builds**. The real blocking lives in `fastepd_full_update()` + ([display_fastepd.cpp:222-226](../src/display_fastepd.cpp)) and `fastepd_direct_refresh` — + *bounded by library — not analyzed (out of scope)*; the firmware passes **no** timeout to + either. +- **Firmware call sites of the blocking work:** [display_service.cpp:1592](../src/display_service.cpp) + (boot), [display_service.cpp:2422-2423](../src/display_service.cpp) (direct refresh — the path + a real transfer takes), [display_service.cpp:3288](../src/display_service.cpp) + (`fastepd_partial_refresh`). + +### 5.4 Boot refresh is invisible to every `epdRefreshInProgress` gate + +- **file:line:** `refreshBootScreenFull()` [display_service.cpp:533-542](../src/display_service.cpp); + FastEPD boot path [display_service.cpp:1588-1594](../src/display_service.cpp). +- **Target:** both. +- **Bound:** the refresh itself is bounded (60 s, §2.3) or not (§5.3) — but **neither path sets + `epdRefreshInProgress`**. Verified: the flag is written only at + [display_service.cpp:2415/2436](../src/display_service.cpp) and + [:3284/3294](../src/display_service.cpp). So every reader — + [main.cpp:322](../src/main.cpp), [main.cpp:478](../src/main.cpp), + [ble_init.cpp:236](../src/ble_init.cpp) — mis-reads a 30–60 s boot refresh as idle. With the + §3.14 retry that window can double. + +### 5.5 Blocking sequences with no escape + +| Sequence | file:line | Blocking cost | +|---|---|---| +| `pwrmgm(true)` rail bring-up | [main.cpp:717-753](../src/main.cpp) | `delay(800)` + `delay(100)` (or `delay(200)` on FastEPD) ≈ **900 ms**, unconditional | +| `initBbepPanelSession()` | [display_service.cpp:341-354](../src/display_service.cpp) | `delay(200)` plus library init | +| `prepareEpdRailForBoot()` (nRF, battery) | [display_service.cpp:172-183](../src/display_service.cpp) | a full extra `pwrmgm` off/on cycle ≈ 950 ms | +| GT911 address-select + reset | [touch_input.cpp:250-269](../src/touch_input.cpp), [:317-339](../src/touch_input.cpp) | ~150 ms + up to 3 × 500 ms reset cycles | +| `esp32_restart_ble_advertising()` | [ble_init.cpp:241](../src/ble_init.cpp) | `delay(100)` | +| `updatemsdata()` advertising re-push | [display_service.cpp:1811](../src/display_service.cpp) | `delay(50)` | +| `directWriteFinishAndRefresh` pre-refresh settle | [display_service.cpp:2414](../src/display_service.cpp) | `delay(20)` | +| `enterDeepSleep()` teardown | [main.cpp:618](../src/main.cpp), [:621](../src/main.cpp), [:637](../src/main.cpp) | `delay(200) + delay(100) + delay(100)` | +| `reboot()` | [device_control.cpp:259-269](../src/device_control.cpp), [communication.cpp:720](../src/communication.cpp) | `delay(200)`/`delay(100)` before reset — terminal, so harmless | +| `checkResetPin()` | [encryption.cpp:875](../src/encryption.cpp), [:881](../src/encryption.cpp) | `delay(100)` ×2 in `setup()` | +| Button init settle | [device_control.cpp:782](../src/device_control.cpp) | `delay(10)` **per configured pin** — up to 320 ms with 32 buttons | +| `od_log_flush()` | [od_log.cpp:64](../src/od_log.cpp) | `delay(5)` on ESP32, per call | + +### 5.6 Terminal `while (1) {}` + +- **file:line:** [device_control.cpp:866](../src/device_control.cpp). +- **Target:** nRF only, inside the DFU-bootloader jump. +- **Bound:** none by design — control never returns; `bootloader_util_app_start()` has already + transferred execution. Not a freeze vector. + +### 5.7 Latched state with no timer of its own + +These are not loops, but they are unbounded in exactly the way that matters: + +| Latch | Set | Cleared | Bounded by | +|---|---|---|---| +| `pipeState.active` after a fatal NACK | [display_service.cpp:2568](../src/display_service.cpp) | only `resetPipeWriteState()` — reachable from disconnect ([main.cpp:347](../src/main.cpp)) or a new `0x0080` | **nothing** for a non-partial pipe: §2.1 keys on `directWriteActive`, which `sendPipeNack`→`cleanupDirectWriteState(true)` just cleared | +| `chunkedWriteState.active` | [communication.cpp:496](../src/communication.cpp) | completion / malformed / auth-fail only | **nothing** | +| `encryptionSession` after a BLE disconnect | — | nothing on BLE; LAN clears it at [wifi_service.cpp:804](../src/wifi_service.cpp) and [:879](../src/wifi_service.cpp) | **nothing** (survives into the next connection) | +| `directWriteTouchSuspended` / `s_epd_refresh_suspend` | [display_service.cpp:2136](../src/display_service.cpp), [:2799](../src/display_service.cpp), [:539](../src/display_service.cpp), [:1590](../src/display_service.cpp) → `touchSuspendForEpdRefresh()` [touch_input.cpp:116-117](../src/touch_input.cpp) | paired `touchResumeAfterEpdRefresh()` [touch_input.cpp:418-421](../src/touch_input.cpp) | **nothing** — an unpaired suspend leaves touch dead for the rest of the boot | +| `roamPending` | [wifi_service.cpp:606](../src/wifi_service.cpp) | `serviceLanRoam()` when idle | **nothing** — a permanently busy device never roams (deliberate) | +| `touch` `rt->disabled` | [touch_input.cpp:100](../src/touch_input.cpp) | only a full re-init | **nothing** — permanent for the boot | + +### 5.8 LED pattern with no global cap + +`processLedFlash()` ([device_control.cpp:524-535](../src/device_control.cpp)) advances a state +machine whose loop counts come from config. Unlike the buzzer there is **no +`kBuzzerMaxTotalMs` equivalent**, so a pathological pattern runs indefinitely. It never blocks +the loop (each step schedules a deadline and returns), so it is a nuisance rather than a freeze — +but it is genuinely unbounded. + +--- + +## 6. Config-driven values — consolidated + +| Timeout | Struct field | Type / range | 0 means | Firmware constant backing it | Consumer | +|---|---|---|---|---|---| +| EPD keep-alive | `power_option.screen_timeout_seconds` [opendisplay_structs.h:504](../include/opendisplay_structs.h) | `uint8_t`, `@min 0 @max 30` | power off immediately | clamp `EPD_KEEPALIVE_MAX_S = 30` [display_service.h:16](../src/display_service.h) | [display_service.cpp:383-395](../src/display_service.cpp) | +| Idle hold / advertising window | `power_option.sleep_timeout_ms` [opendisplay_structs.h:490](../include/opendisplay_structs.h) | `uint16_t` LE ms (max 65 535) | fall back to default | `DEFAULT_IDLE_HOLD_MS = 10000` [main.h:322](../src/main.h) | [main.cpp:375-377](../src/main.cpp), [:487-490](../src/main.cpp); also the nRF `idleDelay` argument [main.cpp:519-520](../src/main.cpp) | +| Minimum wake hold | `power_option.min_wake_time_seconds` [opendisplay_structs.h:503](../include/opendisplay_structs.h) | `uint16_t` LE s | fall back to default | `DEFAULT_MIN_WAKE_TIME_SECONDS = 120` [main.h:312](../src/main.h) | [main.cpp:197-200](../src/main.cpp) | +| Deep-sleep duration | `power_option.deep_sleep_time_seconds` [opendisplay_structs.h:499](../include/opendisplay_structs.h) | `uint16_t` LE s | deep sleep disabled | none | [main.cpp:583-585](../src/main.cpp), [:625-628](../src/main.cpp); overridable for one cycle by command `0x0053` | +| Encryption session lifetime | `securityConfig.session_timeout_seconds` [opendisplay_structs.h:916](../include/opendisplay_structs.h) | `uint16_t` LE s | **no timeout** (persists until disconnect) | none — the 0 case returns `true` directly | [encryption.cpp:221-232](../src/encryption.cpp) | +| Power-off hold (binary inputs) | `BinaryInputs.power_off_hold_sec` [opendisplay_structs.h:862](../include/opendisplay_structs.h) | `uint8_t` s | default 3 s | literal `3000u` [device_control.cpp:746](../src/device_control.cpp) → `ButtonState.power_off_hold_ms` [structs.h:181](../src/structs.h) | [device_control.cpp:78](../src/device_control.cpp) | +| Touch poll interval | `TouchController.poll_interval_ms` [opendisplay_structs.h:951](../include/opendisplay_structs.h) | `uint8_t` ms | header says 25 ms; **firmware uses 100** | `TOUCH_PROCESS_MIN_INTERVAL_MS = 100` [touch_input.cpp:38](../src/touch_input.cpp) | [touch_input.cpp:600](../src/touch_input.cpp) | +| LAN listener idle drop | — (protocol constant, not config) | — | — | `OD_LAN_READ_TIMEOUT_S = 30u` [opendisplay_protocol.h:984](../include/opendisplay_protocol.h) | [wifi_service.cpp:957](../src/wifi_service.cpp) | + +Everything not in this table is a compile-time firmware constant. + +Note the **two independent power-off-hold mechanisms**: `power_latch`'s fixed +`POWER_OFF_HOLD_MS = 3000` ([power_latch.cpp:21](../src/power_latch.cpp)), keyed on +`SystemConfig.pwr_pin_3`, and `device_control`'s config-driven per-instance hold. They share the +3 s default but not the source, and both can be active on one device. + +--- + +## 7. Analysis + +### 7.1 Coverage map + +Rows are failure conditions; cells name the mechanism that actually bounds them today. + +| Failure | nRF | ESP32 | Covered? | +|---|---|---|---| +| **Stalled direct-write transfer** (client silent, `directWriteActive` set) | *nothing* | §2.1, 15 min | Partial — nRF has no watchdog at all | +| **Stalled pipe transfer, no fatal NACK** | *nothing* | §2.1 via `directWriteActive` (set by `directWriteActivatePanel` for both legacy and pipe), 15 min | Partial | +| **Stalled pipe transfer after a fatal NACK** | *nothing* | *nothing* — `directWriteActive` cleared, `pipeState.active` latched | **GAP** | +| **Stalled partial write** | *nothing* | §2.2, 15 min | Partial | +| **Stalled config chunked write** | *nothing* | *nothing* | **GAP** (§3.9) | +| **Idle authenticated connection** (client connected, sending nothing) | *nothing* | *nothing* — `pollActivity` treats a live link as activity ([main.cpp:254](../src/main.cpp)), pinning the device awake | **GAP** | +| **Client flooding undecryptable frames post-session-clear** | *nothing* | *nothing* | **GAP** — every frame is answered `RESP_AUTH_REQUIRED` ([communication.cpp:664-670](../src/communication.cpp)) indefinitely | +| **Wedged panel (bb_epaper BUSY stuck)** | §2.3, 60 s | §2.3, 60 s | Yes | +| **Wedged panel (FastEPD / IT8951)** | n/a | *nothing* (§5.3) | **GAP** | +| **Wedged panel during the boot refresh** | 60 s wait, but invisible to every gate (§5.4) | same | Partial | +| **Hung I²C (GT911 holding SDA low)** | §3.13 disables the controller after 5 failures — but only if the transaction *returns* | same | **GAP** — no `Wire.setTimeOut`, no bus recovery | +| **Hung I²C (AXP2101 / SHT40 / BQ27220)** | *nothing* | *nothing* | **GAP** | +| **Dead radio — advertising stopped, never restarted** | `ble_nrf_advertising_tick` only re-starts after a boost expiry | *nothing* — `esp32_restart_ble_advertising` **clears** the pending flag when `getConnectedCount() > 0` ([ble_init.cpp:232-235](../src/ble_init.cpp)) | **GAP** | +| **Lost WiFi link** | n/a | §2.9, 10 s poll | Yes | +| **Silent LAN client** | n/a | §2.7, 30 s | Yes | +| **Stuck `pwrmgmLock`** | *nothing* (§5.1) | *nothing* | **GAP** | +| **Stuck power button (latch path)** | n/a | *nothing* (§5.2) | **GAP** | +| **Command ring full** | n/a (no ring) | drop-newest + log (§3.7); never flushed | Partial — recoverable, but stale commands survive a disconnect | +| **Response ring full** | n/a | drop-newest + log (§3.6); drained when disconnected | Yes | +| **Dead encryption session with a live link** | *nothing* | *nothing* | **GAP** — invisible to the client | +| **Encryption session expiring mid-transfer** | fires (§2.5) | fires (§2.5) | *Inverted* — the timer is itself the wedge | +| **CPU/peripheral hard hang** | *nothing* (no WDT armed) | *nothing* firmware-side | **GAP** — accepted per the plan's software-only decision | + +The shape of the gap: this firmware bounds **wall-clock transfer age** and **panel BUSY**, and +nothing else. There is no inactivity timer, no link-liveness timer, no I²C bound, no lock bound, +and on nRF no transfer watchdog whatsoever. + +### 7.2 Conflicts and nesting + +1. **§2.1/§2.2 (900 s) nest inside the drain and the refresh, not the other way round.** Both are + evaluated once per `loop()` pass at [main.cpp:436](../src/main.cpp)/[:443](../src/main.cpp), + *after* the command drain at [:406-423](../src/main.cpp). A single dispatched command can block + for a 60 s refresh, so the effective resolution of both watchdogs is one refresh, not one loop + pass. They cannot fire mid-refresh. Correct ordering, but it means "15 minutes" is really + "15 minutes, rounded up to the next loop pass." + +2. **§2.5 (encryption expiry) nests inside §2.1 and can fire during it.** Session expiry runs on + every command dispatch, so on a nonzero `session_timeout_seconds` shorter than 900 s it fires + *inside* the direct-write watchdog's window — and its action (`clearEncryptionSession`) does + **not** clear `directWriteActive`. The result is a transfer that is simultaneously "active" per + §2.1 and unable to accept a single further frame, for up to 15 minutes. + +3. **§3.2 (integrity ≥ 3) and §2.1 fight.** Same shape as (2): the session dies, the transfer + flag lives. §2.1 is the only thing that eventually releases the panel. + +4. **§3.11 (`sendPipeNack`) actively *disables* §2.1.** `cleanupDirectWriteState(true)` clears the + very flag §2.1 keys on, while leaving `pipeState.active` set. A fatal NACK therefore trades a + bounded wedge for an unbounded one. This is the single worst ordering inversion in the + inventory. + +5. **§2.7 (LAN 30 s) and the BLE disconnect path share one flag.** `disconnectWiFiServer()` sets + `bleDisconnectCleanupPending` ([wifi_service.cpp:812](../src/wifi_service.cpp)), so a LAN idle + timeout enters `serviceBleDisconnectCleanup()`. The only thing preventing it from tearing down + a live BLE transfer is the `ownerStillUp` guard at [main.cpp:328-338](../src/main.cpp) — + which is inside `#ifdef OPENDISPLAY_HAS_WIFI`, so on `esp32-N4` the guard does not exist at + all (there is also no LAN on that env, so the flag has no second raiser there today; the + hazard is latent and becomes live the moment anything else raises the flag). + +6. **§2.4 (keep-alive) vs. §2.12 (idle hold).** On battery ESP32, `enterDeepSleep()` calls + `epdSessionForceOff()` unconditionally ([main.cpp:609](../src/main.cpp)), so the effective + keep-alive is `min(screen_timeout_seconds, sleep_timeout_ms)`. With the defaults (keep-alive up + to 30 s, idle hold 10 s) the idle hold usually wins. The code comments at + [main.cpp:602-609](../src/main.cpp) document this deliberately; noting it because "30 s + keep-alive" is not what a battery device actually does. + +7. **§2.13 (min-wake) is checked twice, and the second check has an ordering constraint.** + `minWakeHoldActive()` is evaluated in `loop()` ([main.cpp:383](../src/main.cpp), + [:494](../src/main.cpp)) **and** inside `enterDeepSleep()` ([main.cpp:598](../src/main.cpp)). + The second check must stay above the advertising stop at [main.cpp:611-616](../src/main.cpp), + because everything past that point commits to `esp_deep_sleep_start()` — a late abort would + leave the device awake with the radio dark. The comment at [:594-597](../src/main.cpp) says so; + it is a real constraint on any future reordering. + +8. **§2.13/§2.12 have a side effect: `minWakeHoldActive()` mutates.** It clears + `minWakeWindowActive` when the window elapses ([main.cpp:205](../src/main.cpp)). Because it is + called from three sites, whichever one runs first consumes the transition and logs it. Benign + today (all three are on the loop task), but it is a query with side effects, same class of + defect as `isAuthenticated()`. + +9. **Two power-off holds, one device.** §2.22 (fixed 3 s, `power_latch`) and §2.23 (config, + default 3 s, `device_control`) both run from `processButtonEvents()` + ([device_control.cpp:586-587](../src/device_control.cpp)). If a device configures a + `BinaryInputs` power-off pin that is also `SystemConfig.pwr_pin_3`, both arm on the same press + with independent thresholds. + +10. **§2.15 (60 s MSD) vs. `onConnect`'s `msdUpdatePending`.** Both drive `updatemsdata()`, which + mutates the shared advertisement vector and re-pushes it with a `delay(50)` + ([display_service.cpp:1811](../src/display_service.cpp)). They are serialised only because + both run on the loop task — the `onConnect` callback deliberately defers rather than calling + inline ([esp32_ble_callbacks.h:53-56](../src/esp32_ble_callbacks.h)). Any future producer on + another task breaks this. + +11. **§3.5 (16-notify drain cap) vs. §3.6 (10-slot ring).** The drain cap exceeds the ring size, + so it is never the binding constraint; the binding one is `notify()` returning false. Not a + conflict, but the 16 is dead weight. + +### 7.3 Impact of the freeze-proofing plan + +Per existing mechanism, with the plan phase that touches it. + +| Existing mechanism | Verdict | Plan reference | +|---|---|---| +| §1.1 `-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` | **DELETES** (or renames) | Phase 2 `[L3]` | +| §1.3 reset-reason logging | LEAVES | — | +| §2.1 direct-write 15-min watchdog | **MODIFIES** — kept as a backstop, raised to 20 min | Phase 6 `[H3]` | +| §2.2 `checkPartialWriteTimeout` | **MODIFIES** — same, 20 min | Phase 6 `[H3]` | +| §2.3 `waitforrefresh(60)` (bb_epaper) | LEAVES | — | +| §2.3 `waitforrefresh` → FastEPD stub | **MODIFIES** — implement a real IT8951 LUT-busy poll honouring `timeout_sec`; wrap `fastepd_direct_refresh` | Phase 2 `[X3]` | +| §2.4 EPD keep-alive | LEAVES | — | +| §2.5 encryption session timeout | **DELETES** — age-based expiry removed; session lifetime := connection lifetime | Phase 5 | +| §2.6 auth challenge 30 s | LEAVES | — | +| §2.7 LAN 30 s idle | LEAVES — the BLE/LAN asymmetry is called out as deliberate | Phase 7 | +| §2.8 `wifiClient.setTimeout(30000)` | LEAVES | — | +| §2.9 WiFi 10 s supervisor | LEAVES (but its `disconnectWiFiServer` path gains the owner guard) | Phase 4/5 `[H2]` | +| §2.10 blocking WiFi connect | LEAVES | — | +| §2.11 mDNS 400 ms throttle | LEAVES | — | +| §2.12 deep-sleep idle hold | LEAVES — explicitly stated as requiring no change; the 5-min BLE idle drop makes `connCount` fall so the existing hold elapses naturally | Phase 7 | +| §2.13 min-wake hold | LEAVES | — | +| §2.14 advertising window | LEAVES, but Phase 4 must skip `esp32_set_ble_connectable` during the post-wake window | Phase 4 | +| §2.15 60 s MSD cadence | LEAVES | — | +| §2.16 nRF advertising boost | LEAVES | — | +| §2.17 nRF link-diag one-shot | LEAVES | — | +| §2.18 buzzer 30 s cap + sequencing | LEAVES; `abortToKnownState` adds a stop call | Phase 3 | +| §2.19 LED sequencing | LEAVES; `abortToKnownState` adds a stop call. **The plan does not add a global LED cap** — `[X6]` notes it and defers | Phase 3 / review `X6` | +| §2.20 touch poll interval | LEAVES; `touchForceResume()` added alongside | Phase 3 `[M3]` | +| §2.22 `power_latch` 3 s hold | LEAVES (the *spin* it can reach is bounded — see §5.2 row) | Phase 2 | +| §2.23 config power-off hold | LEAVES | — | +| §2.24 ADC ladder poll | LEAVES | — | +| §2.25 sensor TTLs | LEAVES | — | +| §3.1 auth rate limit 10/60 s | LEAVES | — | +| §3.2 integrity ≥ 3 | **MODIFIES** — nonce failures no longer increment it; CCM-tag trigger stays and must now drop the link | Phase 1, Phase 5 | +| §3.3 nonce window ±32 / 64-ring | **MODIFIES** — symmetric ±128 with a 256-entry ring (fallback ±64); `replay_window_index` moved into the session; `counter_diff == 0` exemption removed; check/commit split around CCM | Phase 1 `[M1]` | +| §3.4 nRF notify retry ×4 | LEAVES | — | +| §3.5 16-notify drain cap | LEAVES | — | +| §3.6 response ring 10 | **MODIFIES** — adds an overflow flag serviced in loop, gated on `transferActive()`; adds a redundant `flushResponseQueue()` | Phase 7, Phase 3 (`[L1]`: the flush is redundant on ESP32) | +| §3.7 command ring 33/32 | **MODIFIES** — adds `flushCommandQueue()`, fixes the `main.h:365-370` off-by-one comment, optionally bumps to 34 off `esp32-N4`. Overflow **keeps** drop-newest; it does not drop the link | Phase 7 `[H1]` | +| §3.8 drain count cap | **MODIFIES** — adds a 2 s wall-clock cap alongside; adds the drain-abort check between [main.cpp:415](../src/main.cpp) and [:416](../src/main.cpp) | Phase 2, Phase 3 `[M5]` | +| §3.9 config chunked write | **SUBSUMES** — no timer of its own; covered by the supervisor predicate, and `resetChunkedWriteState()` is added | Phase 6 `[X4]` | +| §3.11 pipe NACK latch | **MODIFIES** — `error_since_ms` added to `PipeWriteState` + a 10 s `pipeErrorTick()` hardware-release deadline | Phase 5 `[L2]` | +| §3.12 LAN 16 KB drain budget | LEAVES | — | +| §3.13 GT911 retries / disable | **MODIFIES** — `Wire.setTimeOut(25)` after every `Wire.begin()` plus a nine-clock SDA recovery keyed on `i2c_fail_streak` | Phase 2 `[X1]` | +| §3.14 boot-refresh retry | LEAVES, but §5.4's flag fix makes it visible | Phase 2 `[X2]` | +| §5.1 `pwrmgmLockTake` infinite spin | **MODIFIES** — returns `bool` with a **60 s** deadline, fail-closed, no steal | Phase 2 `[C2]` | +| §5.2 `powerOff` stuck-button spin | **MODIFIES** — 10 s bound, then drop the latch anyway | Phase 2 | +| §5.3 FastEPD unbounded refresh | **MODIFIES** — see §2.3 row | Phase 2 `[X3]` | +| §5.4 boot refresh invisible | **MODIFIES** — set/clear `epdRefreshInProgress` around both boot paths | Phase 2 `[X2]` | +| §5.7 `encryptionSession` surviving disconnect | **MODIFIES** — cleared on BLE disconnect (deferred on nRF behind the in-flight depth counter) | Phase 5 `[H4]` | +| §5.7 `directWriteTouchSuspended` unpaired | **MODIFIES** — `touchForceResume()` clears it and asserts the counter reached 0 | Phase 3 `[M3]` | +| §5.7 `roamPending`, `rt->disabled` | LEAVES | — | +| §5.8 LED unbounded pattern | LEAVES | review `X6` | +| §2.12 `workInFlight` gate | **MODIFIES** — `transferActive()` added to the disjunction | Phase 6 `[X5]` | +| `esp32_restart_ble_advertising` clearing the flag on a stale count | **MODIFIES** — `advertisingHealthTick()` at 30 s | Phase 4 `[X7]` | + +**New mechanisms the plan introduces** (for completeness, so a future reader can tell them from +the existing set): the 10-minute progress supervisor (`g_lastProgressMs`, 600 000 ms, Phase 6); +the 5-minute BLE idle disconnect (`OD_BLE_IDLE_DISCONNECT_MS = 300000`, Phase 7); the 10 s pipe +error-release deadline (Phase 5); the 60 s `pwrmgmLock` deadline and the 10 s `powerOff` bound +(Phase 2); the 2 s drain cap (Phase 2); the 30 s advertising health tick (Phase 4); the ~10 s +idle-incumbent eviction threshold (Phase 4 `[M2]`). + +**Silent conflicts to watch:** + +- **The plan's 20-minute backstop must sit above the supervisor's 10 minutes, and both above the + 60 s lock deadline.** As written the hierarchy is 10 s (pipe error) < 60 s (lock, refresh) < + 300 s (BLE idle) < 600 s (supervisor) < 1200 s (backstops). That is consistent — but note the + 20-minute backstops key on a *start* stamp while the supervisor keys on *progress*, so on a slow + legitimate transfer the backstop can fire first. A 20-minute upload is not achievable on any + current panel, so this is theoretical; it stops being theoretical if E1004 payload sizes grow. +- **The 5-minute BLE idle timer and the 60 s `pwrmgmLock` deadline can overlap.** A client + connected but silent for 5 minutes while another context holds `pwrmgmLock` will be dropped by + the idle timer; `abortToKnownState`'s `epdSessionForceOff()` then needs the lock it cannot get, + and must honour the new fail-closed return rather than spinning. +- **Phase 5 disables §2.5 but §2.6 (the 30 s challenge validity) stays.** They are different + timers on the same subsystem; the plan does not mention §2.6 at all. It is correct to leave it, + but the plan's "encryption is scoped to the life of the connection and nothing else" sentence + reads as if no encryption-side timer remains. One does. +- **The plan touches `esp32_restart_ble_advertising` (Phase 4 `[X7]`) but not the `delay(100)` at + [ble_init.cpp:241](../src/ble_init.cpp)**, which now runs on every health-tick-forced restart. +- **`abortToKnownState`'s buzzer/LED stop has no counterpart bound.** §5.8 stays unbounded; the + abort only stops a sequence that is already running when the abort fires. + +### 7.4 Numbers summary — shortest to longest + +Every firmware-controlled duration, sorted. Blocking `delay()`s under 10 ms are omitted. + +| Duration | Mechanism | Where | +|---|---|---| +| 500 µs | GT911 I²C retry gap | [touch_input.cpp:36](../src/touch_input.cpp) | +| 1 ms | `pwrmgmLockTake` spin yield | [display_service.cpp:407](../src/display_service.cpp) | +| 1 ms | `workInFlight` loop yield | [main.cpp:484](../src/main.cpp) | +| 5 ms | ADC ladder poll floor | [device_control.cpp:96](../src/device_control.cpp) | +| 5 ms | nRF notify retry gap | [communication.cpp:347](../src/communication.cpp) | +| 5 ms | `od_log_flush` settle (ESP32) | [od_log.cpp:64](../src/od_log.cpp) | +| 5 ms | buzzer duration unit | [buzzer_control.cpp:15](../src/buzzer_control.cpp) | +| 5 ms | `idleDelay` argument, idle paths | [main.cpp:495](../src/main.cpp), [:507](../src/main.cpp) | +| 10 ms | `waitforrefresh` poll interval | [display_service.cpp:761](../src/display_service.cpp) | +| 10 ms | per-button init settle | [device_control.cpp:782](../src/device_control.cpp) | +| 12 ms | SHT40 measurement wait | [sensor_sht40.cpp:16](../src/sensor_sht40.cpp) | +| 20 ms | buzzer inter-pattern gap | [buzzer_control.cpp:16](../src/buzzer_control.cpp) | +| 20 ms | pre-refresh settle | [display_service.cpp:2414](../src/display_service.cpp) | +| 20 ms | `powerOff` stuck-button poll | [power_latch.cpp:88](../src/power_latch.cpp) | +| 20–30 ms | nRF boosted advertising interval | [ble_init.cpp:42-43](../src/ble_init.cpp) | +| 50 ms | `idleDelay` argument, post-wake window | [main.cpp:396](../src/main.cpp) | +| 50 ms | advertising re-push settle | [display_service.cpp:1811](../src/display_service.cpp) | +| 50 ms | `epdSessionForceOffLocked` post-sleep | [display_service.cpp:430](../src/display_service.cpp) | +| 80 ms ×3 | power-off buzzer alert | [buzzer_control.cpp:369-373](../src/buzzer_control.cpp) | +| 100 ms | `idleDelay` internal chunk | [main.cpp:536](../src/main.cpp) | +| 100 ms | touch process floor / poll fallback | [touch_input.cpp:38](../src/touch_input.cpp) | +| 100 ms | touch I²C fail backoff | [touch_input.cpp:37](../src/touch_input.cpp) | +| 100 ms | LED delay factor | [device_control.cpp:279](../src/device_control.cpp) | +| 100 ms | advertising restart settle | [ble_init.cpp:241](../src/ble_init.cpp) | +| 160–1000 ms | nRF steady advertising interval | [ble_init.cpp:40-41](../src/ble_init.cpp) | +| 200 ms | GT911 post-reset settle | [touch_input.cpp:29](../src/touch_input.cpp) | +| 200 ms | panel init settle | [display_service.cpp:346](../src/display_service.cpp), [:354](../src/display_service.cpp) | +| 300 ms | GT911 pre-reset delay | [touch_input.cpp:30](../src/touch_input.cpp) | +| 400 ms | mDNS MSD TXT throttle | [wifi_service.cpp:378](../src/wifi_service.cpp) | +| 500 ms | nRF link-diag one-shot | [ble_init.cpp:141](../src/ble_init.cpp) | +| 500 ms | WiFi connect inner poll | [wifi_service.cpp:769](../src/wifi_service.cpp) | +| ~900 ms | `pwrmgm(true)` rail bring-up | [main.cpp:721](../src/main.cpp) + [:749](../src/main.cpp) | +| 2 000 ms | WiFi inter-retry delay | [wifi_service.cpp:786](../src/wifi_service.cpp) | +| 3 000 ms | nRF advertising boost | [ble_init.cpp:44](../src/ble_init.cpp) | +| 3 000 ms | power-off hold (latch, fixed) | [power_latch.cpp:21](../src/power_latch.cpp) | +| 3 000 ms | power-off hold default (config) | [device_control.cpp:746](../src/device_control.cpp) | +| 5 000 ms | ADC ladder press-count window | [device_control.cpp:193](../src/device_control.cpp) | +| 10 000 ms | WiFi link supervisor poll | [main.cpp:449](../src/main.cpp) | +| 10 000 ms | WiFi connect timeout per retry | [wifi_service.cpp:763](../src/wifi_service.cpp) | +| 10 000 ms | `DEFAULT_IDLE_HOLD_MS` | [main.h:322](../src/main.h) | +| ≤30 000 ms | EPD keep-alive (`EPD_KEEPALIVE_MAX_S`) | [display_service.h:16](../src/display_service.h) | +| 30 000 ms | buzzer total playback cap | [buzzer_control.cpp:17](../src/buzzer_control.cpp) | +| 30 000 ms | auth challenge validity | [encryption.cpp:604](../src/encryption.cpp) | +| 30 000 ms | LAN idle drop (`OD_LAN_READ_TIMEOUT_S`) | [opendisplay_protocol.h:984](../include/opendisplay_protocol.h) | +| 30 000 ms | `wifiClient.setTimeout` | [wifi_service.cpp:888](../src/wifi_service.cpp) | +| 30 000 ms | sensor / battery read TTLs | [sensor_sht40.cpp:239](../src/sensor_sht40.cpp), [sensor_bq27220.cpp:142](../src/sensor_bq27220.cpp), [display_service.cpp:1707](../src/display_service.cpp) | +| 60 000 ms | auth rate-limit window (10 attempts) | [encryption.cpp:570](../src/encryption.cpp) | +| 60 000 ms | MSD refresh cadence | [main.cpp:510](../src/main.cpp) | +| 60 000 ms | `waitforrefresh` cap (bb_epaper only) | [display_service.cpp:760](../src/display_service.cpp) with `timeout = 60` | +| ≤65 535 ms | `sleep_timeout_ms` idle hold / advertising window; nRF `idleDelay` argument | [opendisplay_structs.h:490](../include/opendisplay_structs.h) | +| 120 000 ms | `DEFAULT_MIN_WAKE_TIME_SECONDS` | [main.h:312](../src/main.h) | +| ≤65 535 s | `min_wake_time_seconds`, `deep_sleep_time_seconds`, `session_timeout_seconds` | config | +| 900 000 ms | direct-write watchdog | [main.cpp:438](../src/main.cpp) | +| 900 000 ms | partial-write watchdog | [display_service.cpp:580](../src/display_service.cpp) | +| ∞ | `pwrmgmLockTake`, `powerOff` button spin, FastEPD refresh, pipe NACK latch, chunked-write latch, encryption session across a BLE disconnect | §5 | + +The visible hierarchy: everything the firmware bounds today is either **under a minute** (panel, +LAN, radio, sensors) or **at fifteen minutes** (transfers). There is nothing in between — which +is precisely the band the plan's 5-minute idle and 10-minute supervisor are meant to fill. + +--- + +## 8. Corrections to existing docs + +Verified against source; each of these is wrong or imprecise in the plan or the review. + +1. **"Every ESP env passes `-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120`" — correct, but only via + inheritance.** Review `L3` cites `platformio.ini:189`. Two envs + (`esp32-s3-E1004` [:156](../platformio.ini), `esp32-s3-N16R8-extuart-debug` + [:278](../platformio.ini)) do not list the flag; they inherit it through + `${env:.build_flags}`. The conclusion (all ten ESP envs, flag inert) holds — but a + `grep` for the flag returns nine lines, not ten, and anyone deleting it must check the + inheriting envs still compile. + +2. **The plan's `[L3]` says "the real TWDT is 5 s/panic on IDLE0."** That claim is sourced from + the precompiled `sdkconfig.h`, which is out of scope for this inventory. What is verifiable + from inside this repo: **the firmware arms nothing** (`grep -rn "esp_task_wdt" src/` → no + hits), so whatever the platform default is, this codebase neither sets it nor feeds it. State + it that way rather than quoting a framework value. + +3. **The buzzer's global cap is 30 s, not 5 s.** `kBuzzerMaxTotalMs = 30000u` + ([buzzer_control.cpp:17](../src/buzzer_control.cpp)), but the in-code comments at + [buzzer_control.cpp:167](../src/buzzer_control.cpp) ("Global 5 s cap") and + [:144](../src/buzzer_control.cpp) ("for the 5 s total cap") both say 5 s. Neither the plan nor + the review mentions the buzzer cap, so this is a source-comment defect rather than a doc + defect — flagged here because `[X6]` claims "nothing bounds a stuck `buzzer_control` + sequence," which is **wrong**: a 30 s cap exists. What is genuinely unbounded is the **LED** + sequence (§5.8), not the buzzer. + +4. **The nRF link-diag timer literal is 500, not 2500.** [ble_init.cpp:141](../src/ble_init.cpp) + passes `500`; the comment on the next line ([:144](../src/ble_init.cpp)) and the block comment + at [:82](../src/ble_init.cpp) both say "~2.5 s later". One of the two is wrong. Neither + plan nor review covers this; it is diagnostics-only, so the consequence is a misleading log + phase label, not a freeze. + +5. **`TouchController.poll_interval_ms`'s documented default is not the firmware's fallback.** + The header says `0 = default 25 ms` + ([opendisplay_structs.h:951](../include/opendisplay_structs.h)); the firmware falls back to + `TOUCH_PROCESS_MIN_INTERVAL_MS` = **100 ms** ([touch_input.cpp:600](../src/touch_input.cpp)). + Even an explicit 25 is floored to 100 by the global gate at + [touch_input.cpp:589-592](../src/touch_input.cpp), so **the config field cannot produce a poll + faster than 100 ms on this firmware**. Not mentioned anywhere; relevant to `[X1]`, which + assumes touch polling is frequent enough to detect an I²C wedge quickly. + +6. **Review `L4`'s line-drift note is itself slightly off.** It corrects + `communication.cpp:113-116` → `:117-121` for "the ring-full check". The ring-full *check* is + [communication.cpp:113-116](../src/communication.cpp) (`nextHead == responseQueueTail` at `:113`, the + `od_log_error` at `:114`); `:117-121` is the memcpy/enqueue that follows. Both the plan's + original and the review's correction point at roughly the right block; neither is exact. + +7. **Review `L4` says `display_fastepd.cpp:222-231` "lands on `fastepd_full_update` at + `:227-231`".** `fastepd_full_update` is [display_fastepd.cpp:222-226](../src/display_fastepd.cpp); + `fastepd_wait_refresh` is [:228-231](../src/display_fastepd.cpp). The two are transposed. The + substantive finding (`X3`) is correct. + +8. **Plan `[H3]` describes `checkPartialWriteTimeout` as living at + `display_service.cpp:578-587`** — correct — **but the plan's §Context item 2 implies it is the + only bound on a pipe transfer.** It bounds `partialCtx` only; for a *non-partial* pipe transfer + after a fatal NACK there is genuinely nothing, which the review states correctly under + "Verified CORRECT" item 3. The plan text should not be read as offering partial coverage there. + +9. **Neither document inventories the second power-off hold.** `power_latch`'s fixed + `POWER_OFF_HOLD_MS = 3000` ([power_latch.cpp:21](../src/power_latch.cpp)) is entirely separate + from `BinaryInputs.power_off_hold_sec`. Phase 2 bounds the `powerOff()` spin + ([power_latch.cpp:87-90](../src/power_latch.cpp)) but says nothing about the fact that the + `device_control` path reaches `powerLatchTriggerOff()` + ([device_control.cpp:80](../src/device_control.cpp)) rather than `powerOff()`, so the two + shutdown routes have different blocking profiles. + +10. **Neither document mentions that the *entire* recovery surface depends on `loop()`.** Every + bound in §2 and §3 except `waitforrefresh` and the nRF notify retry is evaluated from + `loop()` or `idleDelay()`. On ESP32 that includes `processButtonEvents()`, so during a wedged + blocking refresh **even the physical power-off hold does not work**. This matters for the + plan's "residual risk: recoverable by power cycle" claim — on a latching device, the power + button is itself software-mediated ([device_control.cpp:60-84](../src/device_control.cpp), + [power_latch.cpp:124-144](../src/power_latch.cpp)) and is not a recovery path while the loop + task is blocked. diff --git a/src/communication.cpp b/src/communication.cpp index 4101e3f..c6e4a8b 100644 --- a/src/communication.cpp +++ b/src/communication.cpp @@ -763,7 +763,36 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin // screen. It moves to the failure path below, where it is the only thing that // separates a replay-window jump from nonce reuse from a wrong session key, // and where nRF (which has no RX hex line at all) would otherwise be blind. - if (!decryptCommand(data + BLE_CMD_HEADER_SIZE + ENCRYPTION_NONCE_SIZE, encrypted_data_len, plaintext, &plaintext_len, nonce_full, auth_tag, command)) { + NonceResult decrypt_reason = NONCE_OK; + if (!decryptCommand(data + BLE_CMD_HEADER_SIZE + ENCRYPTION_NONCE_SIZE, encrypted_data_len, plaintext, &plaintext_len, nonce_full, auth_tag, command, &decrypt_reason)) { + // Step 4b / [H1]: a pipe DATA frame rejected because its nonce fell + // outside the window IS ordinary packet loss - it is the direct + // consequence of frames having been dropped. pipe-write-protocol.md + // §5.2 already reserves NACKs for unrecoverable conditions, "not + // ordinary packet loss", and §5.1 makes a 0x81 NACK unconditionally + // fatal, so answering one here violates the spec as written. Send + // NOTHING: silence is a first-class signal on the pipe path. The seq + // is absent from the next SACK mask, the client retransmits it under a + // FRESH, higher counter, and that counter is accepted unconditionally + // (nonce_window.h has no forward bound), so the transfer continues. + // Answering with the 3-byte NACK instead makes the client raise + // IntegrityCheckError, which its pipe send loop does not catch, + // killing the whole upload on the first rejected frame. + // + // Rare by construction now: the only surviving nonce rejections are a + // duplicate delivery and a counter more than OD_NONCE_BACKWARD_BITS + // behind, neither of which this transport produces. It stays because + // being wrong here costs the whole upload. + // + // Deliberately narrow: + // - TAG failures keep the NACK. They are tamper evidence, not loss. + // - 0x0071 (legacy DIRECT_WRITE_DATA) is left alone on purpose: it + // has a different ACK discipline that has not been analysed, and + // the field failure lives on the pipe path. Not an oversight. + const bool nonce_loss = (decrypt_reason == NONCE_OUT_OF_WINDOW || decrypt_reason == NONCE_REPLAY); + if (nonce_loss && command == CMD_PIPE_WRITE_DATA) { + return; + } // 16 bytes render as 47 chars + NUL, an exact fit in 48; sized past that // so a future ENCRYPTION_NONCE_SIZE bump truncates nothing. char nonceHex[64]; @@ -846,10 +875,35 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin handlePipeWriteStart(data + 2, len - 2); break; case CMD_PIPE_WRITE_DATA: // 0x0081 - // The replay counter (verifyNonceReplay) already advanced at decrypt time, - // above this switch, for every 0x0081 frame — including ones the handler - // then queues or discards — so drops/dupes never desync it and the counter - // delta stays within in-flight <= W <= 32 <= the +-32 replay window. + // Replay state is committed at decrypt time (nonceCommit, first + // statement of decryptCommand's success arm) for every 0x0081 frame + // that authenticates — including ones this handler then queues or + // discards — so drops/dupes never desync it. + // + // The forward gap is deliberately UNBOUNDED, and it has to be. The + // client burns a nonce counter per *transmission*, landed or not, and + // its three transmit sites are new sends (window-credit-limited), PTO + // probes, and selective repair — and selective repair spends no window + // credit at all. The ceiling is the client's retransmit budget + // max_retx = max(3*W, n/2), scaled by blocks_per_ack, a user-facing + // Home Assistant option (1..32) in another repo: order thousands for a + // full-panel upload, and it accumulates ACROSS aborted attempts + // because the client never re-authenticates mid-transfer. Any firmware + // constant placed here would be a number this repo cannot prove and a + // client-side setting could silently falsify. + // + // So there is no such constant. A counter ahead of last_seen is + // accepted at any distance and gated by the CCM tag instead + // (src/nonce_window.h). A forward cap would not bound an attacker — + // checking commits nothing — but it would strand the session, because + // once a gap exceeded it nothing would commit, last_seen would never + // advance, and each retransmission's still-higher counter would be + // rejected further out than the last, until re-authentication. That is + // a transient link fault promoted to a permanent session fault. + // + // This reverses Decision A of + // docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md, which specified a cap of + // 128; see "Reversal of Decision A" at the end of that file. handlePipeWriteData(data + 2, len - 2); break; case CMD_PIPE_WRITE_END: // 0x0082 diff --git a/src/display_service.cpp b/src/display_service.cpp index 04a9c8a..23b637f 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -2661,6 +2661,24 @@ static void sendPipeAck(void) { static void sendPipeNack(uint8_t err) { uint8_t r[8] = {RESP_NACK, 0x81, err, 0, 0, 0, 0, 0}; pipeBuildAckPayload(r + 3); + // The only record that this session died and why. Everything below is + // observable from the client or a sniffer but nothing was observable from the + // device, so a failed upload could not be told apart from a stall or a link + // drop without one. Logged BEFORE the send so the line survives whatever the + // response path does, and at ERROR because this is always terminal. + // + // Cannot flood: pipeState.error is set immediately below and makes every + // later 0x0081 frame discard, so at most one of these per session. + // + // err 0x04 (out of window on both sides) deserves particular suspicion. A + // conforming client cannot produce it -- it only transmits seq within W of + // its own base, and the device's expected_seq is provably within W of that + // base too -- so seeing 0x04 means either a non-conforming peer or that the + // client's window rule has drifted from what this firmware assumes. + od_log_error("ERROR: PIPE NACK err=0x%02X (expected=%u highest_seen=%u queued=%u W=%u%s) - session fatal", + (unsigned)err, (unsigned)pipeState.expected_seq, (unsigned)r[3], + (unsigned)pipeState.queued_count, (unsigned)pipeState.window, + pipeState.partial ? " partial" : ""); sendResponse(r, sizeof(r)); pipeState.error = true; // Partial transfers own partialCtx, not the full-frame direct-write session: diff --git a/src/encryption.cpp b/src/encryption.cpp index 0b9ac75..c940504 100644 --- a/src/encryption.cpp +++ b/src/encryption.cpp @@ -112,48 +112,84 @@ void deriveSessionId(const uint8_t* session_key, const uint8_t* client_nonce, } } -bool verifyNonceReplay(uint8_t* nonce) { - if (!encryptionSession.authenticated) return false; +// Reset ONLY the four nonce/replay fields. Named explicitly rather than +// described loosely ([M3]): clearEncryptionSession() and handleAuthenticate()'s +// fresh-session block share exactly these four and are *opposite* on everything +// else (authenticated false vs true, timestamps zeroed vs stamped, keys wiped vs +// populated). In particular nonce_counter — the device's OWN outbound counter — +// must keep being zeroed here: a device that carried it across a re-auth while +// the client restarts at 0 would reproduce the [H2] keystream reuse against +// itself. See docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md Step 1. +static void resetNonceState(void) { + encryptionSession.nonce_counter = 0; /* device's OWN outbound counter */ + encryptionSession.last_seen_counter = 0; + encryptionSession.integrity_failures = 0; + memset(encryptionSession.replay_bitmap, 0, sizeof(encryptionSession.replay_bitmap)); +} + +// Rate limiters for the two nonce-rejection logs. Once nonce failures stop +// counting toward integrity_failures ([L7]) nothing else throttles a peer that +// drives these lines, and out-of-window now fires routinely on a lossy link. +// +// One budget PER SITE, deliberately, not one shared budget: a stale client +// spamming session-id mismatches must not be able to silence the out-of-window +// line, which is the condition Step 5's hardware tests 0-2 exist to observe. +static uint32_t nonce_log_badsession_ms = 0; +static uint32_t nonce_log_window_ms = 0; +static bool nonceLogAllowed(uint32_t* last_ms) { + uint32_t now = millis(); + if (*last_ms != 0 && (uint32_t)(now - *last_ms) < 5000u) return false; + *last_ms = now; + return true; +} + +// PURE. Parses the counter out of the 16-byte nonce and decides whether the +// frame may be accepted. Writes NOTHING to encryptionSession on any path — that +// is the property the host test asserts by memcmp, and it is what "only a +// CCM-verified frame may advance replay state" (D2) rests on. +// +// File-static by design (Decision C): nothing outside decryptCommand should be +// able to reach the commit side. The pure logic lives in src/nonce_window.h. +// +// Caveat on how strong that is. Decision C says linkage enforces the rule, and +// for nonceCheck/nonceCommit it does. But encryption_state.h must include +// nonce_window.h for OD_NONCE_BITMAP_WORDS, and main.h includes +// encryption_state.h — so od_nonce_commit(), a static-inline header primitive, +// is visible in every TU alongside the extern encryptionSession. Nothing stops +// a determined caller from committing state directly. That is unavoidable while +// the struct needs the width macro; the rule is enforced by linkage for the +// session-aware wrappers and by convention for the raw primitive. +static NonceResult nonceCheck(const uint8_t* nonce, uint64_t* counter_out) { uint8_t nonce_session_id[8]; uint64_t nonce_counter = 0; memcpy(nonce_session_id, nonce, 8); for (int i = 0; i < 8; i++) { nonce_counter = (nonce_counter << 8) | nonce[8 + i]; } + if (counter_out != nullptr) *counter_out = nonce_counter; + if (!constantTimeCompare(nonce_session_id, encryptionSession.session_id, 8)) { - od_log_error("ERROR: Nonce session_id mismatch\n Nonce ID: %02X%02X%02X%02X%02X%02X%02X%02X\n Expected: %02X%02X%02X%02X%02X%02X%02X%02X", - nonce_session_id[0], nonce_session_id[1], nonce_session_id[2], nonce_session_id[3], - nonce_session_id[4], nonce_session_id[5], nonce_session_id[6], nonce_session_id[7], - encryptionSession.session_id[0], encryptionSession.session_id[1], encryptionSession.session_id[2], encryptionSession.session_id[3], - encryptionSession.session_id[4], encryptionSession.session_id[5], encryptionSession.session_id[6], encryptionSession.session_id[7]); - return false; - } - int64_t counter_diff = (int64_t)nonce_counter - (int64_t)encryptionSession.last_seen_counter; - if (counter_diff < -32 || counter_diff > 32) { - od_log_error("ERROR: Nonce counter outside replay window (counter=%llu, last_seen=%llu, diff=%lld)", - (unsigned long long)nonce_counter, (unsigned long long)encryptionSession.last_seen_counter, (long long)counter_diff); - return false; - } - if (nonce_counter <= encryptionSession.last_seen_counter && counter_diff != 0) { - bool already_seen = false; - for (int i = 0; i < 64; i++) { - if (encryptionSession.replay_window[i] == nonce_counter) { - already_seen = true; - break; - } + // [L7] Demoted from ERROR + rate-limited, and the full session-id dump + // is gone: a mismatched session id is usually a stale client talking to + // a device that re-authenticated, i.e. confusion rather than attack. + // The CCM tag remains the only tamper oracle. + if (nonceLogAllowed(&nonce_log_badsession_ms)) { + od_log_warn("Nonce session_id mismatch (%02X%02X.. vs %02X%02X..) - frame dropped, session kept", + nonce_session_id[0], nonce_session_id[1], + encryptionSession.session_id[0], encryptionSession.session_id[1]); } - if (already_seen) { - od_log_error("ERROR: Nonce counter already seen (replay detected)"); - return false; - } - } - if (nonce_counter > encryptionSession.last_seen_counter) { - encryptionSession.last_seen_counter = nonce_counter; + return NONCE_BAD_SESSION; } - static uint8_t replay_window_index = 0; - encryptionSession.replay_window[replay_window_index] = nonce_counter; - replay_window_index = (replay_window_index + 1) % 64; - return true; + return od_nonce_check(encryptionSession.replay_bitmap, + encryptionSession.last_seen_counter, nonce_counter); +} + +// Records a counter as consumed. Called from exactly one place: as the FIRST +// statement of decryptCommand's success arm, i.e. only after aes_ccm_decrypt +// has verified the tag (the D2 fix). +static void nonceCommit(uint64_t counter) { + od_nonce_commit(encryptionSession.replay_bitmap, + &encryptionSession.last_seen_counter, counter); } void getCurrentNonce(uint8_t* nonce) { @@ -208,14 +244,11 @@ void clearEncryptionSession() { memset(encryptionSession.server_nonce, 0, 16); memset(encryptionSession.pending_server_nonce, 0, 16); encryptionSession.authenticated = false; - encryptionSession.nonce_counter = 0; - encryptionSession.last_seen_counter = 0; - encryptionSession.integrity_failures = 0; + resetNonceState(); encryptionSession.session_start_time = 0; encryptionSession.last_activity = 0; encryptionSession.auth_attempts = 0; encryptionSession.server_nonce_time = 0; - memset(encryptionSession.replay_window, 0, sizeof(encryptionSession.replay_window)); od_log_info("Encryption session cleared"); } @@ -660,12 +693,9 @@ bool handleAuthenticate(uint8_t* data, uint16_t len) { // good handshake followed by one more rejection would drop a client that // had just authenticated. resetAuthAbuseCounter(); - encryptionSession.nonce_counter = 0; - encryptionSession.last_seen_counter = 0; - encryptionSession.integrity_failures = 0; + resetNonceState(); encryptionSession.session_start_time = currentTime; encryptionSession.last_activity = currentTime; - memset(encryptionSession.replay_window, 0, sizeof(encryptionSession.replay_window)); memset(encryptionSession.pending_server_nonce, 0, 16); encryptionSession.server_nonce_time = 0; uint8_t server_response[16]; @@ -694,13 +724,42 @@ bool handleAuthenticate(uint8_t* data, uint16_t len) { } bool decryptCommand(uint8_t* ciphertext, uint16_t ciphertext_len, uint8_t* plaintext, - uint16_t* plaintext_len, uint8_t* nonce_full, uint8_t* auth_tag, uint16_t command_header) { + uint16_t* plaintext_len, uint8_t* nonce_full, uint8_t* auth_tag, uint16_t command_header, + NonceResult* reason_out) { + // reason_out reports WHY the frame was rejected so the caller can tell a + // nonce rejection (ordinary packet loss) from a CCM tag failure (tamper + // evidence). NONCE_OK means "not rejected for a nonce reason" — every + // non-nonce failure path below leaves it at NONCE_OK. See Step 4b. + if (reason_out != nullptr) *reason_out = NONCE_OK; if (!isAuthenticated()) return false; - if (!verifyNonceReplay(nonce_full)) { - encryptionSession.integrity_failures++; - if (encryptionSession.integrity_failures >= 3) { - od_log_warn("Too many integrity failures, clearing session"); - clearEncryptionSession(); + + // Nonce failures are evidence of a LOSSY LINK, not of tampering, so they do + // NOT touch integrity_failures (the D1 fix). Only the CCM tag is a tamper + // oracle. [L7]: routing NONCE_BAD_SESSION here too is a deliberate policy + // change — a session-id mismatch is what a stale client sends after the + // device re-authenticated. See Step 4. + uint64_t nonce_counter = 0; + NonceResult nr = nonceCheck(nonce_full, &nonce_counter); + if (nr != NONCE_OK) { + if (reason_out != nullptr) *reason_out = nr; + if (nr != NONCE_BAD_SESSION && nonceLogAllowed(&nonce_log_window_ms)) { + // Direction comes from the COUNTERS, not from `nr`. Under numeric + // ordering every rejection is at or behind last_seen: anything ahead + // is accepted at any distance, so NONCE_OUT_OF_WINDOW now means only + // "more than OD_NONCE_BACKWARD_BITS behind". Inferring the direction + // from the reason would pick the wrong subtraction, underflow, and + // print a 20-digit number -- the exact unreadability this guards + // against. Computed rather than assumed so the line survives a future + // rule change. + const bool behind = (nonce_counter <= encryptionSession.last_seen_counter); + od_log_warn("Nonce %s (counter=%llu last_seen=%llu %s=%llu) - frame dropped, session kept", + behind ? "replay" : "out-of-window", + (unsigned long long)nonce_counter, + (unsigned long long)encryptionSession.last_seen_counter, + behind ? "back" : "fwd", + (unsigned long long)(behind + ? (encryptionSession.last_seen_counter - nonce_counter) + : (nonce_counter - encryptionSession.last_seen_counter))); } return false; } @@ -723,6 +782,12 @@ bool decryptCommand(uint8_t* ciphertext, uint16_t ciphertext_len, uint8_t* plain ad, 2, ciphertext, encrypted_len, decrypted_with_length, auth_tag, ENCRYPTION_TAG_SIZE); if (success) { + // [L2] FIRST statement of the success arm — deliberately ahead of the + // early return for a malformed payload_length below. That frame is + // *authentic* (it passed the CCM tag) and today's unconditional commit + // does record it; committing after the early return would silently + // leave an authentic frame replayable. + nonceCommit(nonce_counter); uint8_t payload_length = decrypted_with_length[0]; if (payload_length > encrypted_len - 1) { od_log_error("ERROR: Invalid payload length in decrypted data"); diff --git a/src/encryption.h b/src/encryption.h index 374cd6e..f17c93c 100644 --- a/src/encryption.h +++ b/src/encryption.h @@ -3,6 +3,7 @@ #include #include +#include "nonce_window.h" bool deriveSessionKey(const uint8_t* master_key, const uint8_t* client_nonce, const uint8_t* server_nonce, uint8_t* session_key); @@ -14,12 +15,17 @@ bool isAuthenticated(); void clearEncryptionSession(); bool checkEncryptionSessionTimeout(); void updateEncryptionSessionActivity(); -bool verifyNonceReplay(uint8_t* nonce); void getCurrentNonce(uint8_t* nonce); void incrementNonceCounter(); bool handleAuthenticate(uint8_t* data, uint16_t len); +/// Decrypt one CCM-enveloped command. On failure, *reason_out (if non-null) +/// reports whether the frame was rejected for a NONCE reason — ordinary packet +/// loss, which the caller must NOT answer with a fatal NACK on the pipe path +/// (Step 4b) — or is NONCE_OK, meaning the failure was a tag failure or a +/// malformed frame, which keeps today's NACK. bool decryptCommand(uint8_t* ciphertext, uint16_t ciphertext_len, uint8_t* plaintext, - uint16_t* plaintext_len, uint8_t* nonce, uint8_t* auth_tag, uint16_t command_header); + uint16_t* plaintext_len, uint8_t* nonce, uint8_t* auth_tag, uint16_t command_header, + NonceResult* reason_out); bool encryptResponse(uint8_t* plaintext, uint16_t plaintext_len, uint8_t* ciphertext, uint16_t* ciphertext_len, uint8_t* nonce, uint8_t* auth_tag); /// Derive the 16-byte TLS-PSK for the LAN TLS channel from the configured master diff --git a/src/encryption_state.h b/src/encryption_state.h index 88e8f63..ad90c59 100644 --- a/src/encryption_state.h +++ b/src/encryption_state.h @@ -4,6 +4,7 @@ #include #include #include "structs.h" +#include "nonce_window.h" #ifdef TARGET_ESP32 #include "mbedtls/ccm.h" #endif @@ -18,7 +19,15 @@ struct EncryptionSession { #endif uint64_t nonce_counter; uint64_t last_seen_counter; - uint64_t replay_window[64]; + // Anti-replay: bit i == "counter (last_seen_counter - i) has been consumed". + // Bit 0 is last_seen_counter itself. The backward window is implicitly + // OD_NONCE_BACKWARD_BITS - 1; there is no separate window constant to keep + // in step, and no insertion index to reset. Replaces a uint64_t[64] value + // ring (512 B -> 32 B). last_seen_counter only ever moves UP; see + // src/nonce_window.h for the decision rule and why that is the whole + // anti-replay argument, and docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md + // Step 1 / Decision B. + uint64_t replay_bitmap[OD_NONCE_BITMAP_WORDS]; uint32_t last_activity; uint8_t integrity_failures; uint32_t session_start_time; diff --git a/src/main.h b/src/main.h index dbb0a43..d9f93e9 100644 --- a/src/main.h +++ b/src/main.h @@ -257,9 +257,8 @@ void clearEncryptionSession(); bool checkEncryptionSessionTimeout(); void updateEncryptionSessionActivity(); bool handleAuthenticate(uint8_t* data, uint16_t len); -bool decryptCommand(uint8_t* ciphertext, uint16_t ciphertext_len, uint8_t* plaintext, uint16_t* plaintext_len, uint8_t* nonce, uint8_t* auth_tag, uint16_t command_header); +bool decryptCommand(uint8_t* ciphertext, uint16_t ciphertext_len, uint8_t* plaintext, uint16_t* plaintext_len, uint8_t* nonce, uint8_t* auth_tag, uint16_t command_header, NonceResult* reason_out); bool encryptResponse(uint8_t* plaintext, uint16_t plaintext_len, uint8_t* ciphertext, uint16_t* ciphertext_len, uint8_t* nonce, uint8_t* auth_tag); -bool verifyNonceReplay(uint8_t* nonce); void incrementNonceCounter(); void getCurrentNonce(uint8_t* nonce); diff --git a/src/nonce_window.h b/src/nonce_window.h new file mode 100644 index 0000000..164f435 --- /dev/null +++ b/src/nonce_window.h @@ -0,0 +1,206 @@ +#ifndef NONCE_WINDOW_H +#define NONCE_WINDOW_H + +// Anti-replay sliding window — pure state machine, ZERO dependencies. +// +// This header deliberately includes nothing from Arduino, mbedtls, or the +// firmware logging layer, and it touches no global state. Everything here +// operates on plain values passed in by the caller, so the whole state machine +// can be compiled and exercised on a host under UBSan/ASan by +// tools/test_nonce_window.cpp. See Decision D in +// docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. +// +// Representation ("shifting" style, as opposed to the circular RFC 6479 / +// WireGuard style -- Decision B in docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md, +// which still stands; only the arithmetic over it moved from modular to numeric): +// +// bit i of the bitmap == "counter (last_seen - i) has been consumed". +// bit 0 is last_seen itself. +// +// The backward window is therefore implicitly OD_NONCE_BACKWARD_BITS - 1; there +// is no separate window constant to keep in step, and no insertion index to +// reset. "Not seen" is a clear bit rather than a reserved sentinel value, so a +// fresh session (last_seen = 0, all-zero bitmap) accepts counter 0 exactly once +// with no has_seen_counter flag. +// +// --- relationship to RFC 4303 ----------------------------------------------- +// +// The decision rule is RFC 4303 Appendix A2's anti-replay window, and the three +// cases map one-to-one: right of the window -> authenticate, then slide (clearing +// wholesale when the jump exceeds the width); inside the window -> consult the +// bitmap; left of the window -> discard. So does the ordering that makes it safe, +// "if the MAC is valid, the window is updated" — here od_nonce_check() decides, +// CCM verifies, and only then does od_nonce_commit() run. +// +// Three deliberate departures, none of them semantic: +// +// - Counters are 0-based. RFC 4303 §3.3.3 starts sequence numbers at 1; this +// protocol's client sends counter 0 as its first command, so a 1-based rule +// would reject every client's first frame. +// - The full 64-bit counter is on the wire, so there is no ESN high-order-bit +// inference to do. That machinery would be complexity with no function. +// - The window is 256 bits rather than the RFC's 32 minimum / 64 default, +// which §3.4.3 explicitly permits. +// +// Following §3.3.3, the counter does NOT wrap: it is treated as a plain +// monotonically increasing uint64_t, and a session that reached UINT64_MAX would +// have to re-authenticate rather than cycle. Wrapping would reuse a (key, nonce) +// pair, which is the precise CCM failure this file exists to prevent. Exhausting +// 2^64 counters inside one session is not reachable in practice. + +#include +#include +#include + +// Width of the backward (out-of-order / duplicate arrival) window, in bits. +// uint64_t[4] = 32 B. +// +// This width is ONLY reordering tolerance. Replay protection does not depend on +// it: a counter at or below last_seen is either caught by the bitmap (within the +// width) or rejected on width, and only counters above last_seen are ever +// accepted — and those, by the monotonicity argument on od_nonce_check(), were +// never consumed. Narrowing or widening it cannot create a replay hole. +// +// Nor is it a correctness cliff, because a backward rejection is self-healing: +// the client re-encrypts every retransmission with a fresh, HIGHER counter (it +// never resends the original ciphertext), and a higher counter is accepted +// unconditionally. A frame rejected here is re-sent under a counter that is not. +// +// So the exact value is not load-bearing, and there is deliberately no attempt to +// derive it from the client's pipe window, blocks_per_ack or retransmit budget — +// none of those bear on it. 256 is a generous margin over the zero reordering the +// transport actually produces (the client assigns counters synchronously +// immediately before each write, ATT preserves order, and both targets consume +// their RX rings FIFO), at a cost of 32 bytes per session. +#define OD_NONCE_BACKWARD_BITS 256 + +#define OD_NONCE_BITMAP_WORDS (OD_NONCE_BACKWARD_BITS / 64) + +enum NonceResult { + NONCE_OK = 0, + NONCE_BAD_SESSION, + NONCE_OUT_OF_WINDOW, + NONCE_REPLAY +}; + +static inline bool od_nonce_bit_test(const uint64_t* bm, uint64_t bit) { + return ((bm[(size_t)(bit >> 6)] >> (unsigned)(bit & 63u)) & 1ull) != 0ull; +} + +static inline void od_nonce_bit_set(uint64_t* bm, uint64_t bit) { + bm[(size_t)(bit >> 6)] |= (1ull << (unsigned)(bit & 63u)); +} + +// Pure: decides whether `counter` may be accepted. Writes nothing. +// +// There is NO forward bound, and its absence is the point. A counter above +// last_seen is accepted at any distance, exactly as RFC 4303 does, because the +// sender may legitimately have burned counters this receiver never saw — every +// PIPE retransmission spends a fresh one. A forward cap here would not bound an +// attacker (see the commit contract below: passing this check mutates nothing, +// and only a verified CCM tag commits), but it WOULD strand the session: once a +// gap exceeded the cap, nothing would commit, last_seen would never advance, and +// every subsequent frame — each carrying a still higher counter — would be +// rejected further out than the last, until re-authentication. +// +// This reverses Decision A of docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md, which +// specified a cap of 128; that file's "Reversal of Decision A" section carries the +// full argument and the arithmetic showing no cap could have been sized safely. +// +// The security invariant is "a consumed counter is never returned NONCE_OK +// again", and it rests on last_seen being monotonically non-decreasing: only the +// forward branch of od_nonce_commit() assigns it, and only upward. Every consumed +// counter is therefore <= last_seen forever, so the `counter > last_seen` arm +// cannot re-accept one. Below that, a consumed counter is bitmap-caught while it +// is represented and rejected on width once it is not — the region below the +// window is closed, never waved through. +// +// Comparison is plain numeric, NOT modular. Modular arithmetic makes a counter +// far behind indistinguishable from one far ahead (the two differences are +// complements mod 2^64), which is what allowed an ancient counter to present as +// an enormous forward jump. Numeric ordering removes that overlap structurally +// rather than relying on the caller to check before committing. For the same +// reason, do not reintroduce a bound by setting a cap to UINT64_MAX or by +// treating "not within the backward window" as forward — either restores the +// overlap this ordering exists to eliminate. +// +// Total over all 2^64 inputs and never UB: the counter is parsed off the wire +// BEFORE the CCM tag is verified, so an unauthenticated attacker controls both +// operands. Only unsigned comparison and one unsigned subtraction are used, and +// that subtraction is evaluated solely on the branch where counter < last_seen, +// so it cannot wrap. Converting a uint64_t >= 2^63 to int64_t is +// implementation-defined before C++20, the signed subtraction can overflow, and +// negating INT64_MIN is UB — three ways to be undefined on attacker-chosen input, +// all avoided. +static inline NonceResult od_nonce_check(const uint64_t* bm, uint64_t last_seen, uint64_t counter) { + if (counter == last_seen) return od_nonce_bit_test(bm, 0) ? NONCE_REPLAY : NONCE_OK; + if (counter > last_seen) return NONCE_OK; /* ahead: never consumed, see above */ + + const uint64_t back = last_seen - counter; /* counter < last_seen, so no wrap */ + if (back < OD_NONCE_BACKWARD_BITS) return od_nonce_bit_test(bm, back) ? NONCE_REPLAY : NONCE_OK; + return NONCE_OUT_OF_WINDOW; +} + +// Shift the bitmap left by `shift` bits (bit i -> bit i + shift), clearing the +// vacated low bits. Handles shift == 0 and shift >= 64 explicitly: `x << 64` is +// undefined behaviour in C/C++ and is the classic bug in this pattern. +static inline void od_nonce_bitmap_shift_left(uint64_t* bm, uint64_t shift) { + if (shift == 0u) return; + if (shift >= OD_NONCE_BACKWARD_BITS) { + memset(bm, 0, sizeof(uint64_t) * OD_NONCE_BITMAP_WORDS); + return; + } + const size_t word_shift = (size_t)(shift >> 6); + const unsigned bit_shift = (unsigned)(shift & 63u); + for (size_t i = OD_NONCE_BITMAP_WORDS; i-- > 0;) { + uint64_t v = 0u; + if (i >= word_shift) { + v = bm[i - word_shift] << bit_shift; + if (bit_shift != 0u && i > word_shift) { + v |= bm[i - word_shift - 1] >> (64u - bit_shift); + } + } + bm[i] = v; + } +} + +// Records `counter` as consumed. MUST only be called after the frame carrying it +// has been authenticated (CCM tag verified) — that is the D2 fix, and it is what +// makes the unbounded forward arm of od_nonce_check() safe. All 8 counter bytes +// sit inside the CCM nonce, so a tampered counter changes the keystream and fails +// the tag: a committed counter is always exactly the one the key holder emitted. +// +// last_seen moves only upward, which is the whole security argument above. The +// wholesale-clear inside the shift is now an ORDINARY path, not an off-normal +// one: it fires whenever an authenticated frame lands more than +// OD_NONCE_BACKWARD_BITS ahead, which is the case this design exists to accept. +// It stays correct when it does, because every counter it discards is then at +// least OD_NONCE_BACKWARD_BITS behind the new last_seen and is rejected on width +// — never mis-reported as unseen. +// +// Defined (never UB) for every input, including inputs od_nonce_check() would +// have rejected, because the host test calls it directly. A counter further than +// OD_NONCE_BACKWARD_BITS behind is a deliberate no-op rather than a shift: under +// numeric ordering it can no longer masquerade as a forward jump, so the old +// hazard of such a counter rewinding last_seen and un-seeing the bitmap cannot +// arise — by construction, not by contract. +static inline void od_nonce_commit(uint64_t* bm, uint64_t* last_seen, uint64_t counter) { + if (counter == *last_seen) { + od_nonce_bit_set(bm, 0); + return; + } + if (counter < *last_seen) { + /* backward: last_seen does not move. Outside the width there is nowhere + to record it, and nothing needs recording — od_nonce_check() already + rejects everything that far behind. */ + const uint64_t back = *last_seen - counter; + if (back < OD_NONCE_BACKWARD_BITS) od_nonce_bit_set(bm, back); + return; + } + /* forward */ + od_nonce_bitmap_shift_left(bm, counter - *last_seen); + *last_seen = counter; + od_nonce_bit_set(bm, 0); +} + +#endif // NONCE_WINDOW_H diff --git a/tools/test_nonce_window.cpp b/tools/test_nonce_window.cpp new file mode 100644 index 0000000..b67c572 --- /dev/null +++ b/tools/test_nonce_window.cpp @@ -0,0 +1,816 @@ +// Host test for src/nonce_window.h — standalone, no test framework. +// +// Build and run from the repo root: +// +// g++ -std=c++17 -Wall -Wextra -Werror -O1 -fsanitize=undefined,address +// tools/test_nonce_window.cpp -o /tmp/test_nonce_window +// /tmp/test_nonce_window +// +// This file is as much a written-down statement of the intended semantics of the +// anti-replay window as it is a test. It discharges Decision D of +// docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. +// +// The representation under test (see the header): +// bit i of the bitmap == "counter (last_seen - i) has been consumed" +// bit 0 == last_seen itself +// so the backward window is OD_NONCE_BACKWARD_BITS wide (indices 0..255). There +// is no forward window: a counter ahead of last_seen is accepted at any distance. + +#include "../src/nonce_window.h" + +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Make UBSan reports fatal +// +// UBSan defaults to "print and keep going", so a regression that reintroduces +// signed counter arithmetic would emit a runtime-error line and still exit 0 — +// invisible to CI. __ubsan_on_report is the sanitizer's weak notification hook; +// overriding it turns any report into a nonzero exit without needing the caller +// to remember UBSAN_OPTIONS=halt_on_error=1. +// +// Defined unconditionally: libubsan declares it weak, so this overrides it when +// the sanitizer is linked in, and is simply unreferenced when it is not. (It +// cannot be guarded on a macro — GCC only started defining __SANITIZE_UNDEFINED__ +// in GCC 14, and this must work on older CI runners.) +// --------------------------------------------------------------------------- + +extern "C" void __ubsan_on_report(void); +extern "C" void __ubsan_on_report(void) { + std::fputs("FAIL: undefined behaviour reported by UBSan (see the runtime error above)\n", + stderr); + std::fflush(stderr); + std::_Exit(EXIT_FAILURE); +} + +// --------------------------------------------------------------------------- +// Minimal check harness +// --------------------------------------------------------------------------- + +static unsigned long g_checks = 0; +static unsigned long g_failures = 0; + +// Free-form context describing the enclosing loop iteration, printed on failure +// so that a failing case inside a data-driven loop is identifiable. +static char g_context[256] = ""; + +static void set_context(const char* fmt, ...) __attribute__((format(printf, 1, 2))); + +static void set_context(const char* fmt, ...) { + va_list ap; + va_start(ap, fmt); + vsnprintf(g_context, sizeof(g_context), fmt, ap); + va_end(ap); +} + +static void clear_context(void) { g_context[0] = '\0'; } + +static const char* res_name(NonceResult r) { + switch (r) { + case NONCE_OK: return "NONCE_OK"; + case NONCE_BAD_SESSION: return "NONCE_BAD_SESSION"; + case NONCE_OUT_OF_WINDOW: return "NONCE_OUT_OF_WINDOW"; + case NONCE_REPLAY: return "NONCE_REPLAY"; + } + return "NONCE_"; +} + +static void report_failure(const char* file, int line, const char* expr) { + ++g_failures; + if (g_context[0] != '\0') { + std::printf("FAIL %s:%d [%s]: %s\n", file, line, g_context, expr); + } else { + std::printf("FAIL %s:%d: %s\n", file, line, expr); + } +} + +#define CHECK(expr) \ + do { \ + ++g_checks; \ + if (!(expr)) report_failure(__FILE__, __LINE__, #expr); \ + } while (0) + +// Compares two NonceResult values and prints both symbolic names on mismatch. +#define CHECK_RES(actual, expected) \ + do { \ + ++g_checks; \ + const NonceResult check_a_ = (actual); \ + const NonceResult check_e_ = (expected); \ + if (check_a_ != check_e_) { \ + ++g_failures; \ + if (g_context[0] != '\0') { \ + std::printf("FAIL %s:%d [%s]: %s -> got %s, want %s\n", \ + __FILE__, __LINE__, g_context, #actual, \ + res_name(check_a_), res_name(check_e_)); \ + } else { \ + std::printf("FAIL %s:%d: %s -> got %s, want %s\n", __FILE__, \ + __LINE__, #actual, res_name(check_a_), \ + res_name(check_e_)); \ + } \ + } \ + } while (0) + +// --------------------------------------------------------------------------- +// State container +// --------------------------------------------------------------------------- + +struct NonceState { + uint64_t bm[OD_NONCE_BITMAP_WORDS]; + uint64_t last_seen; +}; + +// All members are uint64_t, so the struct has no padding and can be compared +// byte-for-byte by the purity test below. +static_assert(sizeof(NonceState) == sizeof(uint64_t) * (OD_NONCE_BITMAP_WORDS + 1), + "NonceState must be padding-free for the byte-identical purity check"); + +static void state_reset(NonceState* s, uint64_t last_seen) { + std::memset(s->bm, 0, sizeof(s->bm)); + s->last_seen = last_seen; +} + +static NonceResult check(const NonceState* s, uint64_t counter) { + return od_nonce_check(s->bm, s->last_seen, counter); +} + +static void commit(NonceState* s, uint64_t counter) { + od_nonce_commit(s->bm, &s->last_seen, counter); +} + +// Accept-and-record, the way firmware uses the pair: check, and only commit if +// the frame would be accepted (and, in firmware, only after the CCM tag verifies). +static NonceResult check_and_commit(NonceState* s, uint64_t counter) { + const NonceResult r = check(s, counter); + if (r == NONCE_OK) commit(s, counter); + return r; +} + +// --------------------------------------------------------------------------- +// Decision D coverage: fresh session (last_seen = 0, empty bitmap) +// +// "Not seen" is a clear bit, not a reserved sentinel, so counter 0 on a virgin +// state is accepted exactly once with no has_seen_counter flag anywhere. +// --------------------------------------------------------------------------- + +static void test_fresh_session(void) { + NonceState s; + state_reset(&s, 0); + + CHECK_RES(check(&s, 0), NONCE_OK); + commit(&s, 0); + CHECK_RES(check(&s, 0), NONCE_REPLAY); + + // last_seen has not moved: committing at fwd == 0 only sets bit 0. + CHECK(s.last_seen == 0); + CHECK(od_nonce_bit_test(s.bm, 0)); + + // Counter 1 is one ahead and cannot have been seen. + CHECK_RES(check(&s, 1), NONCE_OK); + + // The no-wrap contract, stated deliberately rather than inherited. + // + // Ordering is numeric, so with last_seen == 0 there is nothing "behind": every + // other counter is ahead and accepted at any distance. These three were + // previously read as modular distances behind zero (back == 1, 255, 256) and + // answered OK / OK / OUT_OF_WINDOW. They are all OK now, and for the opposite + // reason. + CHECK_RES(check(&s, UINT64_MAX), NONCE_OK); + CHECK_RES(check(&s, 0u - (uint64_t)OD_NONCE_BACKWARD_BITS), NONCE_OK); + CHECK_RES(check(&s, 0u - (uint64_t)(OD_NONCE_BACKWARD_BITS - 1)), NONCE_OK); + + // The state effect is what actually differs, and it is why wrap is not + // supported: accepting UINT64_MAX drives last_seen to the top of the range, + // after which nothing below it can ever be accepted again and the session must + // re-authenticate. Only a key holder can reach this (commit is post-CCM), so + // it is a contract, not a vulnerability. + { + NonceState t; + state_reset(&t, 0); + commit(&t, UINT64_MAX); + CHECK(t.last_seen == UINT64_MAX); + CHECK_RES(check(&t, UINT64_MAX), NONCE_REPLAY); + CHECK_RES(check(&t, 0), NONCE_OUT_OF_WINDOW); + } +} + +// --------------------------------------------------------------------------- +// Decision D coverage: D3 — re-presenting a committed counter is REPLAY, +// including at fwd == 0, the case today's firmware exempts. +// --------------------------------------------------------------------------- + +static void test_d3_same_counter_replay(void) { + const uint64_t base = 1000000u; + + // fwd == 0: the exempted case. + { + NonceState s; + state_reset(&s, base); + CHECK_RES(check(&s, base), NONCE_OK); // virgin bit 0 + commit(&s, base); + CHECK_RES(check(&s, base), NONCE_REPLAY); + CHECK_RES(check(&s, base), NONCE_REPLAY); // and it stays REPLAY + } + + // Forward acceptance then immediate re-presentation: the accepted counter + // becomes the new last_seen, so the replay lands on fwd == 0 again. + { + NonceState s; + state_reset(&s, base); + commit(&s, base); + CHECK_RES(check_and_commit(&s, base + 5), NONCE_OK); + CHECK(s.last_seen == base + 5); + CHECK_RES(check(&s, base + 5), NONCE_REPLAY); + // The old last_seen is now 5 behind and still recorded. + CHECK_RES(check(&s, base), NONCE_REPLAY); + // A gap counter between them was never committed. + CHECK_RES(check(&s, base + 3), NONCE_OK); + } + + // Backward, in-window acceptance then re-presentation. + { + NonceState s; + state_reset(&s, base); + commit(&s, base); + CHECK_RES(check_and_commit(&s, base - 100), NONCE_OK); + CHECK(s.last_seen == base); // backward commits never move last_seen + CHECK_RES(check(&s, base - 100), NONCE_REPLAY); + CHECK_RES(check(&s, base - 99), NONCE_OK); + CHECK_RES(check(&s, base - 101), NONCE_OK); + } +} + +// --------------------------------------------------------------------------- +// Decision D coverage: purity of od_nonce_check (D2) +// +// THE most important assertion in this file. "The tag is the only thing that may +// advance replay state" rests entirely on od_nonce_check writing nothing, so a +// pre-authentication call on attacker-controlled input cannot poison the window. +// Every result class is exercised against one snapshot and the whole state is +// compared byte-for-byte afterwards. +// --------------------------------------------------------------------------- + +static void test_check_is_pure(void) { + const uint64_t base = 500000u; + + NonceState s; + state_reset(&s, base); + // Build a non-trivial bitmap: bits at word boundaries and in between. + commit(&s, base); + const uint64_t seen_offsets[] = {1, 2, 63, 64, 65, 127, 128, 191, 192, 254, 255}; + for (size_t i = 0; i < sizeof(seen_offsets) / sizeof(seen_offsets[0]); ++i) { + commit(&s, base - seen_offsets[i]); + } + + unsigned char snapshot[sizeof(NonceState)]; + std::memcpy(snapshot, &s, sizeof(snapshot)); + + // One input per result class produced by od_nonce_check. + struct Case { + uint64_t counter; + NonceResult expected; + const char* what; + }; + const Case cases[] = { + {base, NONCE_REPLAY, "equal, bit 0 set -> REPLAY"}, + {base + 1, NONCE_OK, "forward by 1 -> OK"}, + {base - 3, NONCE_OK, "backward, unseen -> OK"}, + {base - 64, NONCE_REPLAY, "backward, seen -> REPLAY"}, + {base - OD_NONCE_BACKWARD_BITS, NONCE_OUT_OF_WINDOW, "past the backward window"}, + // Forward is unbounded: these are the distances a forward cap would have + // rejected, and every one of them must be OK. + {base + 129, NONCE_OK, "just past where the old cap sat"}, + {base + 5000, NONCE_OK, "far ahead"}, + {base + (1ull << 32), NONCE_OK, "very far ahead"}, + {base + (1ull << 62), NONCE_OK, "absurdly far ahead"}, + // Genuinely behind: the distance must not exceed `base`, or the + // subtraction underflows and the counter is numerically AHEAD -- which is + // exactly the modular confusion this ordering removes. + {base - 100000, NONCE_OUT_OF_WINDOW, "far behind"}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + set_context("purity case: %s", cases[i].what); + CHECK_RES(check(&s, cases[i].counter), cases[i].expected); + } + clear_context(); + + CHECK(std::memcmp(snapshot, &s, sizeof(snapshot)) == 0); + + // The fwd == 0 OK class needs a state where bit 0 is clear; check purity there too. + NonceState fresh; + state_reset(&fresh, base); + unsigned char fresh_snapshot[sizeof(NonceState)]; + std::memcpy(fresh_snapshot, &fresh, sizeof(fresh_snapshot)); + CHECK_RES(check(&fresh, base), NONCE_OK); + CHECK(std::memcmp(fresh_snapshot, &fresh, sizeof(fresh_snapshot)) == 0); + + // Repeated calls on every input class, in bulk, still change nothing. + for (int rep = 0; rep < 4; ++rep) { + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + (void)check(&s, cases[i].counter); + } + } + CHECK(std::memcmp(snapshot, &s, sizeof(snapshot)) == 0); +} + +// --------------------------------------------------------------------------- +// Decision D coverage: shift edges +// +// For each forward delta d, seed a window with a spread of previously consumed +// counters, slide forward by d, and then require: +// - every seeded counter whose new distance (d + i) is < OD_NONCE_BACKWARD_BITS +// is still reported REPLAY (the bit moved with the window, it did not fall +// off early and it did not land on the wrong index), and +// - every seeded counter whose new distance is >= OD_NONCE_BACKWARD_BITS is +// reported OUT_OF_WINDOW, never REPLAY. +// +// EVERY delta is driven through od_nonce_check, including the ones at and past +// the bitmap width. Forward acceptance is unbounded, so the wholesale-clear path +// is now reached the way production reaches it rather than by calling commit +// directly — which matters, because that path stopped being an unreachable guard +// and became the case this design exists to accept. +// --------------------------------------------------------------------------- + +static const uint64_t kSeedOffsets[] = {0, 1, 2, 63, 64, 65, 127, 128, 129, 191, 192, 254, 255}; +static const size_t kSeedCount = sizeof(kSeedOffsets) / sizeof(kSeedOffsets[0]); + +static void seed_window(NonceState* s, uint64_t base) { + state_reset(s, base); + for (size_t i = 0; i < kSeedCount; ++i) { + commit(s, base - kSeedOffsets[i]); + } + // Sanity: everything seeded reads back as consumed, before any slide. + for (size_t i = 0; i < kSeedCount; ++i) { + CHECK_RES(check(s, base - kSeedOffsets[i]), NONCE_REPLAY); + } + CHECK(s->last_seen == base); +} + +static void run_shift_edge(uint64_t d) { + const uint64_t base = 1000000u; + NonceState s; + seed_window(&s, base); + + set_context("shift edge d=%" PRIu64, d); + + // d == 0 lands on the equality branch, whose bit is already set by the seed. + // Every other d is forward and accepted at any distance. + CHECK_RES(check(&s, base + d), d == 0 ? NONCE_REPLAY : NONCE_OK); + commit(&s, base + d); + + CHECK(s.last_seen == base + d); + // The newly committed counter is always recorded. + CHECK_RES(check(&s, base + d), NONCE_REPLAY); + + for (size_t i = 0; i < kSeedCount; ++i) { + const uint64_t counter = base - kSeedOffsets[i]; + const uint64_t new_back = d + kSeedOffsets[i]; + const bool wholesale_clear = (d >= OD_NONCE_BACKWARD_BITS); + set_context("shift edge d=%" PRIu64 ", seeded offset %" PRIu64 + " (new back %" PRIu64 ")", + d, kSeedOffsets[i], new_back); + if (new_back < OD_NONCE_BACKWARD_BITS && !wholesale_clear) { + CHECK_RES(check(&s, counter), NONCE_REPLAY); + } else { + CHECK_RES(check(&s, counter), NONCE_OUT_OF_WINDOW); + } + } + + // Counters that were never seeded and are still inside the backward window + // must read as unseen, not as bits smeared by the shift. + for (uint64_t back = 1; back < OD_NONCE_BACKWARD_BITS; ++back) { + const uint64_t counter = (base + d) - back; + // Is this counter one of the seeded ones (or the pre-slide last_seen)? + bool seeded = false; + for (size_t i = 0; i < kSeedCount; ++i) { + if (counter == base - kSeedOffsets[i]) seeded = true; + } + const bool expect_replay = seeded && (d < OD_NONCE_BACKWARD_BITS); + set_context("shift edge d=%" PRIu64 ", back=%" PRIu64, d, back); + CHECK_RES(check(&s, counter), expect_replay ? NONCE_REPLAY : NONCE_OK); + } + + // Exactly at the window edge and beyond it: rejected on width. + set_context("shift edge d=%" PRIu64 ", window edge", d); + CHECK_RES(check(&s, (base + d) - OD_NONCE_BACKWARD_BITS), NONCE_OUT_OF_WINDOW); + CHECK_RES(check(&s, (base + d) - (OD_NONCE_BACKWARD_BITS + 1)), NONCE_OUT_OF_WINDOW); + + clear_context(); +} + +static void test_shift_edges(void) { + // Word boundaries, the exact width boundary (255 / 256, the shift-vs-clear + // pair), and jumps far past it — all through od_nonce_check. + const uint64_t deltas[] = {0, 1, 63, 64, 65, 127, 128, 129, + 191, 192, 255, 256, 257, 300, + 4096, (1ull << 32)}; + for (size_t i = 0; i < sizeof(deltas) / sizeof(deltas[0]); ++i) { + run_shift_edge(deltas[i]); + } +} + +// --------------------------------------------------------------------------- +// Decision D coverage: wholesale slide +// +// A forward jump of at least OD_NONCE_BACKWARD_BITS clears the bitmap outright +// (x << 64 is UB, and shifting a 256-bit map by >= 256 has no surviving bits). +// Everything previously consumed must then come back OUT_OF_WINDOW, NOT REPLAY. +// Both reject, so conflating them is invisible in behaviour — and would hide a +// genuine slide bug where the map was cleared when it should not have been. +// --------------------------------------------------------------------------- + +static void test_wholesale_slide(void) { + const uint64_t base = 1000000u; + const uint64_t jumps[] = {OD_NONCE_BACKWARD_BITS, OD_NONCE_BACKWARD_BITS + 1, 300, 100000, + (1ull << 40)}; + + for (size_t j = 0; j < sizeof(jumps) / sizeof(jumps[0]); ++j) { + const uint64_t d = jumps[j]; + NonceState s; + seed_window(&s, base); + set_context("wholesale slide d=%" PRIu64, d); + + commit(&s, base + d); + CHECK(s.last_seen == base + d); + + // Bitmap holds exactly one bit: the counter just committed. + CHECK(s.bm[0] == 1ull); + for (size_t w = 1; w < OD_NONCE_BITMAP_WORDS; ++w) { + CHECK(s.bm[w] == 0ull); + } + + for (size_t i = 0; i < kSeedCount; ++i) { + CHECK_RES(check(&s, base - kSeedOffsets[i]), NONCE_OUT_OF_WINDOW); + } + // The whole new backward window is unseen except bit 0. + CHECK_RES(check(&s, base + d), NONCE_REPLAY); + for (uint64_t back = 1; back < OD_NONCE_BACKWARD_BITS; ++back) { + CHECK_RES(check(&s, (base + d) - back), NONCE_OK); + } + } + clear_context(); +} + +// --------------------------------------------------------------------------- +// Decision D coverage: bit index correctness after a forward commit +// +// Direct statement of the shift invariant, below the od_nonce_check layer: the +// bit that denoted counter (L - i) under last_seen = L must denote exactly the +// same counter under L' = L + d, i.e. it must sit at index i + d. Bits pushed to +// index >= OD_NONCE_BACKWARD_BITS are gone. +// --------------------------------------------------------------------------- + +static void test_bit_indices_after_shift(void) { + const uint64_t base = 1000000u; + const uint64_t deltas[] = {1, 63, 64, 65, 127}; + + for (size_t k = 0; k < sizeof(deltas) / sizeof(deltas[0]); ++k) { + const uint64_t d = deltas[k]; + NonceState s; + seed_window(&s, base); + + bool before[OD_NONCE_BACKWARD_BITS]; + for (uint64_t i = 0; i < OD_NONCE_BACKWARD_BITS; ++i) { + before[i] = od_nonce_bit_test(s.bm, i); + } + + commit(&s, base + d); + + for (uint64_t i = 0; i < OD_NONCE_BACKWARD_BITS; ++i) { + set_context("bit index d=%" PRIu64 ", old index %" PRIu64, d, i); + if (i + d < OD_NONCE_BACKWARD_BITS) { + // Same counter, new index. + CHECK(od_nonce_bit_test(s.bm, i + d) == before[i]); + } + // The vacated low bits are all clear except bit 0, which the commit set. + if (i < d) { + CHECK(od_nonce_bit_test(s.bm, i) == (i == 0)); + } + } + clear_context(); + } +} + +// --------------------------------------------------------------------------- +// Counter arithmetic at the extremes [M1] +// +// Expectations are derived from the numeric definition in the header, not from +// intuition: counters are plain monotonically increasing uint64_t values that do +// NOT wrap, and the ordered tests are counter == last_seen, counter > last_seen, +// back < BITS, else OUT_OF_WINDOW. +// +// UBSan makes the whole file self-checking against the signed formulation, in +// which these same inputs are undefined (int64_t conversion of values >= 2^63, +// signed overflow, negating INT64_MIN). +// --------------------------------------------------------------------------- + +static void test_counter_arithmetic_extremes(void) { + const uint64_t two63 = 1ull << 63; + + // counter = 2^63, last_seen = 1. The input that makes the signed form UB, and + // the one whose answer INVERTED when ordering stopped being modular: it used + // to be "out of window in both directions", because the modular fwd and back + // were both enormous. Numerically it is simply ahead, so it is accepted -- + // and gated by the CCM tag, not by distance. + { + NonceState s; + state_reset(&s, 1); + CHECK(two63 > 1u); + CHECK_RES(check(&s, two63), NONCE_OK); + } + // The mirror image is unchanged: last_seen = 2^63, counter = 1 is behind, far + // past the width. + { + NonceState s; + state_reset(&s, two63); + CHECK_RES(check(&s, 1), NONCE_OUT_OF_WINDOW); + CHECK_RES(check(&s, 0), NONCE_OUT_OF_WINDOW); + } + // From the very top of the range, everything is behind. + { + NonceState s; + state_reset(&s, UINT64_MAX); + CHECK_RES(check(&s, UINT64_MAX - two63), NONCE_OUT_OF_WINDOW); + CHECK_RES(check(&s, UINT64_MAX - (uint64_t)OD_NONCE_BACKWARD_BITS), NONCE_OUT_OF_WINDOW); + CHECK_RES(check(&s, UINT64_MAX - (uint64_t)(OD_NONCE_BACKWARD_BITS - 1)), NONCE_OK); + CHECK_RES(check(&s, UINT64_MAX), NONCE_OK); // equal, bit 0 clear + } + + // Near UINT64_MAX. last_seen = UINT64_MAX - 2. + // + // This block used to assert that the window WRAPS -- that counter 0 is three + // ahead of UINT64_MAX - 2 and acceptable. It no longer does, and that is the + // deliberate contract: wrapping would reuse a (key, nonce) pair. Counters at + // the low end of the range are simply behind, and the session must + // re-authenticate rather than cycle. + { + const uint64_t L = UINT64_MAX - 2u; + NonceState s; + state_reset(&s, L); + + CHECK_RES(check(&s, L), NONCE_OK); // equal, bit 0 clear + commit(&s, L); + CHECK_RES(check(&s, L), NONCE_REPLAY); + + // Forward, no wrap available: only two counters remain in the range. + CHECK_RES(check(&s, UINT64_MAX - 1u), NONCE_OK); + CHECK_RES(check(&s, UINT64_MAX), NONCE_OK); + + // Past the top there is nothing. 0 and 1 are far BEHIND, not ahead. + CHECK_RES(check(&s, 0), NONCE_OUT_OF_WINDOW); + CHECK_RES(check(&s, 1), NONCE_OUT_OF_WINDOW); + + // Backward across the top of the space behaves normally. + CHECK_RES(check(&s, L - 1u), NONCE_OK); + CHECK_RES(check(&s, L - (uint64_t)(OD_NONCE_BACKWARD_BITS - 1)), NONCE_OK); + CHECK_RES(check(&s, L - (uint64_t)OD_NONCE_BACKWARD_BITS), NONCE_OUT_OF_WINDOW); + + // Consume the last counter in the range; the session is now exhausted. + CHECK_RES(check_and_commit(&s, UINT64_MAX), NONCE_OK); + CHECK(s.last_seen == UINT64_MAX); + CHECK_RES(check(&s, UINT64_MAX), NONCE_REPLAY); + CHECK_RES(check(&s, 0), NONCE_OUT_OF_WINDOW); + // Everything below is either recorded or off the end -- never OK again. + CHECK_RES(check(&s, L), NONCE_REPLAY); + CHECK_RES(check(&s, UINT64_MAX - 1u), NONCE_OK); // never committed, in window + + // Backward commits still do not move last_seen. + CHECK_RES(check_and_commit(&s, UINT64_MAX - 1u), NONCE_OK); + CHECK(s.last_seen == UINT64_MAX); + CHECK_RES(check(&s, UINT64_MAX - 1u), NONCE_REPLAY); + } +} + +// --------------------------------------------------------------------------- +// Differential / property test against a naive oracle +// +// The oracle keeps the UNPRUNED set of every counter it has ever committed. That +// is the point: the previous version pruned counters as they fell out of the +// backward window, which meant it encoded the implementation's forgetting and +// could only ever agree with it. An unpruned set lets the oracle be written from +// the specification instead -- +// +// a counter is acceptable iff it has never been committed AND it is either +// ahead of last_seen or within OD_NONCE_BACKWARD_BITS of it +// +// -- and it is what makes od_nonce_never_accepts_consumed() below possible, which +// is the one assertion in this file that does not restate the code. +// +// Counters stay clear of 0 and 2^64 so the walk never runs out of range; the +// extremes are covered by test_counter_arithmetic_extremes(). +// --------------------------------------------------------------------------- + +struct Oracle { + std::set ever_committed; // never pruned + uint64_t last_seen; +}; + +static NonceResult oracle_check(const Oracle& o, uint64_t counter) { + const bool consumed = o.ever_committed.count(counter) != 0; + if (counter > o.last_seen) return NONCE_OK; // ahead: cannot have been consumed + if (counter == o.last_seen) return consumed ? NONCE_REPLAY : NONCE_OK; + const uint64_t back = o.last_seen - counter; + if (back >= (uint64_t)OD_NONCE_BACKWARD_BITS) return NONCE_OUT_OF_WINDOW; + return consumed ? NONCE_REPLAY : NONCE_OK; +} + +static void oracle_commit(Oracle* o, uint64_t counter) { + o->ever_committed.insert(counter); + if (counter > o->last_seen) o->last_seen = counter; // only ever upward +} + +// THE security property, asserted directly and independently of how the window is +// represented: no counter that was ever consumed may be accepted again, at any +// distance, by any route. A regression that reintroduced modular overlap -- or a +// commit that rewound last_seen -- shows up here as an OK on a member of the set, +// whatever the bitmap happens to contain. +static void od_nonce_never_accepts_consumed(const NonceState* s, const Oracle& o) { + for (std::set::const_iterator it = o.ever_committed.begin(); + it != o.ever_committed.end(); ++it) { + set_context("consumed counter %" PRIu64 " must never be OK (last_seen=%" PRIu64 ")", + *it, s->last_seen); + CHECK(check(s, *it) != NONCE_OK); + } + clear_context(); +} + +static void test_differential_against_oracle(void) { + std::mt19937_64 rng(0xD15EA5EULL); // fixed seed: this test must be reproducible + + const int kSequences = 40; + const int kStepsPerSequence = 200; + + for (int seq = 0; seq < kSequences; ++seq) { + const uint64_t base = (1ull << 40) + (uint64_t)seq * 4096u; + + NonceState s; + state_reset(&s, base); + Oracle o; + o.last_seen = base; + + for (int step = 0; step < kStepsPerSequence; ++step) { + const unsigned bucket = (unsigned)(rng() % 100u); + uint64_t counter; + if (bucket < 40u) { + // Forward by a small step, including 0 (a replay probe). + counter = s.last_seen + (rng() % 129u); + } else if (bucket < 60u) { + // Forward by a large jump -- the case a forward cap used to reject + // and which must now be accepted, including jumps wide enough to + // clear the bitmap wholesale. + counter = s.last_seen + 129u + (rng() % 100000u); + } else if (bucket < 90u) { + // Backward inside or just outside the window. + counter = s.last_seen - (1u + rng() % (uint64_t)(OD_NONCE_BACKWARD_BITS + 64)); + } else { + // Far behind. + counter = s.last_seen - (uint64_t)OD_NONCE_BACKWARD_BITS - + (rng() % 100000u); + } + + set_context("differential seq=%d step=%d counter=%" PRIu64 + " last_seen=%" PRIu64, + seq, step, counter, s.last_seen); + + const NonceResult got = check(&s, counter); + const NonceResult want = oracle_check(o, counter); + CHECK_RES(got, want); + CHECK(o.last_seen == s.last_seen); + + if (got == NONCE_OK) { + commit(&s, counter); + oracle_commit(&o, counter); + CHECK(o.last_seen == s.last_seen); + } + } + + // End of sequence: sweep the entire backward window and compare, so that + // any bit the two models disagree about is caught even if the random + // walk never probed it. + for (uint64_t back = 0; back < (uint64_t)OD_NONCE_BACKWARD_BITS + 8u; ++back) { + const uint64_t counter = s.last_seen - back; + set_context("differential sweep seq=%d back=%" PRIu64, seq, back); + CHECK_RES(check(&s, counter), oracle_check(o, counter)); + } + + // ...and the security property over every counter this sequence consumed, + // not just the ones still inside the window. + od_nonce_never_accepts_consumed(&s, o); + } + clear_context(); +} + +// --------------------------------------------------------------------------- +// The cliff, as a regression test. +// +// A forward cap made this sequence unrecoverable: nothing commits, so last_seen +// never advances, and each retransmission's higher counter is rejected further +// out than the last. Asserted as a SEQUENCE rather than a point, because a point +// test at last_seen+129 would also pass under "cap = UINT64_MAX" -- one of the two +// shortcuts the header warns against. +// --------------------------------------------------------------------------- + +static void test_forward_gap_is_not_a_cliff(void) { + const uint64_t L = 1000u; + + // Distances that a cap would have rejected, each from a clean state. + const uint64_t jumps[] = {129u, 300u, 5000u, 100000u, (1ull << 32), (1ull << 63)}; + for (size_t i = 0; i < sizeof(jumps) / sizeof(jumps[0]); ++i) { + NonceState s; + state_reset(&s, L); + commit(&s, L); + set_context("forward jump %" PRIu64, jumps[i]); + + CHECK_RES(check(&s, L + jumps[i]), NONCE_OK); + commit(&s, L + jumps[i]); + CHECK(s.last_seen == L + jumps[i]); + CHECK_RES(check(&s, L + jumps[i]), NONCE_REPLAY); + + // The stream must KEEP flowing afterwards -- this is what a cap broke. + for (uint64_t k = 1; k <= 8u; ++k) { + CHECK_RES(check_and_commit(&s, L + jumps[i] + k), NONCE_OK); + } + CHECK(s.last_seen == L + jumps[i] + 8u); + } + clear_context(); + + // The client's actual failure mode: escalating retransmissions. Nothing is + // committed until the frame authenticates, so a run of checks at ever greater + // distance must leave the state untouched AND stay acceptable. + { + NonceState s; + state_reset(&s, L); + commit(&s, L); + unsigned char snapshot[sizeof(NonceState)]; + std::memcpy(snapshot, &s, sizeof(snapshot)); + + uint64_t c = L + 129u; + for (int i = 0; i < 32; ++i) { + set_context("escalating retransmit %d counter=%" PRIu64, i, c); + CHECK_RES(check(&s, c), NONCE_OK); + CHECK(std::memcmp(snapshot, &s, sizeof(snapshot)) == 0); + c += 1000u; + } + // The one that finally authenticates advances the window normally. + CHECK_RES(check_and_commit(&s, c), NONCE_OK); + CHECK(s.last_seen == c); + clear_context(); + } +} + +// --------------------------------------------------------------------------- +// The two shortcuts the header forbids. +// +// Setting a cap to UINT64_MAX, or treating "not within the backward window" as +// forward, both keep modular overlap: an ancient counter presents as an enormous +// forward jump and is accepted. Under numeric ordering it must be OUT_OF_WINDOW. +// This is the test that distinguishes the real fix from either shortcut. +// --------------------------------------------------------------------------- + +static void test_far_behind_is_never_forward(void) { + const uint64_t L = 1ull << 40; + NonceState s; + state_reset(&s, L); + commit(&s, L); + + const uint64_t behind[] = {300u, 4096u, 100000u, (1ull << 32), (1ull << 39)}; + for (size_t i = 0; i < sizeof(behind) / sizeof(behind[0]); ++i) { + set_context("far behind by %" PRIu64, behind[i]); + CHECK_RES(check(&s, L - behind[i]), NONCE_OUT_OF_WINDOW); + + // And committing one anyway (the primitive is callable directly) must not + // rewind last_seen or clear the bitmap -- the old sharp edge. + NonceState t = s; + commit(&t, L - behind[i]); + CHECK(t.last_seen == L); + CHECK(std::memcmp(&t, &s, sizeof(NonceState)) == 0); + } + clear_context(); +} + +// --------------------------------------------------------------------------- + +int main(void) { + test_fresh_session(); + test_d3_same_counter_replay(); + test_check_is_pure(); + test_shift_edges(); + test_wholesale_slide(); + test_bit_indices_after_shift(); + test_counter_arithmetic_extremes(); + test_forward_gap_is_not_a_cliff(); + test_far_behind_is_never_forward(); + test_differential_against_oracle(); + + if (g_failures != 0u) { + std::printf("FAILED %lu of %lu checks\n", g_failures, g_checks); + return EXIT_FAILURE; + } + std::printf("PASSED %lu checks\n", g_checks); + return EXIT_SUCCESS; +}