diff --git a/.gitignore b/.gitignore index c7ea016..589dd7e 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,15 @@ __pycache__/ .idea .claude +# Coredumps pulled off-device for post-mortem analysis +core.* + +# Generated by the build for clangd/IDE indexing +compile_commands.json + +# Local MCP server config +.mcp.json + ### VS Code workspace *.code-workspace diff --git a/docs/FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md b/docs/FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md deleted file mode 100644 index b3b85a5..0000000 --- a/docs/FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md +++ /dev/null @@ -1,706 +0,0 @@ -# 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 deleted file mode 100644 index 70a0bbf..0000000 --- a/docs/FINDINGS_LOOP_ARCHITECTURE_CONVERGENCE_2026-07-26.md +++ /dev/null @@ -1,158 +0,0 @@ -# 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 deleted file mode 100644 index 0169586..0000000 --- a/docs/FINDINGS_PHASE1_PLAN_REVIEW_2026-07-26.md +++ /dev/null @@ -1,431 +0,0 @@ -# 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 deleted file mode 100644 index 901cf68..0000000 --- a/docs/FINDINGS_PHASE3_PLAN_REVIEW_2026-07-26.md +++ /dev/null @@ -1,232 +0,0 @@ -# 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/FIRMWARE_NIMBLE_PORT_CODE_REVIEW_2026-07-17.md b/docs/FIRMWARE_NIMBLE_PORT_CODE_REVIEW_2026-07-17.md deleted file mode 100644 index 7aba237..0000000 --- a/docs/FIRMWARE_NIMBLE_PORT_CODE_REVIEW_2026-07-17.md +++ /dev/null @@ -1,359 +0,0 @@ -# Firmware Code Review — Post NimBLE Port - -**Repo:** `Firmware` (PlatformIO; ESP32-S3/C3/C6 + nRF52840) -**Date:** 2026-07-17 -**Trigger:** ESP32 BLE stack migrated Bluedroid → NimBLE-Arduino 2.x (`5d7e705` + follow-ups `a5b49b2`, `84c322e`, `ae85de8`) -**Scope:** Whole Firmware repo, current `main`, with a deliberate bias toward **race conditions, object lifetime, and NimBLE 2.x porting hazards**, plus general error conditions. -**Method:** 5 parallel review agents, each reading the actual source (and, for the BLE core, the vendored `NimBLE-Arduino` 2.x library under `.pio/libdeps/`). Every finding cites `file:line`. Findings marked *needs verification* could not be fully confirmed from source alone. -**Nature:** Report only — no code was modified. - ---- - -## Executive summary - -The NimBLE port's **data plane is well built.** The command path uses a correct single-producer/single-consumer (SPSC) ring with acquire/release atomics; the `onWrite` callback only copies bytes into that ring and does no processing; binary payloads (including leading `0x00`) survive because the port uses `NimBLEAttValue` rather than Arduino `String`; the callback objects are statically allocated with the `deleteCallbacks=false` ownership flags set correctly; and `deinit(true)` + handle-clearing are ordered safely on a single task. The known `setCallbacks` hazard class was audited and has **no remaining siblings** (see *Verified clean*). - -The port's structural weakness is concentrated in **one root cause**: heavyweight, state-mutating work still runs *inside the NimBLE server callbacks*, on the NimBLE host task. On nRF/Bluefruit the connect/disconnect/write callbacks and command processing all shared a single BLE task, so these operations were serialized by construction. The migration moved command/response processing to `loop()` but left connect-time and disconnect-time teardown/refresh on the now-genuinely-concurrent (and on the S3, other-core) host task. This produces the Critical SPI/rail-teardown race and two High races. **All three share one fix** — make every callback *flag-only* and service the flag from `loop()`, exactly as the code already does for `bleRestartAdvertisingPending`. - -A second, independent cluster of findings is in the **encryption/session layer** (replay + nonce reuse). These are **pre-existing** (not introduced by the NimBLE port) but are the highest-severity correctness/security issues after the Critical race, so they are included. - -### Provenance at a glance - -| Cluster | Introduced by NimBLE port? | -|---|---| -| Callback-on-host-task races (#1, #2, #3, #6) | **Yes** — migration moved command processing to `loop()`, left teardown on the callback task | -| `0x0052` force-sleep deinit-while-connected (#8) | Partly — interacts with NimBLE `deinit(true)` semantics | -| MTU silent-drop, advertising restart, MSD churn (#7, #13, #14, #26) | Yes — NimBLE 2.x advertising/MTU behavior | -| Crypto replay + nonce reuse + DoS (#4, #5, #9) | **No** — pre-existing session logic | -| Display TCON/pipe/keep-alive (#10, #11, #22) | No — pre-existing display path (but #11 interacts with disconnect cleanup) | -| Buzzer/sleep, sensors, wifi (#12, #23, #25) | No — pre-existing | - -### Count by severity - -| Severity | Count | Numbers | -|---|---|---| -| Critical | 1 | #1 | -| High | 4 | #2, #3, #4, #5 | -| Medium | 7 | #6, #7, #8, #9, #10, #11, #12 | -| Low | 13 | #13–#25 | -| Informational | 7 | #26–#32 | - ---- - -## CRITICAL - -### #1 — `onDisconnect` (host task) tears down the EPD/direct-write session while `loop()` is streaming SPI -- **Location:** `src/esp32_ble_callbacks.h:53-74`; `src/display_service.cpp:1711-1737` (`cleanupDirectWriteState`), `:220-236` (`epdSessionForceOffLocked`); `src/main.cpp:325-341` (queue drain), `:662-673` (`pwrmgm`/`SPI.end()`); `src/main.h:381-384` -- **Category:** race (cross-task, use-after-free of the SPI bus/rail) -- **Provenance:** Introduced by the NimBLE migration. -- **Observed:** In NimBLE-Arduino 2.x, `MyBLEServerCallbacks::onDisconnect` is invoked directly from the GAP event handler on the `nimble_host` FreeRTOS task (confirmed in the vendored `NimBLEServer.cpp:507`), **not** on `loopTask`. The handler does: - ```cpp - if (epdRefreshInProgress) { /* defer */ } else { - if (directWriteActive) cleanupDirectWriteState(true); // -> epdSessionForceOff() - cleanupPartialWriteOnDisconnect(); - } - ``` - `cleanupDirectWriteState(true)` → `epdSessionForceOff()` runs `bbepSleep(&bbep,1); delay(50); pwrmgm(false)`, and `pwrmgm(false)` calls `SPI.end()` and cuts the panel rail. Meanwhile the **loop task** is draining queued `0x71`/`0x81` frames (`imageDataWritten` at `main.cpp:332`) and clocking them out via `bbepWriteData`. `main.h:381-383` explicitly sizes the 33-slot queue to absorb "a 60 s Spectra SPI stall (loop blocked in `bbepWriteData`)." -- **Why wrong / trigger:** The only guard, `epdRefreshInProgress`, covers the *refresh* phase, not the *streaming* phase. A client disconnect (supervision timeout / phone out of range — especially likely *during* that 60 s SPI stall) while `directWriteActive` and the loop is inside `bbepWriteData` causes the host task to issue SPI ops (`bbepSleep`) on the bus the loop is concurrently clocking, then `SPI.end()` and power the rail off under the loop's feet. On the dual-core S3 these run truly in parallel. Result: SPI driver corruption/crash, or the loop writing to a dead bus and hanging. `pwrmgmLock` does not protect this — the loop only holds it inside `epdSessionAcquire/Release`, and it is released (`display_service.cpp:273`) before any data is streamed. The `directWrite*` globals cleared at `display_service.cpp:1712-1723` are plain non-atomic bools/ints, read mid-operation by the loop, and even the `directWriteActive` check in the callback is a cross-task read of a non-volatile bool. -- **Solution:** Make `onDisconnect` **flag-only**: set a `volatile bool bleDisconnectCleanupPending` and perform `cleanupDirectWriteState` / `cleanupPartialWriteOnDisconnect` / `resetPipeWriteState` in `loop()` (where they are already single-task-safe). The advertising restart already uses exactly this deferral pattern (`bleRestartAdvertisingPending`); the cleanups must too. -- **Confidence:** High — call chains and callback task context confirmed against vendored `NimBLEServer.cpp`. - ---- - -## HIGH - -### #2 — `resetPipeWriteState()` on disconnect races the loop's pipe-frame processing (and bypasses the refresh guard) -- **Location:** `src/esp32_ble_callbacks.h:72`; `src/display_service.cpp:2137-2140` -- **Category:** race -- **Provenance:** Introduced by the NimBLE migration. -- **Observed:** `resetPipeWriteState()` (`pipeState = PipeWriteState{};` + clearing every `pipeReorder[i].occupied`) is called **unconditionally** in `onDisconnect` — outside the `if (epdRefreshInProgress)` else-branch — on the host task. -- **Why wrong / trigger:** After a disconnect, up to 32 queued `0x0081` frames may still be draining in `loop()` via `handlePipeWriteData`, which reads/mutates `pipeState` and memcpys payloads into `pipeReorder` slots. A non-atomic multi-word struct wipe racing that produces torn state: `pipeState.active` cleared mid-handler, half-zeroed window bookkeeping, or a reorder slot marked unoccupied while the loop is memcpying into it. Worst realistic outcome is corrupted/misrouted transfer state (combined with the `pipeState.partial` path, an inconsistent panel session) rather than a crash. It also defeats the refresh deferral: even when the rest of cleanup is deferred, the pipe state is still ripped out from the host task. -- **Solution:** Defer to the loop via the same pending flag as #1. If an immediate marker is needed, set only a single `volatile bool pipeAbortPending` that `handlePipeWriteData` checks. -- **Confidence:** High — code-confirmed. - -### #3 — `updatemsdata()` called from `onConnect` (host task) races the loop-task `updatemsdata()`: concurrent `std::vector` mutation + cross-task I2C/ADC -- **Location:** `src/esp32_ble_callbacks.h:51` (`onConnect` → `updatemsdata()`); `src/display_service.cpp:1474-1560`; `src/main.cpp:415-419` (60 s loop caller); `src/ble_init.cpp:244` (restart-path caller) -- **Category:** race (heap corruption) -- **Provenance:** Introduced by the NimBLE migration. -- **Observed:** `updatemsdata()` (a) polls I2C sensors (`pollSht40SensorsForMsd`, `pollBq27220ForMsd`) and the battery ADC (`readBatteryVoltage`, unguarded static cache at `display_service.cpp:1449-1451`), (b) writes shared globals `msd_payload[16]`, `mloopcounter`, `rebootFlag`, and a function-static `prev_msd_payload`, and (c) on the connected branch does `*advertisementData = BLEAdvertisementData();` then `setName/setFlags/setManufacturerData`, i.e. assignment and repeated `m_payload.insert(...)` on the `std::vector` inside global `globalAdvertisementData`. The loop task calls the same function every 60 s and from `esp32_restart_ble_advertising`. -- **Why wrong / trigger:** If a central connects while the loop is inside its periodic `updatemsdata()` (which includes a `stop(); … delay(50); start()` window that widens the overlap), two tasks concurrently assign/append to the same `std::vector` — undefined behavior, realistically heap corruption. Independently, the host-task I2C polls contend with the loop's own `Wire` traffic (GT911 touch, sensors) on a non-thread-safe bus, and the `readBatteryVoltage` static cache is read-modify-written from both tasks. -- **Solution:** In `onConnect`, set flags only (`rebootFlag = 0; esp32BleNotifySubscribed = false; msdUpdatePending = true;`) and let `loop()` run `updatemsdata()`. Note the connected-branch rebuild of `*advertisementData` is dead work anyway — it is never pushed via `setAdvertisementData()`; the restart path rebuilds fresh data itself (`display_service.cpp:1540-1550`). -- **Confidence:** High — call sites, the `std::vector` member, and both task contexts verified. - -### #4 — Replay protection accepts a repeat of the current-highest nonce counter -- **Location:** `src/encryption.cpp:128-155` (esp. `:136`) -- **Category:** crypto-logic / replay -- **Provenance:** Pre-existing (not the NimBLE port). -- **Observed:** - ```c - if (nonce_counter <= encryptionSession.last_seen_counter && counter_diff != 0) { - bool already_seen = false; - for (int i = 0; i < 64; i++) { if (replay_window[i] == nonce_counter) { already_seen = true; } } - if (already_seen) return false; - } - if (nonce_counter > encryptionSession.last_seen_counter) last_seen_counter = nonce_counter; - ``` - The seen-set membership test is gated by `counter_diff != 0`, so when `nonce_counter == last_seen_counter` the check is skipped and the function returns `true`. -- **Why wrong / trigger:** After any command with counter *K* (which sets `last_seen_counter = K` when it is the max), an attacker who re-sends that exact captured frame has `counter_diff == 0`, passes `verifyNonceReplay`, and — because the ciphertext/tag are identical — passes `aes_ccm_decrypt`, so the command re-executes. The most-recent authenticated command is therefore always replayable (re-trigger a config write, LED, deep-sleep, reboot…). Also hits at session start: `last_seen_counter` initializes to 0 (`:605`), so a first command with counter 0 is replayable. -- **Solution:** Treat `counter_diff == 0` as a replay — drop the `&& counter_diff != 0` so the equality case runs the seen-set check, or reject `counter_diff == 0` outright once any counter has been recorded. -- **Confidence:** High — code-confirmed. - -### #5 — AES-CCM nonce reused across command and response directions -- **Location:** `src/encryption.cpp:158-168` (`getCurrentNonce`), `:604` & `:689-690`, `:663-665` -- **Category:** crypto-logic -- **Provenance:** Pre-existing (not the NimBLE port). -- **Observed:** Command and response nonces are both built as `nonce = session_id[8] || counter_be[8]`, then `nonce_ccm = nonce_full[3..15]`, under the **same** `session_key`. The device response counter is set to 0 at auth (`:604`) and the client command counter likewise starts at 0; there is no direction bit in the nonce. -- **Why wrong / trigger:** The first encrypted command (counter 0) and first encrypted response (counter 0) yield an identical CCM nonce under an identical key. CCM keystream depends only on key+nonce (not the AD), so equal-counter messages in the two directions share keystream — XOR of the two plaintexts leaks, and CCM nonce reuse also weakens the CBC-MAC authenticity guarantee. Every overlapping counter value between the two independent counters is a reuse. -- **Solution:** Domain-separate the directions: reserve a direction bit/byte in the nonce, or derive two sub-keys (one per direction) from the session key, so device→client and client→device never share a (key, nonce) pair. -- **Confidence:** Med — device-side reuse is code-confirmed; full impact depends on the `py-opendisplay` client using the same single-key/counter convention (*needs verification against the client*). - ---- - -## MEDIUM - -### #6 — Long blocking operations inside NimBLE callbacks stall the host task -- **Location:** `src/esp32_ble_callbacks.h:51` (`onConnect`), `:66-72` (`onDisconnect`); `src/display_service.cpp:230-233` (`bbepSleep`+`delay(50)`), `:203-209` (`pwrmgmLockTake` spin-with-`delay(1)`); `src/touch_input.cpp:416-440` (`touchResumeAfterEpdRefresh`: settle delay + GT911 I2C reinit) -- **Category:** nimble-api-misuse -- **Provenance:** Introduced by the NimBLE migration. -- **Observed:** The disconnect path can execute `pwrmgmLockTake()` (blocks until the loop releases the lock — potentially spanning the tick's SPI ops), `bbepSleep + delay(50)`, `pwrmgm(false)` (I2C PMIC shutdown, pin writes), and `touchResumeAfterEpdRefresh`. `onConnect`'s `updatemsdata()` performs sensor I2C, ADC reads, and mDNS TXT updates. All on the NimBLE host task. -- **Why wrong / trigger:** The host task also services all GAP/GATT events. Blocking it hundreds of ms (or longer if `pwrmgmLockTake` waits on a busy loop) delays connection setup, MTU exchange, and subscription events, and risks host-level timeouts. NimBLE callbacks are expected to return promptly. -- **Solution:** Falls out of the #1/#3 fix — once callbacks are flag-only, the host task never blocks. -- **Confidence:** High — code-confirmed. - -### #7 — MTU advertised as 512 but any write of 257–512 bytes is silently discarded (no error response) -- **Location:** `src/ble_init.cpp:255` (`BLEDevice::setMTU(512)`); `src/esp32_ble_callbacks.h:97, 119-123` -- **Category:** error-handling -- **Provenance:** Introduced by the NimBLE migration (MTU/limit interaction). -- **Observed:** - ```cpp - if (value.length() > 0 && value.length() <= MAX_COMMAND_SIZE) { /* queue */ } - else if (value.length() > MAX_COMMAND_SIZE) { writeSerial("WARNING: Command too large, dropping"); } - ``` - `MAX_COMMAND_SIZE` is 256, but the ATT MTU (512) and the characteristic's default 512-byte max value accept larger writes, which are dropped with only a local serial log — the client's ATT write completes successfully and no NACK/notification is sent. -- **Why wrong / trigger:** A third-party client that negotiates MTU 512 and sends >256-byte frames (legal on the raw protocol) sees writes silently vanish and hangs waiting for acks. The `main.h:382-384` comment acknowledges the >256 B regression as "none known" — but the *silent* part is separately fixable. -- **Solution:** Emit a small error notification (e.g. `{0xFF, cmd, err}`), raise `MAX_COMMAND_SIZE` to the true max frame size, or cap advertised MTU nearer 259 so oversized writes can't be produced. (The full-queue drop at `:119-121` is lower risk — the per-frame ack/pipe-mask protocols give the client a recovery path.) -- **Confidence:** High — code-confirmed. - -### #8 — `0x0052` force-sleep tears down NimBLE while the client is still connected (no graceful disconnect) -- **Location:** `src/main.cpp:481-523` (force path); `src/device_control.cpp:740`; `src/esp32_ble_callbacks.h:85-127` -- **Category:** race / lifetime -- **Provenance:** Interacts with NimBLE `deinit(true)` semantics. -- **Observed:** Host command `0x0052` calls `enterDeepSleep(true, overrideSeconds)`. With `force==true`, the connected-client bail (`main.cpp:492`) is skipped, and execution proceeds to `epdSessionForceOff()` → advertising stop → `delay(200)` → `BLEDevice::deinit(true)` → `esp32_ble_clear_handles()`. No `disconnect()` of the peer is issued first. The command is drained in the loop task; the NimBLE host runs `onWrite` in a separate task. -- **Why wrong / trigger:** In the force path the link is still up, so the central can still be delivering WRITE_NR frames (each firing `onWrite`, which memcpys into `commandQueue`) at the moment the loop calls `deinit(true)`. `deinit(true)` frees the GATT objects and stops the host; a callback executing against those objects concurrently is a use-after-free window. `delay(200)` narrows but does not close it. Idle-path sleeps are safe (only when `getConnectedCount()==0`); this is specific to `0x0052`. -- **Solution:** In the force path, stop accepting new work and gracefully `pServer->disconnect(connHandle)`, poll `getConnectedCount()==0` (bounded wait) before `deinit(true)`; or disable the RX characteristic first. At minimum confirm NimBLE's `deinit(true)` internally quiesces the host task before freeing. -- **Confidence:** Med — force bypass + missing disconnect are code-confirmed; exact UAF depends on NimBLE-Arduino deinit/host-task synchronization (*needs verification*). - -### #9 — Replay counter is advanced by unauthenticated (pre-decrypt) data → cheap session-teardown DoS -- **Location:** `src/encryption.cpp:637-684` (`verifyNonceReplay` at `:640`, before `aes_ccm_decrypt` at `:663`) -- **Category:** crypto-logic / error-handling -- **Provenance:** Pre-existing. -- **Observed:** `decryptCommand` calls `verifyNonceReplay(nonce_full)` — which mutates `last_seen_counter` and `replay_window` — *before* the tag is verified. The `session_id` gating the nonce (`:118`) is transmitted in the clear as the first 8 bytes of every command nonce. -- **Why wrong / trigger:** An attacker who sniffs one command learns `session_id`, then forges frames with that id and an in-window counter (`last_seen ± 32`). Each forged frame passes `verifyNonceReplay` (advancing `last_seen_counter`) then fails CCM auth, incrementing `integrity_failures`; after 3, the session force-clears (`:678-682`). So 3 sniff-and-forge packets tear down an authenticated session and/or push `last_seen_counter` ahead of legitimate in-flight commands (which then fall outside the ±32 window and get rejected). Low-cost remote DoS. -- **Solution:** Do not persist replay-window/counter state until *after* successful authenticated decryption (advance only on the success path after `:666`); rate-limit / deprioritize the integrity-failure teardown so unauthenticated traffic can't cheaply reset a session. -- **Confidence:** Med — logic code-confirmed; exploitability depends on sniffing the cleartext `session_id`, which the protocol exposes. - -### #10 — Seeed/IT8951 refresh always reports success — TCON busy-timeout is discarded -- **Location:** `src/display_seeed_gfx.cpp:123-127` (`seeed_gfx_wait_refresh`); consumed at `src/display_service.cpp:510, 2078` -- **Category:** error-handling -- **Provenance:** Pre-existing (Seeed display path). -- **Observed:** - ```cpp - bool seeed_gfx_wait_refresh(int timeout_sec) { (void)timeout_sec; delay(300); return true; } - ``` - The library sets a real failure flag (`lib/Seeed_GFX/Extensions/Tcon.cpp:32`: `opnd_seeed_tcon_busy_timed_out = true;`) and exposes `opnd_seeed_tcon_busy_timeout_occurred()` (`display_seeed_gfx.cpp:46`), but `seeed_gfx_wait_refresh` never consults it. -- **Why wrong / trigger:** `waitforrefresh()` routes to this function for the Seeed driver, returning `true` unconditionally, so `refreshSuccess` in `directWriteFinishAndRefresh` (`:2078`) is always true even when the IT8951 stalled. The device then commits `displayed_etag = newEtag` (`:2099`) and replies `{0x00,0x73}` success. A later partial-update diff bases against an etag that doesn't match the physical panel → corrupt partial refresh, failure invisible to the client. -- **Solution:** Return `!opnd_seeed_tcon_busy_timeout_occurred()` (the flag is reset at refresh start by `seeed_gfx_epaper_begin`, `:106`) and honor `timeout_sec` instead of a fixed `delay(300)`. -- **Confidence:** High — code-confirmed including the library set-site. - -### #11 — PIPE full-frame stall leaves a zombie `pipeState.active`; a later END drives SPI/refresh into a powered-off panel -- **Location:** `src/main.cpp:346-352` (watchdog) with `src/display_service.cpp:356-365` (`checkPartialWriteTimeout` / `resetPipeWriteState`), `:2353` (`handlePipeWriteStart`), `:2522` (`handlePipeWriteEnd`), `:1773` (`directWriteActivatePanel`) -- **Category:** lifetime / resource-leak -- **Provenance:** Pre-existing (interacts with disconnect cleanup #2). -- **Observed:** The only stall guard covering a PIPE *full-frame* transfer is the direct-write watchdog (`cleanupDirectWriteState(true)` when `directWriteDuration > 900000UL`). A PIPE full-frame transfer sets **both** `directWriteActive` and `pipeState.active`, but `checkPartialWriteTimeout` only clears `pipeState` when `pipeState.partial` (`:363`). -- **Why wrong / trigger:** On a 15-min stall, `cleanupDirectWriteState(true)` resets `directWrite*` and powers the panel off, but `pipeState.active` stays true (nothing calls `resetPipeWriteState()`). Before the next `0x0080` START self-heals it, a `0x0082` END is accepted (`handlePipeWriteEnd` has no `directWriteActive` check), completeness passes (both byte counters 0 and equal), and `directWriteFinishAndRefresh` calls `bbepRefresh()` + `waitforrefresh(60)` on a rail that is powered **off** — a spurious refresh plus a ~60 s blocking hang returning failure. -- **Solution:** In the direct-write watchdog (or a dedicated pipe timeout), also `if (pipeState.active) resetPipeWriteState();` when the stall fires; equivalently gate `handlePipeWriteEnd`'s refresh on `directWriteActive` for the non-partial branch. -- **Confidence:** High for the zombie state; Med for the 60 s-hang manifestation (depends on END arriving before a new START). - -### #12 — Deep sleep can silently truncate active buzzer playback (no "buzzer active" gate) -- **Location:** `src/buzzer_control.cpp:130` (private `s_buzzer.active`, no exported accessor); `src/main.cpp:380-405` (deep-sleep gate), `enterDeepSleep()` -- **Category:** timing / logic -- **Provenance:** Pre-existing. -- **Observed:** The `workInFlight` gate (`main.cpp:380`) does not include buzzer state, and `enterDeepSleep()` never calls `buzzer_stop_internal()`. A melody may run up to `kBuzzerMaxTotalMs = 30000` ms, but the battery idle-hold default is ~10 s. -- **Why wrong / trigger:** If a melody is triggered over WiFi/LAN, or the BLE client disconnects mid-playback (so `getConnectedCount()==0`), the idle window can expire while `s_buzzer.active` is still true. `buzzerService()` (`main.cpp:422`) runs only *after* the non-returning `enterDeepSleep()` call at `:404`, so the melody is cut off and the buzzer enable pin / PWM are torn down abruptly. -- **Solution:** Export `bool buzzerIsActive(void)` and add it to `workInFlight`; and/or call `buzzer_stop_internal()` at the top of `enterDeepSleep()` so the buzzer is deterministically silenced before sleep. -- **Confidence:** High — code-confirmed across both files. - ---- - -## LOW - -### #13 — `esp32_restart_ble_advertising`: unchecked start result, pending flag cleared before success, redundant double-restart -- **Location:** `src/ble_init.cpp:228-246` -- **Category:** error-handling / logic -- **Observed:** `bleRestartAdvertisingPending = false;` then `delay(100); BLEDevice::startAdvertising(); updatemsdata();`. The flag is cleared before `startAdvertising()`, whose boolean result is ignored; `updatemsdata()` then does a second `stop(); … delay(50); start()` cycle (its dedupe never fires — see #14). Verified against NimBLE 2.x that `m_advertiseOnDisconnect` defaults to **false**, so this function is the only thing that restarts advertising. -- **Why wrong / trigger:** If `startAdvertising()` fails (transient controller state right after disconnect), the flag is already cleared and there is no retry until the 60 s idle MSD tick — the radio can stay dark up to a minute. The `delay(100)`+`delay(50)` also stall the loop ~150 ms per disconnect. -- **Solution:** Clear `bleRestartAdvertisingPending` only after `start()` returns true; drop/soften the pre-delay; call `updatemsdata()` (which starts advertising with fresh data) *instead of* `startAdvertising()`. -- **Confidence:** High (code); failure frequency inferred (*needs hardware verification*). - -### #14 — `updatemsdata` payload dedupe is dead code → advertising stop/start churn every 60 s -- **Location:** `src/display_service.cpp:1493-1496` (statusByte includes `mloopcounter` nibble), `:1523-1528` (memcmp dedupe), `:1558-1559` (`mloopcounter++`) -- **Category:** logic -- **Observed:** `statusByte` embeds `(mloopcounter & 0x0F) << 4`, and `mloopcounter` increments every call, so consecutive payloads always differ in the top nibble and `memcmp(prev_msd_payload, msd_payload, 16) == 0` can never be true. -- **Why wrong / trigger:** The dedupe branch (and its NRF twin at `:1505-1510`) is unreachable; every idle 60 s tick runs `stop(); … delay(50); start()` — a ≥50 ms advertising gap + loop stall each minute that the dedupe intended to avoid. Cosmetic reliability cost, not a correctness bug (the rolling nibble is deliberate for scan distinguishability). -- **Solution:** Compare payloads excluding the sequence nibble, or only restart advertising when the non-rolling bytes change (prefer `setAdvertisementData` without stop/start). -- **Confidence:** High — code-confirmed. - -### #15 — Duplicated struct/constant definitions across translation units — silent-divergence (ODR) hazard -- **Location:** `src/esp32_ble_callbacks.h:17-28` (`#ifndef COMMAND_QUEUE_SIZE` fallback 33/256 + `CommandQueueItem`); `src/main.h:384-399` (authoritative); `src/communication.cpp:50-57` (local `chunked_write_state_t` with literal `buffer[4096]` vs `MAX_CONFIG_SIZE`), `:65-76` (local `ResponseQueueItem data[512]`, `RESPONSE_QUEUE_SIZE_LOCAL = 10`) -- **Category:** buffer / logic -- **Observed:** The BLE callback header carries its own `#ifndef` copies of the queue geometry, and `communication.cpp` re-declares `chunked_write_state_t` and `ResponseQueueItem` locally with literal sizes. All values currently agree. -- **Why wrong / trigger:** Any future change to `MAX_CONFIG_SIZE`, `MAX_RESPONSE_SIZE`, or queue sizes in one place but not the others compiles cleanly and produces layout-mismatched access to the same global — memcpy past the real buffer, i.e. memory corruption with no diagnostic. The `#ifndef` fallback is the sharpest edge: a TU including it without `main.h` first gets a *different* `CommandQueueItem` layout. -- **Solution:** Move the queue structs/sizes into one shared header included by both; replace the `communication.cpp` literals with the named macros; add `static_assert(sizeof(...))` cross-checks. -- **Confidence:** High — duplication confirmed and currently matches. - -### #16 — `0x0052` timer-sleep path sends no ACK before deinit -- **Location:** `src/device_control.cpp:717-740` -- **Category:** error-handling / logic -- **Observed:** The D-FF branch sends OK before power-off (`:721-722`) and reject branches send error frames (`:729-736`), but the normal battery timer-sleep branch calls `enterDeepSleep(true, overrideSeconds)` (`:740`) with no success response; the subsequent `deinit(true)` destroys any queued ACK. -- **Why wrong / trigger:** A host issuing `0x0052` with a duration override gets no confirmation the device accepted it, inconsistent with the reject cases (which do respond). -- **Solution:** `sendResponse({0x00,0x52,0x00,0x00})` + brief flush (as the D-FF branch does at `:721-723`) before `enterDeepSleep`, or document intentional silent sleep. -- **Confidence:** High — code-confirmed. - -### #17 — `epdSessionForceOff()` in the force path is not gated on `epdRefreshInProgress` -- **Location:** `src/main.cpp:492-513` -- **Category:** ordering -- **Observed:** On the force path the early bails at `:492`/`:501` are skipped and execution reaches `epdSessionForceOff()` (`:513`) unconditionally. The idle path only reaches `enterDeepSleep` when `workInFlight` (including `epdRefreshInProgress`, `:384`) is false, so it can never power off mid-refresh — the force path has no such guard. -- **Why wrong / trigger:** If a refresh were ever in progress when `0x0052` is handled, `epdSessionForceOff()` would cut the EPD rail mid-refresh (corrupt/partial image, wedged controller). Currently `0x0052` is drained in the same loop pass as refreshes (both in the main task), so no refresh is concurrently in progress — defense-in-depth, not a live bug. -- **Solution:** Guard `epdSessionForceOff()` with `if (epdRefreshInProgress) { defer/wait }` so the invariant holds for future callers/contexts. -- **Confidence:** Med — missing guard confirmed; benign under current single-task execution. - -### #18 — Button GPIO ISR has no debounce and reads the pin inside the ISR -- **Location:** `src/device_control.cpp:517-537` (`handleButtonISR`/`buttonISR`, `attachInterruptArg(... CHANGE)` at `:616`); interacts with `src/main.cpp:187-230` (`pollActivity`) -- **Category:** race / logic -- **Observed:** The ISR fires on every edge, does `digitalRead()`, and on a state change bumps `press_count` and sets `buttonEventPending`/`lastChangedButtonIndex` (volatile, consumed under `noInterrupts()` at `:431`). No debounce interval. -- **Why wrong / trigger:** A bouncing contact produces a burst of edges → multiple `press_count` increments per press and repeated activity; via `dynamicreturndata` this stamps `lastActivityMs` (`main.cpp:213`), holding the wake/idle window open longer than intended and reporting an inflated press count. Does not cause spurious sleep, only inaccurate counts / slightly delayed sleep. -- **Solution:** Per-button debounce (ignore edges within N ms, tracked in `ButtonState`), or latch in the ISR and validate in `processButtonEvents`. -- **Confidence:** High — no debounce confirmed. - -### #19 — Config packet parser ignores the on-wire length field and trusts `sizeof(struct)` -- **Location:** `src/config_parser.cpp:288-609` (loop header `:289-291`; each case advances by `sizeof(struct …)`) -- **Category:** logic / buffer (bounded) -- **Observed:** The loop reads a 2-byte header (`offset++` then `packetId = configData[offset++]`) but discards the first byte, then advances `offset += sizeof(struct SystemConfig)` etc., using the compiled struct size rather than any length carried in the packet. -- **Why wrong / trigger:** If a stored/BLE-delivered body's length differs from the firmware's `sizeof(struct)` (schema drift, malformed input, an older/newer toolbox), parsing desyncs — following bytes are reinterpreted as a new `[len][id]` header, silently mis-loading later packets into `globalConfig` (pins, flags, security_config). **Not** memory-unsafe — every memcpy is guarded by `offset + sizeof(...) <= configLen - 2` — but it can load attacker-influenced garbage. The trailing outer CRC (`:616`) is warn-only and does not reject. -- **Solution:** Parse and honor the packet's declared length to advance `offset`, and validate it equals the expected struct size for known IDs before memcpy; treat a mismatch as a hard parse error. -- **Confidence:** High that the length byte is unused; Med on real-world triggerability (*verify against the wire format the toolbox emits*). - -### #20 — Config reload mutates `globalConfig` in the BLE callback task while `loop()` reads it (nRF only) -- **STATUS: RESOLVED 2026-07-27** by Phase 3 of - `docs/PLAN_BLE_TRANSPORT_ABSTRACTION_2026-07-27.md`, which applied this - finding's own first proposed solution: nRF now marshals command processing to - the main loop as ESP32 does. The nRF write callback pushes onto the shared RX - ring and `serviceBleRx()` dispatches from `loop()`, so `loadGlobalConfig()` and - every `globalConfig` reader run on one task. No critical section or - double-buffer swap was needed. `src/ble_init.cpp` no longer exists. -- **Location:** `src/config_parser.cpp:263-264` (`memset`/repopulate in `loadGlobalConfig`), reached via `handleWriteConfig`/`reloadConfigAfterSave`; nRF dispatch at `src/ble_init.cpp:160` -- **Category:** race -- **Provenance:** Pre-existing (Bluefruit path), **not** the NimBLE port — but in-scope for the callback-vs-loop concern. -- **Observed:** On nRF, `imageDataWritten` (and thus `loadGlobalConfig`, which zeroes and rebuilds `globalConfig`) runs in the Bluefruit BLE callback context, while `loop()` concurrently reads `globalConfig.leds[...]`, `globalConfig.displays[...]`, button state (e.g. `processLedFlash`, `flashLed`). ESP32 is unaffected (commands drain in `loop()`). -- **Why wrong / trigger:** A config write arriving mid-flash lets `loop()` observe a half-`memset`/half-rebuilt `globalConfig` (e.g. `led_count` changed while `leds[]` is being overwritten) → inconsistent reads / bad pin writes. -- **Solution:** On nRF, marshal command processing to the main loop as ESP32 does, or guard `globalConfig` mutation/read with a critical section / double-buffer swap. -- **Confidence:** Med — call context confirmed; exact interleaving not instrumented (*needs verification on nRF scheduler specifics*). - -### #21 — Non-atomic image-write flags read from the NimBLE host task (benign log race) -- **Location:** `src/esp32_ble_callbacks.h:92` → `src/display_service.cpp:1638-1653` (`imageWriteLogQuietFrame`); `directWriteActive` defined plain at `src/main.h:172` -- **Category:** race -- **Observed:** `onWrite` (host task) calls `imageWriteLogQuietFrame`, which reads `(directWriteActive || partialCtx.active || pipeState.active) && imgLogChunks >= 1` — all mutated by `loop()` with no atomics/locking and not `volatile`. -- **Why wrong / trigger:** Genuine concurrent read (host) vs write (loop) of non-atomic multi-byte state, but impact is confined to log suppression — a torn read only mis-logs a line. No memory-safety consequence (the payload is copied into the ring, not shared). -- **Solution:** Acceptable as-is; if desired, compute the quiet flag in `loop()` only, or snapshot it. Worth a comment noting the deliberate benign race. -- **Confidence:** High that it is a data race; High that impact is benign. - -### #22 — Seeed keep-alive WARM state is inconsistent with a slept controller -- **Location:** `src/display_service.cpp:2075-2087` (refresh tail), `:280-295` (`epdSessionRelease`), `:2291` (WARM comment) -- **Category:** logic -- **Observed:** On the Seeed path the refresh tail calls `seeed_gfx_direct_sleep()` (`:2079`) unconditionally, then `cleanupDirectWriteState(false)` → `epdSessionRelease(true)`. With keep-alive enabled, Release sets `PWR_WARM` documented as "controller stays AWAKE (no bbepSleep)" — but the Seeed TCON was just slept. -- **Why wrong / trigger:** The "warm = controller awake, rail up" invariant doesn't hold for Seeed: the rail stays powered (draws current) while the TCON sleeps. Not a correctness fault — the next push runs `seeed_gfx_direct_write_reset` with `seeed_gfx_hw_initialized` still true → `g_seeed_epaper.wake()` (`display_seeed_gfx.cpp:157`), the right call for a slept-but-powered TCON. If Release takes the force-off branch instead, `seeed_gfx_direct_sleep()` is called twice (idempotent, harmless). -- **Solution:** Skip `seeed_gfx_direct_sleep()` at `:2079` when keep-alive will hold WARM, or document that Seeed WARM means "rail up, TCON asleep, re-wake on next push." Consider forcing keep-alive off for Seeed if the warm-rail draw isn't worth it. -- **Confidence:** Med — behavior confirmed; whether it matters depends on Seeed power budget (*needs verification*). - -### #23 — Blocking SHT40 measurement/retry runs inside `loop()` -- **Location:** `src/sensor_sht40.cpp:79` (`delay(SHT40_MEASURE_DELAY_MS)`), `:118-156` (`read_sht40_sample` 2-pass × up-to-3-address retry with `Wire.end()`/`begin()`+`delay(2)`) -- **Category:** timing / error-handling -- **Observed:** A healthy sensor succeeds in ~12 ms, but if the preferred address doesn't ACK on pass 1, the code re-inits the bus and retries all addresses, stacking `delay()`s and possible `Wire.requestFrom` timeouts — hundreds of ms of loop stall that delays BLE command/response servicing. Bounded (every 30 s). -- **Solution:** Cap retries, avoid the full `Wire.end()/begin()` teardown on the hot path, or convert to a state-machine tick like the buzzer. -- **Confidence:** Med — inferred from retry structure; exact stall depends on Wire timeout config. - -### #24 — Buzzer global-cap comment says "5 s" but the constant is 30 s -- **Location:** `src/buzzer_control.cpp:17` (`kBuzzerMaxTotalMs = 30000u`), `:144`, `:167` (comments say "5 s") -- **Category:** logic (doc/constant mismatch) -- **Observed:** No functional bug — the code uses the constant consistently and the comparisons are rollover-safe subtraction — but the stale "5 s" comments mislead about the real 30 s ceiling. -- **Solution:** Update the comments to 30 s (or make the constant match intent). -- **Confidence:** High. - -### #25 — mDNS service re-registered on every WiFi reconnect without teardown -- **Location:** `src/wifi_service.cpp:73-83`, `:249-257` (`restartLanService`), `:177` / `:256` callers -- **Category:** resource-leak -- **Observed:** `restartLanService()` calls `MDNS.begin()` + `MDNS.addService(...)` on each reconnect with no matching `MDNS.end()`. -- **Why wrong / trigger:** Repeated `begin`/`addService` across reconnect cycles can duplicate the advertised record or leak responder state (build-dependent); frequently-reconnecting devices may accumulate records. -- **Solution:** `MDNS.end()` before re-`begin()`, or add the service once and only update TXT records on reconnect. -- **Confidence:** Med — depends on ESPmDNS idempotency (*needs verification against the library version*). - ---- - -## INFORMATIONAL - -### #26 — Advertising payload sits at exactly 31 bytes with all builder return values ignored -- **Location:** `src/ble_init.cpp:297-308`; `src/display_service.cpp:1535-1550`; vendored `NimBLEAdvertisementData.cpp:39-47, 269-280` -- **Observed:** Payload = name (10 B) + flags (3 B) + MFG data (18 B) = **31 bytes**, exactly `BLE_HS_ADV_MAX_SZ`. NimBLE 2.x `setManufacturerData`/`setName` *append* via `addData`, which returns `false` and drops the field once `size + length > 31`; every call site discards the result. -- **Why wrong / trigger:** Zero headroom. Any future growth (7-char ID, an extra dynamic byte, one more AD field) makes the last `set*` fail *silently* → device advertises without manufacturer data (id 9286), breaking HA discovery, with nothing logged. Also note NimBLE 2.x `set*` on a persistent `NimBLEAdvertisementData` appends rather than replaces; today this is safe only because every path rebuilds from scratch — worth a comment so a future "just update the MSD field" edit doesn't reintroduce payload growth. -- **Solution:** Check the boolean results of `setName/setFlags/setManufacturerData` and log/assert on failure. -- **Confidence:** High — confirmed against the vendored library. - -### #27 — Verified clean: callback lifetime, binary-value handling, SPSC atomics, notify path (positive finding) -- **Location:** `src/ble_init.cpp:261-263, 286`; `src/main.cpp:409-410, 523-524`; vendored `NimBLEServer.cpp`, `NimBLECharacteristic.cpp`, `NimBLEAttValue.h` -- **Observed / conclusion:** The `setCallbacks` hazard class was audited and is clean: `staticServerCallbacks`/`staticCharCallbacks` are static; `setCallbacks(&staticServerCallbacks, false)` correctly prevents `~NimBLEServer` from deleting the static, and NimBLE 2.x `NimBLECharacteristic::setCallbacks` takes no ownership flag and never deletes `m_pCallbacks`, so `pTxCharacteristic->setCallbacks(&staticCharCallbacks)` is safe. `esp32_ble_clear_handles()` runs immediately after `deinit(true)` on the same task, so there is no window where the loop uses a freed `pServer`/`pTxCharacteristic`. The `onWrite` path uses `NimBLEAttValue` deep-copy with `.c_str()/.length()`, so the `84c322e` `0x00`-truncation fix is complete — no remaining Arduino-`String` conversions of binary RX payloads. The command-queue SPSC atomics (RELEASE publish / ACQUIRE consume) are correctly paired; `flushResponseQueueToBle`'s `notify(data,len)` (immediate mbuf copy) with stop-on-false backpressure is sound. **Caveat:** the response queue's non-atomic indices are safe only because it is produced and consumed exclusively on the loop task — an undocumented invariant that a single host-task `sendResponse` call would break. -- **Confidence:** High — all code-confirmed. - -### #28 — RTC_DATA_ATTR wake state cannot distinguish a hidden mid-cycle reset from a true cold boot -- **Location:** `src/main.cpp:83-92, 147-153`; `src/main.h:317-318`; `src/wake_button.cpp:28` -- **Observed:** `deep_sleep_count`/`woke_from_deep_sleep` are `RTC_DATA_ATTR`, but the bootloader reloads RTC segments from the app image on every reset *except* a deep-sleep wake. A panic/WDT/brownout during an awake cycle lands in the NORMAL BOOT branch with `deep_sleep_count==0`, indistinguishable from first boot → re-runs `initDisplay()` (boot-screen redraw) and re-arms the min-wake window, spending extra energy when it should have resumed quietly. -- **Note:** Already captured in `docs/FINDINGS_DEEP_SLEEP_WAKE_BOOT_SCREEN_2026-07-07.md`. A durable fix needs a non-RTC source (NVS) or a magic-word validity check that survives non-wake resets. -- **Confidence:** High — matches documented behavior. - -### #29 — Non-reentrant static scratch buffers in the crypto/config path -- **Location:** `src/encryption.cpp:662` (`static uint8_t decrypted_with_length[512]`), `:694` (`static uint8_t payload_with_length[513]`); `src/config_parser.cpp:85, 159` (`static config_storage_t config`) -- **Observed:** Safe today — command processing is serialized in `loop()` on ESP32 and each function fully consumes its buffer before returning; sizes are correct (frames capped at `MAX_COMMAND_SIZE` 256). Would break if ever called re-entrantly or from two contexts (see #20). -- **Solution:** No action now; keep the single-consumer invariant. If nRF moves to direct-callback dispatch, convert to caller-provided/stack buffers. -- **Confidence:** High — informational. - -### #30 — Buzzer state machine has no locking but is currently single-threaded -- **Location:** `src/buzzer_control.cpp:129-145` (`s_buzzer`); dispatch `src/communication.cpp:652-655`, `src/main.cpp:332` -- **Observed:** `s_buzzer` is shared mutable state touched by `handleBuzzerActivate()` (stop + memcpy + re-init) and `buzzerService()`, both currently on the loop task (ESP32 enqueues; WiFi path is also loop-side). No live race — the "new melody mid-playback" case is handled (preempt via `buzzer_stop_internal()` before the memcpy). Called out because a future direct-from-callback invocation would race with zero synchronization. -- **Solution:** Keep buzzer command handling on the loop task only; document the single-thread invariant near `s_buzzer`. -- **Confidence:** High — dispatch path confirmed. - -### #31 — `fb_byte_size()` / Seeed chunk path dereference `displays[0]` without a `display_count` guard -- **Location:** `src/display_seeed_gfx.cpp:87-94` (`fb_byte_size`), `:133-140`, `:166-177` -- **Observed:** `fb_byte_size()` reads `globalConfig.displays[0].pixel_width/height` unconditionally; `seeed_gfx_panel_is_4gray()` guards `display_count < 1` but these do not. Only reachable with a misconfigured/empty display config on a Seeed build (all real entry points require a configured display) — defensive only. -- **Solution:** Early-return / zero-size when `display_count < 1`, matching `seeed_gfx_prepare_hardware` (`:97`). -- **Confidence:** High — low practical impact. - -### #32 — Touch loops rely on the `count <= 4` invariant without local clamping -- **Location:** `src/touch_input.cpp:471-559` (`prior_rt[4]`/`s_touch_rt[4]`), `:589-731`, `:429-443` -- **Observed:** Loops iterate `i < globalConfig.touch_controller_count` and index size-4 arrays with no `i < 4` clamp, unlike `touch_detach_all_configured_ints` (`:146`) which clamps. Safe today because `config_parser.cpp:396` caps `touch_controller_count` at 4; would become an OOB write if that cap ever regresses. -- **Solution:** Add `&& i < 4` for defense-in-depth, matching the already-clamped sibling. -- **Confidence:** High — not currently a bug; fragility note. - ---- - -## Recommended remediation order - -1. **#1 (Critical) + #2, #3, #6 (High/Medium) — single fix.** Convert all NimBLE server callbacks (`onConnect`, `onDisconnect`) to **flag-only**, servicing the flags from `loop()`, exactly as `bleRestartAdvertisingPending` already does. One change closes the SPI/rail teardown race, the pipe-state wipe race, the shared-`BLEAdvertisementData` vector race, and the host-task blocking. This is the highest-leverage fix in the report. -2. **#4, #5, #9 (High/Medium crypto).** Fix replay equality (`#4`, one-line), then direction-separate the CCM nonce (`#5`), then move replay-state advancement to the post-auth path (`#9`). Coordinate `#5` with the `py-opendisplay` client (protocol-level change). -3. **#8 (Medium).** Graceful disconnect before `deinit(true)` on the `0x0052` force path. -4. **#10, #11, #12 (Medium).** Honor the Seeed TCON timeout flag; clear zombie `pipeState` on stall; gate/silence the buzzer before deep sleep. -5. **Low / Informational.** Address opportunistically; **#15** (shared-header + `static_assert`) and **#26** (check advertising-builder results) are cheap traps worth closing early. - -## Methodology / caveats -- Five agents reviewed disjoint file sets (core BLE runtime; power/sleep/wake; display/panel; config/crypto/device-control; peripherals); `main.cpp` was intentionally double-covered. Each read the actual source; the BLE core also read the vendored `NimBLE-Arduino` 2.x library to confirm callback task context and ownership semantics. -- All three of the top races were independently corroborated by multiple agents' confirmation that the ESP32 `onWrite` path is a clean SPSC enqueue and that display/config/buzzer work is single-threaded in `loop()` — which is precisely what makes the *non*-`onWrite* callbacks (`onConnect`/`onDisconnect`) the exception that breaks the model. -- Items tagged *needs verification* (#5 client-side, #8 NimBLE deinit quiescing, #19 wire format, #20 nRF scheduler, #22 power budget, #25 ESPmDNS) depend on facts outside this repo's source and should be confirmed before or alongside the fix. diff --git a/docs/IMPLEMENTATION_EPD_KEEPALIVE_CONFIG_2026-07-13.md b/docs/IMPLEMENTATION_EPD_KEEPALIVE_CONFIG_2026-07-13.md deleted file mode 100644 index 900460b..0000000 --- a/docs/IMPLEMENTATION_EPD_KEEPALIVE_CONFIG_2026-07-13.md +++ /dev/null @@ -1,106 +0,0 @@ -# Implementation Summary: Configurable EPD Keep-Alive (screen_timeout_seconds) - -> 2026-07-13, branch `feat/less-latency`. Plan of record: -> `PLAN_EPD_KEEPALIVE_CONFIG_2026-07-13.md`. Status: **implemented as planned**, -> both target builds verified. - -## What was implemented - -The EPD panel keep-alive window (how long the panel stays powered in `PWR_WARM` -after a successful refresh) changed from a hardcoded 30 s (`EPD_KEEPALIVE_MS`) to a -per-device config value: - -| screen_timeout_seconds | Effective keep-alive window | -|---|---| -| 0 (default; old blobs/factory) | none — panel powers off immediately after refresh (matches `main`) | -| 1–30 | value × 1000 ms | -| 31–255 | clamped to 30 000 ms (`EPD_KEEPALIVE_MAX_S`) | -| any, on an AXP2101 board | forced to 0 (safety override); logged when a non-zero value is suppressed | - -The `screen_timeout_seconds == 0` case is handled **explicitly** (not via the tick -timer): `epdKeepAliveWindowMs()` returns 0 and `epdSessionRelease()`'s pre-existing -`window == 0` branch powers the panel down synchronously under `pwrmgmLock`, with -the controller slept before the rail cut. No new shutdown code was needed; the panel -never transits a zero-length `PWR_WARM`. - -## Code changes (all in `src/`) - -1. **`structs.h:61-64`** — carved `uint8_t screen_timeout_seconds` out of - `PowerOption.reserved[5]` → field + `reserved[4]`. Packed struct size unchanged, - so the fixed-size 0x04 `memcpy` in `config_parser.cpp` needs no change and old - persisted blobs read 0 (feature off). Same carve-out pattern as - `min_wake_time_seconds`. -2. **`display_service.h:16`** — `#define EPD_KEEPALIVE_MS 30000` replaced by - `#define EPD_KEEPALIVE_MAX_S 30` (hard cap, clamped not rejected). The previous - comment's "hard cap ~60 s" claim was never enforced; the clamp now makes the cap - real, at 30 s. -3. **`display_service.cpp:182-197`** — `epdKeepAliveWindowMs()` rewritten: AXP2101 - sensor scan first (override retained; PMIC warm idle draw unmeasured), returning 0 - and logging - `[EPD session] AXP2101 present - keep-alive forced off (screen_timeout_seconds ignored)` - only when it suppresses a non-zero configured value (quiet in the default-0 case; - at most once per release otherwise). Non-AXP2101 path: - `min(screen_timeout_seconds, EPD_KEEPALIVE_MAX_S) * 1000`. -4. **`communication.cpp:32-38`** — live-disable hardening in - `reloadConfigAfterSave()`: after a successful config reload, if - `screen_timeout_seconds == 0 && epdSessionIsWarm()` → `epdSessionForceOff()`, so - disabling takes effect immediately instead of after the stale ≤30 s deadline. - Conditional on purpose — a normal config save never tears down a warm panel. -5. **`config_parser.cpp:686`** — boot diagnostic added next to the sleep-flags - prints: `Screen Timeout: s (EPD keep-alive; 0 = off immediately after refresh)` - (enables log-based verification, mirroring the sleep_flags bit-0 precedent). -6. **Stale "30 s" comments fixed** (comments only): `main.cpp:263` (tick call site), - `main.cpp:488-495` (`enterDeepSleep` block — now "min(configured window, - idle-hold)"), `main.h:191` and `display_service.cpp:45` (`EPD_KEEPALIVE_MS` → - `EPD_KEEPALIVE_MAX_S`), `device_control.cpp:102-103` ("reconnect < 30 s" → - "reconnect within the window"). - -## Docs changes - -- **`epd-panel-power-session.md`** — §2.1 constant updated; §4 rewritten around the - config-sourced window (source/clamp/0-default semantics, AXP2101 override + exact - log line, actual `epdKeepAliveWindowMs()` listing); new §4 subsection - "Live-disable hardening (config reload)"; §5 deep-sleep permutation table, §6 - results, and §7 residual-behavior notes reworded from "30 s" to "the configured - keep-alive window (≤30 s)". -- **`architecture-deep-sleep-power-buttons.md`** — timer table gained an - "EPD keep-alive window" row (`structs.h:61`, uint8 s, default 0 = off, 30 s clamp, - AXP2101 forced-0), plus a prose note that on battery ESP32 the effective warm time - is `min(keep-alive window, idle-hold)` because `enterDeepSleep()` always calls the - idempotent `epdSessionForceOff()`. -- **`PLAN_EPD_KEEPALIVE_CONFIG_2026-07-13.md`** — plan of record saved into docs. - -## Deviations from the plan - -- `extern struct GlobalConfig globalConfig;` in `communication.cpp` was declared - *below* `reloadConfigAfterSave()`; the declaration was moved above the function - (compilation necessity, no behavioral change). -- Two additional stale keep-alive comments found by grep and fixed beyond the three - the plan listed (`display_service.cpp:45`, `device_control.cpp:103`). - -Everything else matches the plan verbatim, including the exact log/diagnostic -strings. - -## Build verification - -`pio run` — both environments compile clean (verified twice: by the implementing -agent and independently afterward): - -| Environment | Result | -|---|---| -| `nrf52840custom` (nRF52) | SUCCESS (only pre-existing `-Wmaybe-uninitialized` warning in `boot_screen.cpp`, unrelated) | -| `esp32-s3-N16R8` (ESP32-S3) | SUCCESS | - -## On-hardware verification (pending) - -Steps 2–7 of the plan's Verification section require hardware: default/0 immediate -off, enabled window (`off in ms` → `WARM re-acquire` → `keep-alive expired`), -clamp at 120 → 30 000 ms, live disable via config write, ESP32 battery deep-sleep -sanity, and the AXP2101 override log. - -## Follow-up (out of scope) - -- **Toolbox (`opendisplay.org` repo)**: `config.yaml` must expose the new - `screen_timeout_seconds` byte in the 0x04 power_option packet (as was done for - `min_wake_time_seconds` / sleep_flags bit 0). Until then the default is 0/off and - the warm-reconnect latency win cannot be enabled from the toolbox. diff --git a/docs/IMPLEMENTATION_WAKE_ON_BUTTON_2026-07-12.md b/docs/IMPLEMENTATION_WAKE_ON_BUTTON_2026-07-12.md deleted file mode 100644 index f143c3e..0000000 --- a/docs/IMPLEMENTATION_WAKE_ON_BUTTON_2026-07-12.md +++ /dev/null @@ -1,153 +0,0 @@ -# Implementation Summary: Wake-on-Button-Press from Deep Sleep - -**Date:** 2026-07-12 **Branch:** `feat/button-wake` (from `feat/pipe-partial`) -**Plan:** `docs/PLAN_WAKE_ON_BUTTON_2026-07-12.md` -**Build status:** all five environments compile clean — `esp32-N4` (classic), -`esp32-s3-N16R8`, `esp32-c3-N4`, `esp32-c6-N4`, `nrf52840custom` — with no -warnings in project sources. - -## What was implemented - -### New module: `src/wake_button.h` / `src/wake_button.cpp` - -Follows the `power_latch.cpp` pattern (whole implementation in -`#if defined(TARGET_ESP32)`, no-op stubs otherwise). Two functions: - -- **`armButtonWakeSources()`** — called from `enterDeepSleep()` between - `esp_sleep_enable_timer_wakeup()` and `powerLatchHoldForSleep()`. Builds - candidate list from every initialized `buttonStates[]` entry (wake level = - pressed level) plus `pwr_pin_3` as an active-low candidate on - `DEVICE_FLAG_BATTERY_LATCH` boards. Exclusions, each logged: - `SLEEP_FLAG_BUTTON_WAKE_DISABLE` (arm nothing), `pwr_pin_2` (latch hold pin), - `pwr_pin_3` on D-FF boards (flip-flop CP clock — arming could cut power), - pins failing `esp_sleep_is_valid_wakeup_gpio()`, and pins held at their - pressed level at sleep entry (instant-wake ping-pong mitigation). Per-variant - arming: - - **C3/C6** (`SOC_GPIO_SUPPORT_DEEPSLEEP_WAKEUP`): `esp_deep_sleep_enable_gpio_wakeup()` - once per polarity group; both return codes checked and logged; a failed - group degrades to timer-only, never aborts the sleep. - - **Classic ESP32** (`CONFIG_IDF_TARGET_ESP32`): HIGH group → ext1 ANY_HIGH; - LOW group → ext0 on the lowest-numbered pin (classic silicon has no - ANY_LOW); additional LOW pins logged as unarmed. - - **S2/S3**: larger polarity group → ext1 (ANY_HIGH or ANY_LOW); first pin of - the other group → ext0; extras logged as unarmed. - - ext0/ext1 pads get their configured pulls re-asserted through the RTC IO - registers (`rtc_gpio_pullup_en`/`rtc_gpio_pulldown_en`); pads with no - internal pull get a "floating wake pin" warning but are still armed. - - The timer wake source is always left armed alongside the buttons. -- **`detectButtonWake(int cause)`** — called once in `setup()`. Classifies - EXT0 (logs the armed pin from `RTC_DATA_ATTR s_ext0WakePin`; there is no ext0 - status register), EXT1 / GPIO (logs the waking pin mask from the status - APIs), TIMER, and default. Returns true only for the button causes. Does not - inject synthetic button press events (the press occurred while the ISR was - dead; `initButtons()`'s settle pass would erase or double-count one). - -### Shared minimum-wake window (timer refactor) - -- `PowerOption.min_wake_time_seconds` (uint16, carved from `reserved[7]` → - field + `reserved[5]`; struct size unchanged, old config blobs read 0 → - default) and `SLEEP_FLAG_BUTTON_WAKE_DISABLE` (sleep_flags bit 0) in - `src/structs.h`. -- `DEFAULT_MIN_WAKE_TIME_SECONDS = 120`, `minWakeWindowActive`, - `minWakeWindowStartMs` in `src/main.h`; the four - `FIRST_BOOT_DEEP_SLEEP_DELAY_MS` / `firstBootDelay*` lines deleted. -- `minWakeTimeMs()` / `minWakeHoldActive()` helpers in `src/main.cpp`. The hold - is a **floor layered under the existing quiet-window logic**: sleep requires - both the idle/advertising quiet condition AND the hold expired. Armed in - `setup()` on (a) button wake and (b) first boot (`deep_sleep_count == 0`, - which — per the RTC-reload finding — also covers hidden mid-cycle resets). - Timer wakes never arm it, so their behavior is unchanged. -- Consumers: post-wake advertising branch - (`idle_duration >= advertising_timeout_ms && !minWakeHoldActive()`), idle - gate (`idleMs < idleHoldMs || minWakeHoldActive()`), and a defense-in-depth - guard in `enterDeepSleep()` placed **before** the advertising stop so an - aborted sleep can never leave the radio dark. The old first-boot block in - `loop()` was deleted (superseded). - -### `enterDeepSleep()` changes (`src/main.cpp`) - -New signature `enterDeepSleep(bool force = false, uint16_t overrideSleepSeconds = 0)` -(defaults in the `main.h` declaration and the `device_control.cpp` local -re-declaration). All three pre-existing guards byte-identical. After the guards: -`sleepSeconds = override ? override : config`, used for the timer arm and the -entry log (tagged "(host override, one cycle)" vs "(config)"). The TODO comment -at the old line 464 is now the `armButtonWakeSources()` call. The incorrect -"RTC memory survives soft resets" comment in `setup()` was rewritten to state -the bootloader-reload behavior (per the 2026-07-07 findings capture). - -### `0x0052` protocol extension - -- `communication.cpp`: dispatch passes `data + 2, len - 2` (dispatcher already - guarantees `len >= 2`). -- `device_control.cpp/.h`: `handleDeepSleepCommand(const uint8_t*, uint16_t)`. - Big-endian 2-byte seconds payload; bytes beyond 2 ignored (forward compat); - 1-byte payload logs a warning and is treated as absent; `0x0000` = explicit - no-override. **Eligibility pre-checks with NACK** (payload = duration only, - never eligibility): `power_mode != 1` → `{0xFF, 0x52, 0x02, 0x00}`; - `deep_sleep_time_seconds == 0` → `{0xFF, 0x52, 0x01, 0x00}`. D-FF path - byte-identical to before (ACK + hard power off) plus an ignored-payload log. - No ACK on the successful non-DFF path (unchanged from legacy). -- `config_parser.cpp`: config dump prints `Min Wake Time` and - `Button Wake: enabled/disabled (sleep_flags bit0)`. - -## Differences between implementation and plan - -| # | Difference | Evaluation | -|---|---|---| -| 1 | **EXT0/EXT1 cases in `detectButtonWake()` are guarded by `SOC_PM_SUPPORT_EXT0/EXT1_WAKEUP`** — not in the plan. | **Required fix, found by the build matrix.** The plan's verified-framework-facts said `esp_sleep_get_ext1_wakeup_status()` is *declared* unguarded on every chip — true, but the **symbol does not link on C3** (no ext1 hardware; precompiled libs omit it). The esp32-c3-N4 build failed at link; guarding the cases is correct since those causes cannot occur on chips without the hardware, and the `default` case covers them defensively. This validates the plan's caveat that precompiled-lib behavior could not be fully verified offline. | -| 2 | `detectButtonWake` takes `int`, not `esp_sleep_wakeup_cause_t`; no separate `wokeByButton()` accessor. | Plan-sanctioned option (header must compile on nRF without `esp_sleep.h`). The accessor was unnecessary — `setup()` captures the return value in a local that spans both uses. | -| 3 | `pwr_pin_2`/`pwr_pin_3` exclusions apply only when those pins are **valid** (`!= 0 && != 0xFF`). | Refinement, agent-initiated. `0` is the "unset" sentinel for these fields; without the validity check, a real button on GPIO0 would be silently excluded on every board with no latch configured. Correct — accepted. | -| 4 | Pull configuration for wake pads is read from `globalConfig.binary_inputs[instance_index]` via `ButtonState.pin_offset`. | The plan's preferred fallback: `ButtonState` does not cache pull bits, but it does cache `instance_index`/`pin_offset`, so the config lookup is exact — no lossy heuristic needed. | -| 5 | C3/C6 path checks **both** `esp_deep_sleep_enable_gpio_wakeup()` return codes (plan required only the second, mixed-polarity call). | Strictly more defensive; no behavior downside. | -| 6 | Config dump lines placed at contextually adjacent anchors (Min Wake Time under Deep Sleep Time; Button Wake under Sleep Flags) rather than one block. | Cosmetic; better readability of the dump. | -| 7 | nRF branch of `handleDeepSleepCommand` gained `(void)payload; (void)payloadLen;`. | Warning hygiene only; no behavior. | -| 8 | S2/S3 "larger group" tie-break: equal group sizes put the HIGH group on ext1. | Plan didn't specify tie behavior; either choice is valid — at most one pin of the other polarity is relegated to ext0, which the plan requires anyway. | - -Everything else matches the plan exactly: struct layout and offsets, flag bit, -API signatures, guard ordering (hold guard before advertising stop; arming -between timer arm and latch hold), one-cycle override-by-parameter, NACK codes, -hold-as-floor semantics, first-boot refactor, D-FF exclusions, and the -RTC-comment correction. - -## Validation performed - -1. **Diff review vs plan** — every hunk in all 9 changed/new files checked - against the plan's steps; deviations enumerated above and each evaluated. -2. **Cross-agent consistency** — the two implementation agents worked disjoint - file sets against a shared `enterDeepSleep(bool, uint16_t)` contract; - signatures, field names, and macro names line up exactly (verified by - compile, not just inspection). -3. **Compile matrix** — 5/5 environments SUCCESS after the C3 link fix - (difference #1). No warnings in project sources. -4. **Logic checks** — - - All new loops bounded (≤ 33 candidates, 16 hex nibbles, 64 warn-mask bits); - no unbounded loops, no recursion, no dynamic allocation beyond the - codebase's existing `String` logging idiom, no exceptions (none used - anywhere in the codebase). - - Min-wake hold self-clears by time with wraparound-safe `millis()` - subtraction; both call sites short-circuit so the mutation only runs when - the gate is actually consulted; a stray armed hold on a wired device is - provably inert (every consumer sits behind `power_mode == 1` gates). - - Every wake-arming failure degrades to timer-only sleep — no failure mode - yields no-sleep or no-wake. - - Advertising continuity invariant preserved: the hold guard aborts before - the advertising stop; past the stop, `enterDeepSleep()` unconditionally - reaches `esp_deep_sleep_start()`. - - `0x0052` payload-length underflow impossible (`len >= 2` dispatcher - guard); `0xFFFF` payload (≈18.2 h) safe in the 64-bit µs conversion; - override cannot leak across cycles (parameter, never stored). - -## Not yet validated (requires hardware — see plan's validation task list) - -- Actual button wake per variant (classic ext0/ext1, S3, C3/C6 gpio-wake), and - the mixed-polarity double `esp_deep_sleep_enable_gpio_wakeup()` accumulation - on C3/C6 (plan's flagged-unverified item; the code checks and logs both - return codes). -- MOSFET-latch board: latch held through timer sleep with wake buttons armed; - power-button wake; `powerOff()` regression; sleep-current delta from RTC - pulls. -- D-FF board: `0x0052` hard-off regression; `pwr_pin_2/3` never in the logged - wake mask. -- 120 s window timing, `min_wake_time_seconds` override, first-boot window, - held-button skip, `0x0052` payload matrix and NACKs, cross-version - compatibility against the previous firmware release. diff --git a/docs/IT8951_BBEPAPER_INTEGRATION_PLAN.md b/docs/IT8951_BBEPAPER_INTEGRATION_PLAN.md deleted file mode 100644 index 11a5134..0000000 --- a/docs/IT8951_BBEPAPER_INTEGRATION_PLAN.md +++ /dev/null @@ -1,471 +0,0 @@ -# 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 deleted file mode 100644 index 72b1cc8..0000000 --- a/docs/PLAN_BLE_IDLE_SESSION_DISCONNECT_2026-07-29.md +++ /dev/null @@ -1,686 +0,0 @@ -# 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_HARDENING_2026-07-31.md b/docs/PLAN_FREEZE_HARDENING_2026-07-31.md deleted file mode 100644 index 5467fef..0000000 --- a/docs/PLAN_FREEZE_HARDENING_2026-07-31.md +++ /dev/null @@ -1,1650 +0,0 @@ -# Freeze-Hardening the OpenDisplay Firmware — 2026-07-31 - -A self-contained four-phase plan for the BLE e-paper firmware, written from the code -as it stands on `fix/nonce-replay-window` (last code commit `9ca1d8f`, rebased onto the -squashed `main` at `aae5bdf`; every commit after it on this branch is docs-only, so the -citations below still describe the tree). - -Every claim below was verified by direct reading of the current tree and is cited to -`file:line` so a reviewer can re-check rather than trust. The loop/BLE unification -(PR `#132`) and the nonce rewrite (this branch) both landed recently and changed the -shape of several subsystems, so nothing here is taken on inherited assumption — the -ground truth is re-established from scratch below. - -## Conformance with `CONNECTION_POLICY.md` - -[`CONNECTION_POLICY.md`](CONNECTION_POLICY.md) is the **normative** ruleset for -connection behaviour: it defines what must be true. This plan **schedules** it — when -each rule is built, on which mechanism, and how it is verified. Where the two disagree -the policy wins, and this revision exists to remove the disagreements: the policy's -"supersedes" list is discharged below rather than left as a standing conflict. - -| Policy rule | Lands in | What this revision changed | -|---|---|---| -| **R1** one admitted client, globally | Phase 3 | unchanged in substance | -| **R2** identity is `(transport, handle, epoch)` | Phase 2 | the owner token gains an **epoch**, allocated in the connect callback for *every* instance; it was a `(transport, handle)` pair | -| **R3** a contender is refused, and refusal is inert | Phase 2 (mechanism) + Phase 3 (policy) | adds the **per-handle instance table**, **identity-bearing disconnect** events, per-link **subscribe** filtering and **handle-targeted notify** — the plan previously had only handle-bearing *connect* events and write filtering | -| **R3a** a firmware-initiated drop waits for link-down | Phase 2 | the seam **waits synchronously** for the link to go down before the abort releases; the plan previously released at request time | -| **R4** idle timeout, ungated by transfer state | Phase 2 (clock) + Phase 3 (policy) | the clock stamps a **recognised command from the current owner**, not any queued frame, and is re-stamped by a single `endRefresh()` helper | -| **R5** refresh watchdog | **out of scope**, named | recorded under [residual risk](#residual-risk-honest-list); the FastEPD refresh path has no bound at all, which the plan did not previously say | -| **R6** abort on every non-refused disconnect | Phase 2 | the invocation set gains **deep sleep** (R7e row 3) and states R6's exceptions | -| **R7d** within-pass ordering | Phase 3 | new: the loop order is normative, not incidental | - -Two rules cost nothing to schedule because they are already satisfied: R6's buzzer/LED -carve-out and its WARM-panel survival are the design `abortToKnownState` already had, -and R4's no-transfer-gate was reconciled in the previous revision. - -> **Revision 2026-07-31b (external review).** An adversarial review of this plan and -> the policy found three defects that would have surfaced mid-implementation, corrected -> in both documents: (1) the owner token was loop-task-only while callback-side write -> filtering needed to read it on the host task — the token is now a single atomic word -> claimed by CAS at the earliest transport hook, and the epoch narrows to 16 bits so -> the word stays lock-free; (2) `abortToKnownState` step 10 dropped a BLE handle -> unconditionally, which is wrong for a LAN owner (the transfer watchdog is -> origin-agnostic) — the drop now dispatches on the owner's transport; (3) -> `bleDropAndWait()` polled the aggregate `connectedCount()`, which never reaches zero -> while a refused contender is attached — the predicate is now the owner's -> instance-table entry, ticked on a plain bounded delay rather than `idleDelay()`, -> whose event early-out degrades into a busy spin mid-teardown. - -> **Revision 2026-07-31c (same review, second batch).** Three further findings shared -> one root cause — queued frames are anonymous — and are fixed together by -> CONNECTION_POLICY R3 requirement 6: every queued frame now carries its writer's -> packed instance-identity word, stamped in `onWrite` from the same owner-word load -> the write filter already does, and the dispatcher executes a frame only if its tag -> still equals the owner word. That one mechanism closes the teardown window (the -> departing owner writing during its own abort), dissolves the boundary-lost-to- -> handle-reuse hazard, and makes the activity clock's "from the owner" test true -> instance identity instead of transport-only. It **retires the RX-boundary -> mechanism** — `s_rxBoundaryAtDisconnect`, `takeDisconnectedEvent`'s boundary -> out-param, and `bleRxQueueDiscardTo` all go — and the abort's step 9 now resets -> both rings, not just TX. - -> **Revision 2026-07-31d (closing the review).** The remaining findings, corrected in -> both documents: 7a gains rows 9–10 and the admission-decided-once rule (racing -> arrivals are serialized by the claim CAS; a lingering refused contender never -> inherits a freed slot); the deep-sleep abort's rationale is rewritten — neither RAM -> nor hardware state survives in a way that needs it, so the abort stands on teardown -> uniformity at a mid-session exit; the auth-abuse `FE` is best-effort (stack -> acceptance plus a bounded negotiated-interval dwell, not guaranteed receipt); and -> three miscited lines are fixed -> (`sessionOrigin` stamps, dispatcher rejection sites, the Bluefruit `disconnect()` -> signature). - -## Phase map - -| # | Phase | Depends on | State today | -|---|---|---|---| -| 1 | Nonce / replay correctness | — | **Shipped** on this branch (`e2e95cd`…`19335e6`) | -| 2 | BLE-HAL foundation: link-drop seam (with the R3a wait), instance identity + owner token, the instance table, callback-side filtering, frame identity tags, activity clock, abort-to-known-state — **plus contender refusal, moved here from Phase 3** | — | **Implemented** on `feat/phase2-ble-hal-foundation` (`dbec776`, `bb7ad1d`); landed, not closed | -| 3 | Idle drop + the remaining exclusivity policy | Phase 2 | **Implemented** on `feat/phase3-exclusivity-idle-drop`; landed, not closed | -| 4 | Auth-abuse disconnect | Phase 2, Phase 3 | **Implemented** on `feat/phase4-auth-abuse-disconnect`; landed, not closed | - -> **Refusal moved from Phase 3 to Phase 2 during implementation.** Phase 2 is not -> safely shippable without it, so the split as originally drawn was wrong rather -> than merely inconvenient. Admission is decided once per instance and never -> revisited (7a row 10), so a client that reconnects into a still-held slot — the -> ordinary case when `loop()` was blocked in a refresh — becomes a permanent -> contender; on nRF it occupies the only peripheral link and the device stops -> accepting anyone until that client happens to leave. The two alternatives were -> both worse and both were tried: releasing the token in the disconnect callback -> admits a new owner while the departed session's transfer, crypto and TX ring are -> still live, and skipping the refusal scan while the slot is unowned leaves a -> decided loser attached forever. -> -> What stayed in Phase 3: the idle timeout and every other path that reclaims a -> *held* slot. Refusal only makes the "decided once" rule true; it never evicts. -> LAN accept also became refuse-not-evict here for the same reason (its eviction -> path could strand the token until reboot). - -**Phase order note.** Phase 2 is the foundational layer: every transport/HAL -*mechanism* the later phases stand on — the portable `disconnect()`, connection instance -identity and the owner token, the instance table with callback-side write/subscribe/notify -filtering, the activity clock, and the shared abort routine. Phase 3 is **policy** on top of those -mechanisms (when to refuse and when to drop); Phase 4 is the auth-abuse policy. Phase 2 -lands first because 3 and 4 both call into it — building the foundation last (as an -earlier draft did, with exclusivity as Phase 2) created a dependency cycle, since the -idle drop calls the abort routine. - -**Two cross-phase deliverables** thread through Phases 2–4 and are specified once -here rather than repeated: - -- **Threshold discipline — at the point of use, not in a new header.** Every tunable - this plan introduces (the R3a link-down wait bound, the idle-drop timeout, the - auth-abuse count and its flush deadline) is a compile-time `#ifndef`-guarded - `#define` **in the file that consumes it**, each carrying a comment naming the - *client behaviour it assumes*. No threshold is a wire/config field, so none touches - the hard constraint. - - The four differ in how load-bearing they are, and the comments should say so. - `OD_BLE_IDLE_TIMEOUT_MS` carries the most weight (it is the sole reclaim path, and - under R4 it can end a live upload); `OD_BLE_LINK_DOWN_WAIT_MS` carries the least — - per CONNECTION_POLICY R3a its expiry is not a failure needing recovery, just an - early exit into an abort that runs regardless. - - This follows the repo's existing convention rather than inventing one. The model is - [wifi_service.cpp:470-472](../src/wifi_service.cpp): - - ```c - #ifndef OD_LAN_ROAM_RSSI_THRESHOLD - #define OD_LAN_ROAM_RSSI_THRESHOLD (-75) /* dBm; valid range -100..10 */ - #endif - ``` - - and likewise `OD_TINFL_DICT_SIZE`, `OD_CHARGER_FLAG_*`, `OD_LOG_LEVEL` - ([od_log.h:16-18](../src/od_log.h)); `TRANSFER_WATCHDOG_MS` is a plain `static const` - in [display_service.cpp:582](../src/display_service.cpp). There is no central - tunables header in this repo and this plan does not add one. - - *An earlier draft specified a `src/session_policy.h` collecting all four.* It was - cut. It would have been the only file of its kind, and it groups by **type** - ("these are all thresholds") rather than by dependency: the four are consumed by two - unrelated subsystems — the idle drop by the loop-side policy helpers, auth-abuse by - `communication.cpp` — so the header buys a new include edge shared by two callers - that need nothing else from each other. The goal behind it was that the assumptions - be legible rather than bare numbers; that is served by the mandatory - client-behaviour comment, which reads *better* next to the code that acts on it, and - by the client-side CI assertions below. If a shared home is ever genuinely needed, - `structs.h` is the existing common hub. - - **They do not go in the BLE transport headers either.** These are policy, and - Phase 2 is mechanisms-only by construction. This is settled precedent here, in the - same direction: [ble_transport.h:89-93](../src/ble_transport.h) records that the - loop-serviced deferred-work flags were *moved out* of the transport because they - "encode application policy, not link state, so exporting them from the transport - seam was backwards." The same reasoning puts the activity clock beside the owner - token rather than in the transport; deciding how long is too long belongs to the - loop-side policy code that Phase 3 adds. -- **A companion HIL test per phase**, under `tests/`, following the existing - `tests/serial_stall_test.py` pattern (pytest driving a real board through - `py-opendisplay`). These *are* the Verification sections — versioned with the - code, not prose. See [Verification model](#verification-model) below. - -## Hard constraint — NO wire protocol change - -`include/opendisplay_protocol.h` must not change, and no new opcode or response -code may be added. Verified for every phase below: dropping a link, refusing a -connection, and idle teardown are all HCI-level (a disconnect *reason* byte, not -an app-protocol field); `RESP_AUTH_REQUIRED` already exists and is used in its -documented meaning. If any phase turns out to need a wire change it stops and the -change goes through `../opendisplay-protocol` first. - ---- - -## What the current code actually does (ground truth) - -Established by direct reading of the tree, 2026-07-31. These are the facts the -phases build on; each is cited so a reviewer can re-check rather than trust. - -### Connection model is asymmetric and, on ESP32, unguarded - -- **nRF** caps at one central in hardware: `Bluefruit.begin(1, 0)` - ([ble_transport_nrf.cpp:164](../src/ble_transport_nrf.cpp)). The SoftDevice - refuses a second central at the link layer. Advertising re-arms itself - (`restartOnDisconnect(true)`, `:210`). -- **ESP32** allows **three** centrals: `CONFIG_BT_NIMBLE_MAX_CONNECTIONS = 3` is - baked into the precompiled NimBLE framework and a `-D` override is inert (the - precompiled `sdkconfig.h` wins). `onConnect` - ([ble_transport_esp32.cpp:81-93](../src/ble_transport_esp32.cpp)) does **no** - count check and **no** rejection; a second central's handle simply **overwrites** - the single scalar `s_connHandle` (`:87`), and its writes land in the same RX ring - undistinguished. This is a live multi-central exposure, not a hypothetical. -- **Every piece of per-link state on ESP32 is a global scalar any central can move**, - which is why CONNECTION_POLICY R3 needs six requirements at the callback and - dispatch boundary rather than one. Besides `s_connHandle`: `s_notifySubscribed` is set by whichever central - subscribed last (`onSubscribe` discards its `connInfo`, `:129`), and `onWrite` - discards its `connInfo` too (`:135`), so a contender's frames enter the incumbent's - RX ring. -- **Notifications go to every subscribed client — a live leak, present today.** - `BleTransport::notify` calls `s_txCharacteristic->notify(data, len)` - ([ble_transport_esp32.cpp:269-277](../src/ble_transport_esp32.cpp)), the two-argument - overload. NimBLE's third parameter defaults to `BLE_HS_CONN_HANDLE_NONE`, documented - as "send the notification to **all subscribed clients**." A second central that - connects and subscribes therefore receives every response the incumbent is sent, - including authentication traffic, before `loop()` runs at all and with no policy - decision having been made. Fixing it is a one-argument change (Phase 2). -- **Connect and disconnect events coalesce**, and their side-band data is single-slot. - Both are plain `volatile bool` - ([ble_transport_esp32.cpp:33-34](../src/ble_transport_esp32.cpp)) and the header - records the weakness itself: "a second same-type event arriving inside the - check-then-clear window is lost" ([ble_transport.h:66-71](../src/ble_transport.h)). - `s_disconnectReason`, `s_rxBoundaryAtDisconnect` and `s_connHandle` are each one - slot, so each event overwrites the last. Harmless today — `serviceBleEvents()` - decides nothing per-connection ([main.cpp:461-500](../src/main.cpp)) — and a - correctness problem the moment each event drives an admission decision. -- **LAN** is single-client, last-in-wins: a second TCP accept evicts the first - ([wifi_service.cpp:871-877](../src/wifi_service.cpp)). -- **BLE and LAN can both be live at once.** There is no connection-level - arbitration. The only ownership is per-*transfer*: `sessionOrigin`, stamped at - transfer START ([display_service.cpp:2159,2200,2712](../src/display_service.cpp)), - enforced per-frame by `frameOwnsSession()` and per-disconnect by - `serviceBleDisconnectCleanup()`. - -### No application code can drop a BLE link through the transport - -- `BleTransport` ([ble_transport.h](../src/ble_transport.h)) exposes **no** - `disconnect()`. `end()` is a full-controller teardown, and a no-op on nRF. -- ESP32 captures the conn handle (`s_connHandle`, `ble_transport_esp32.cpp:87`) - but **never calls** `NimBLEServer::disconnect()`. The capability is one line - away and unused. -- nRF has exactly one host-initiated disconnect in the whole firmware — - `Bluefruit.disconnect(Bluefruit.connHandle())` - ([device_control.cpp:857](../src/device_control.cpp)), inside DFU entry, reaching - past the abstraction into Bluefruit directly. **Bluefruit's public `disconnect()` - takes only a handle and always sends reason 0x13 — there is no reason argument to - honour** — a fact the seam design - below has to respect. - -### No stall detection reaches a hung `loop()` - -- nRF has **no watchdog at all** ("every fault handler is `b .`", - [od_log.h:40](../src/od_log.h)). -- ESP32's `loop()` is **not** subscribed to the task WDT: Arduino leaves - `loopTaskWDTEnabled = false` and nothing here calls `esp_task_wdt_add()` for the - loop task, so `loop()` is unsupervised. (Whatever `CONFIG_FREERTOS_WATCHDOG_TIMEOUT_S` - is set to is immaterial — no framework code arms a loop watchdog from it.) -- The only wall-clock teardown is `checkTransferTimeouts()` - ([display_service.cpp:584-638](../src/display_service.cpp)), and it measures total - elapsed from transfer **START** — 15 minutes (`TRANSFER_WATCHDOG_MS = 900000`). It - is a total-duration bound, **not** a stall/inactivity timeout: a transfer that - stalls at minute 1 is still not torn down until minute 15, and a slow-but- - progressing transfer is cut off at 15 minutes regardless of progress. -- **The refresh BUSY-wait is bounded on one path only.** On `bb_epaper`, - `waitforrefresh(60)` loops `timeout * 100` times at 10 ms and then fails - ([display_service.cpp:803-831](../src/display_service.cpp)). **On FastEPD there is no - bound whatsoever**: `waitforrefresh()` delegates to `fastepd_wait_refresh()` (`:805`), - which ignores its timeout argument outright — `(void)timeout_sec; return - !s_init_failed;` ([display_fastepd.cpp:277-280](../src/display_fastepd.cpp)) — and the - real blocking lives above that call, inside `fullUpdate()`/`fastUpdate()`. This is - CONNECTION_POLICY R5's exposure, and it is out of scope here; see residual risk. - -### An idle connected client is never dropped - -- `pollActivity()` stamps `lastActivityMs` whenever `connCount > 0` - ([main.cpp:366](../src/main.cpp)) — a live link is treated as activity in - itself. So a client that connects, authenticates, and goes silent holds the - device out of its idle path **forever**. -- `session_timeout_seconds` ([encryption.cpp:254-265](../src/encryption.cpp)) - measures from session START not last activity, clears the *session* but **not** - the *link*, and is only evaluated when a command arrives — so it never fires on - a silent client. It defaults to 0 (disabled). -- There is **no** BLE idle link-drop. LAN has one (`OD_LAN_READ_TIMEOUT_S = 30`, - [wifi_service.cpp:952](../src/wifi_service.cpp)); BLE has no equivalent. - -### State with no disconnect-time reset (Phase 2 surface) - -Confirmed missing or open-coded, i.e. what an abort must newly cover: - -- `encryptionSession` — **not** cleared on BLE disconnect. Crypto state survives a - link drop. `clearEncryptionSession()` runs on session-timeout-at-command, a new - auth, config reload ([communication.cpp:66](../src/communication.cpp)), and LAN - teardown ([wifi_service.cpp:798,874](../src/wifi_service.cpp)) — but no BLE - disconnect path is among them. -- `chunkedWriteState` (config chunked upload, - [config_parser.h:47](../src/config_parser.h)) — **no reset function**; cleared - only by open-coded inline assignments in `communication.cpp`, untouched by - disconnect and by the watchdogs. -- The response TX ring — **no** flush/discard primitive (only `bleRxQueueDiscardTo` - exists, RX side). -- The RX ring is **anonymous** — `CommandQueueItem` is `{data, len, pending}` - ([command_queue.h:72-76](../src/command_queue.h)); nothing records which link a frame - came from. The disconnect path compensates with a boundary captured at link-down - ([ble_transport_esp32.cpp:105](../src/ble_transport_esp32.cpp)) and discarded to on - the loop ([main.cpp:480-489](../src/main.cpp)) — a mechanism Phase 2 retires - (requirement 6 below). -- `directWriteTouchSuspended` — reset only *inside* `cleanupDirectWriteState()`, so - a teardown routed through the partial path can leave touch suspended. -- Buzzer and LED — serviced each loop pass, and **no session-teardown stop API exists. - The abort deliberately does not add one**; see the carve-out in `abortToKnownState` - below. Both are bounded and self-terminating — the buzzer's `outer` repeat count is a - `uint8_t` coerced to at least 1 and playback calls `buzzer_stop_internal()` at - `rep >= outer` ([buzzer_control.cpp:215-217,288-291](../src/buzzer_control.cpp)); the - LED runs a stepped pattern to completion - ([device_control.cpp:530-541](../src/device_control.cpp)). Neither can run forever, so - neither is state a later connection can inherit. - - **The stop routines themselves exist but are file-static**, which matters for the one - caller that does need them: `buzzer_stop_internal()` - ([buzzer_control.cpp:147](../src/buzzer_control.cpp)) and `led_stop_internal(bool - clear_mode)` ([device_control.cpp:347](../src/device_control.cpp)). Deep sleep must - silence both (7e row 3), so Phase 2 adds two thin **public wrappers** — sleep APIs, not - teardown APIs. Nothing in the abort may call them. - ---- - -## Phase 1 — Nonce / replay correctness ✅ SHIPPED - -Shipped on this branch (`e2e95cd`…`19335e6`), recorded here for completeness. What -landed: - -- The AES-CCM anti-replay state moved from a 512 B ring of raw counter values to a - 32 B sliding bitmap (`src/nonce_window.h`, a dependency-free pure state machine). -- Check split from commit: `nonceCheck()` decides and writes nothing; `nonceCommit()` - runs only *after* the CCM tag verifies. So packet loss is no longer counted as - tampering, and an unauthenticated peer cannot advance replay state. -- The forward-distance cap was **removed** and comparison made numeric, not modular: - a counter ahead of `last_seen` is accepted at any distance (the tag is the gate), - which fixed a cliff where a forward gap past the cap stranded the session - unrecoverably. A consumed counter is still never re-accepted (`last_seen` only - moves up; below it, bitmap-caught or rejected on width). - -**Verified:** host suite 47,445 checks under `-Werror`+ASan/UBSan (and 1,635 -failures against the pre-change code, proving the tests discriminate); -`nrf52840custom`, `esp32-c3-N16`, `esp32-N4` build. -**Not verified:** the entire hardware matrix. - -Nothing in Phase 1 is reopened here. One carry-forward: an **auth-abuse disconnect** -was prototyped alongside the nonce work on a separate branch -(`feat/nonce-replay-and-auth-guard`) but is **not** on this branch, and is redesigned -fresh as Phase 4. - ---- - -## Phase 2 — BLE-HAL foundation (mechanisms) - -**Goal:** every transport/HAL *mechanism* the later phases build on — the portable -link-drop seam and its R3a wait, connection instance identity and the owner token, the -instance table with callback-side write/subscribe/notify filtering, the activity clock, -and the idempotent abort-to-known-state routine. No *policy* lives here (Phase 3 decides -when to refuse and when to drop); Phase 2 only makes each action possible and each fact -observable. - -**Phase 2 already closes two live holes on its own**, before any Phase 3 policy exists: -the notify leak (a second central receiving the incumbent's responses) and command -injection into the incumbent's RX ring. Both are callback-side filtering, and neither -waits on an admission decision. If Phase 3 slips, these should still land. - -### The link-drop seam — `BleTransport::disconnect(uint16_t handle)` - -Add to the abstraction ([ble_transport.h](../src/ble_transport.h)) and implement per -target. It takes an explicit **handle**, not just "the current connection", because -Phase 3's admission needs to drop a *specific* link; pass the current handle for the -common case. - -- **ESP32:** `s_server->disconnect(handle, BLE_ERR_REM_USER_CONN_TERM)`. Return the - call's bool; log WARN on failure. Note the library already treats "the link is - gone" as success — `NimBLEServer::disconnect` returns `true` for `BLE_HS_ENOTCONN`, - `BLE_HS_EALREADY` and `UNK_CONN_ID` (`NimBLEServer.cpp:321-332`), so a WARN here - means a genuine failure, not a benign race with a client that left first. -- **nRF:** `Bluefruit.disconnect(handle)`. Lift the pattern from - [device_control.cpp:857](../src/device_control.cpp) but keep - `restartOnDisconnect(true)` (unlike DFU, which disables it). - -**Reason fixed at 0x13, and the seam hard-codes it.** A host-initiated disconnect -must use a Core-Spec-legal `HCI_Disconnect` reason. `BLE_ERR_REM_USER_CONN_TERM` -(**0x13**) is legal; `BLE_ERR_CONN_LIMIT` (0x09) is **not**, and the controller -silently rejects it (0x12) — the gatecrasher stays connected while the code looks -like it worked. The stacks are asymmetric, and both were read rather than assumed: - -- NimBLE takes a reason and *defaults it to 0x13* — - `disconnect(uint16_t connHandle, uint8_t reason = BLE_ERR_REM_USER_CONN_TERM)` - (`NimBLEServer.h:66`), forwarded to `ble_gap_terminate`. -- Bluefruit takes **only a handle** — `AdafruitBluefruit::disconnect(uint16_t conn_hdl)` - (`bluefruit.h:171`) delegates to `BLEConnection::disconnect(void)`, which calls - `sd_ble_gap_disconnect(_conn_hdl, BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION)` - (`BLEConnection.cpp:206`). There is no reason parameter to pass, let alone one to - honour. - -So the seam exposes no `reason` parameter: 0x13 is the only value this plan wants, -the value NimBLE already defaults to, and the only value nRF can send. Both stacks -do take a **handle**, which is what the seam's signature carries. - -**Also fix the inbound reason, which currently lies (ESP32).** Not a new feature — -a correctness fix to what is already logged. `s_disconnectReason` is a `uint8_t` -([ble_transport_esp32.cpp:35](../src/ble_transport_esp32.cpp)) assigned from -NimBLE's `int reason` with a truncating cast (`:99`). NimBLE uses two ranges: HCI -reasons wrapped as `BLE_HS_ERR_HCI_BASE + code` (`0x200 + code`), and host-layer -`BLE_HS_E*` codes in `1..31`. The cast keeps only the low byte, so an HCI reason -survives by luck (`0x213 & 0xFF == 0x13`) while `BLE_HS_ENOTCONN` (7) truncates to -`0x07` and reads back as the unrelated HCI "memory capacity exceeded". The log at -[main.cpp:472](../src/main.cpp) then prints it as decimal `%u`, so the two collide -on screen as well as in storage. nRF is unaffected — it stores a raw HCI `uint8_t` -from the SoftDevice with no wrapping ([ble_transport_nrf.cpp:38,135](../src/ble_transport_nrf.cpp)). - -Fix: widen `s_disconnectReason` and `takeDisconnectedEvent`'s reason out-param to -`uint16_t` ([ble_transport.h:81](../src/ble_transport.h), one caller at -[main.cpp:471](../src/main.cpp)), drop the cast, and log `0x%03X` so a wrapped HCI -reason (`0x213`) and a host reason (`0x007`) are visibly distinct. No enum, no -classifier — just stop discarding half the value. (The *other* out-param, `rxBoundary`, -is retired outright by requirement 6 — the boundary mechanism it fed is superseded by -frame tags.) - -*Deferred, deliberately:* normalizing the inbound reason into an `OdDiscReason` -enum (`SUCCESS / REMOTE / LOCAL / TIMEOUT / MIC_FAILURE / OTHER`). Nothing in -Phases 2–4 branches on *why* a link dropped — the abort runs the same teardown -regardless, and a self-initiated drop is identified by its `*DropPending` flag, not -by reading the reason back. The classifier would feed a log line and nothing else. -The likely first real consumer is MIC-failure handling (0x3D signals encryption -desync); when that lands it is a small header and a `switch`, and the `uint16_t` -raw value preserved here is exactly its input, so nothing above has to be redone. - -All disconnect calls are made from the **loop task** (a `serviceBleLinkDrop` hook, or -inline in the loop-serviced helpers), never a stack callback — a callback that severs -its own link mid-dispatch is exactly the class of bug `#132` removed. - -#### The drop waits for link-down (CONNECTION_POLICY R3a) - -**`disconnect()` requests termination; it does not perform it.** `NimBLEServer::disconnect()` -returns true even for `BLE_HS_ENOTCONN`/`BLE_HS_EALREADY` (`NimBLEServer.cpp:321-332`), -so a true return means "requested," not "down." An earlier draft of this plan released -the owner token at request time, which would let a new connection be admitted while the -old link was still physically up. R3a supersedes that: the drop is **synchronous** — the -seam requests termination, then waits cooperatively and with a bound until the link is -actually down, and only then does the abort release the slot. - -So the seam is not the bare call but a small helper beside it: - -``` -bool bleDropAndWait(uint16_t handle); // request + bounded cooperative wait; true if down -``` - -Three properties of the current tree make this the simple form it looks like, and R3a -records the investigation that rejected a cross-pass `DROPPING` state as more machinery -than the problem needs: - -- **Link-down is per-handle pollable without consuming the event.** The disconnect - callback writes the departing instance's table entry (requirement 5 below) at the - moment the link drops, so the wait's predicate is "the owner's `(handle, epoch)` - entry is no longer live" — a scan of the instance table. **The aggregate - `connectedCount()` must NOT be the predicate**: it is the stack's total peer count - on both targets — `s_server->getConnectedCount()` - ([ble_transport_esp32.cpp:257-259](../src/ble_transport_esp32.cpp)), - `Bluefruit.connected()` ([ble_transport_nrf.cpp:235-239](../src/ble_transport_nrf.cpp)) — - and R1 explicitly permits a refused contender to be transiently attached, so dropping - the owner moves the count 2→1, never to 0, and the wait would sit out its full bound - on a link that is already down. The disconnect *event* stays queued for - `serviceBleEvents()` ([main.cpp:461-500](../src/main.cpp)) to consume on its normal - path — the wait neither consumes nor reorders it. (What survives of that path is the - event flow, flag and reason; its RX-boundary capture is retired by requirement 6.) -- **The wait ticks on a short plain `delay()`, not `idleDelay()`.** An earlier draft - named `idleDelay()` the right primitive for its early-out on `ble.eventPending()` - ([main.cpp:749](../src/main.cpp)). That early-out is exactly wrong here: the - predicate is table state, not event arrival, and mid-teardown events are deliberately - left unconsumed — so once any event is pending (the owner's own disconnect, or an - unserviced contender's connect), every `idleDelay()` call returns immediately and the - wait degrades into a busy spin for its remaining bound. A plain `delay(2)` tick - services *neither* RX nor transport events — the safety property actually wanted — - and costs a few milliseconds of latency against a bound sized in tens of them. -- **The epoch makes an expired wait harmless.** If the bound expires with the old link - still up, that link is inert by construction: its writes are filtered as non-owner, - and its late disconnect is inert on stale epoch (7b rows 4 and 9). This is why the - bound is the least load-bearing threshold in the plan. - -*The wait can never land inside a refresh.* Every caller is loop-task-only and already -deferred while `epdRefreshInProgress` ([main.cpp:389](../src/main.cpp)). - -*Timing.* An alive peer terminates within a few connection intervals — tens of ms; the -firmware requests no interval, so the central's negotiated value applies. A peer already -gone is reaped by the link layer at ~4–6 s. So `OD_BLE_LINK_DOWN_WAIT_MS` wants to cover -a few connection intervals with margin — tens to low hundreds of ms — not a supervision -timeout. It is deliberately *not* sized against the 120 s idle timeout. - -### Connection instance identity and the owner token - -A tiny arbiter, one new translation unit (`src/link_owner.h/.cpp`) or folded into -`communication.cpp`. **Identity is the triple `(transport, handle, epoch)`**, per -CONNECTION_POLICY R2 — an earlier draft of this plan used a `(transport, handle)` pair, -which R2 supersedes: - -``` -enum LinkOwner { OWNER_NONE, OWNER_BLE, OWNER_LAN, OWNER_TERMINAL }; -struct LinkId { LinkOwner who; uint16_t handle; uint16_t epoch; }; - -// The token itself is ONE 32-bit word: [31:30] transport | [29:16] handle | [15:0] epoch. -// All-zero == unowned; epoch 0 is never allocated; 0xC0000000 (transport 0b11, -// handle 0, epoch 0) == OWNER_TERMINAL, the deep-sleep admission gate. -uint16_t linkNextEpoch(void); // __atomic_fetch_add; connect callback, EVERY instance -bool linkClaim(LinkId id); // one CAS on the word; safe from stack callbacks -void linkRelease(LinkId id); // CAS holder -> NONE; loop task only, after R3a's - // wait; full-identity match, so it can never zero - // the terminal word (and never accepts it as id) -LinkId linkMarkTerminal(void); // atomic exchange -> OWNER_TERMINAL, returning the - // DISPLACED owner identity (possibly NONE) — the - // identity the terminal caller hands the abort; - // deep-sleep path only, BEFORE the abort (R7e row 3) -LinkId linkOwnerId(void); // one atomic load; callable from ANY task -bool linkIsOwner(LinkId id); // full-triple comparison; handle alone is never enough -``` - -The token is **connection-level**: at most one transport-and-link owns the session at -a time. `OWNER_LAN` uses handle 0 (single TCP client by construction); `OWNER_BLE` -carries the conn handle, which the arbiter records — authoritative "who owns the link", -separate from the transport's `s_connHandle` scalar that the newest connect overwrites. - -**Why the epoch, and where it is allocated.** BLE conn handles are small integers the -stack reuses — NimBLE allocates from 0 upward, so a client that disconnects and -reconnects can be handed the *same* handle. This firmware defers work by design, and -`serviceBleDisconnectCleanup` can run tens of seconds late when `loop()` was blocked in -a refresh, a hazard the code already documents at -[main.cpp:398-403](../src/main.cpp) — so a deferred operation carrying a stale handle can -otherwise match a newer session and act on it. The epoch turns "same handle" into "same -connection instance," which is what every deferred consumer actually needs. - -**The epoch is allocated in the connect callback, for every connection instance, -admitted or not** — never on successful claim. This is the trap R2 calls out explicitly: -a *refused* contender never claims, so on claim-time allocation it would carry no epoch, -and 7a row 4 (a contender reusing the incumbent's handle after a stale link) could not be -distinguished from the incumbent at all. Allocation must precede the admission decision, -because the identity is what the decision is *made on*. On admission the token copies the -instance's already-allocated epoch. - -Scope is one boot — no deferred RAM state survives a reset, so cross-reset uniqueness is -neither required nor claimed. The epoch is **16 bits, a deliberate narrowing** (an -earlier draft had `uint32_t`): the one-word token below must stay lock-free, and neither -Cortex-M4 nor the ESP32 ISAs have a lock-free 64-bit CAS, so the triple packs as -`transport(2) | handle(14) | epoch(16)` — HCI conn handles are spec-bounded at 0x0EFF, -so 14 bits holds them with headroom. The invariant that justifies the width (per R2's -wrap rule, where the full conditional argument lives): no outstanding event may survive -a full counter cycle. Epochs churn at link-layer connection rate — tens of ms per -instance — so a full 2^16 cycle needs about half an hour of continuous connect churn -inside a single blocking window that later *completes*; a hung refresh that never -completes (R5's gap) never resumes the loop, so nothing is ever consumed there and a -collision has no consumer to mislead. `linkNextEpoch` re-draws when the fetch-add -yields 0, so wrap cannot mint the reserved unowned encoding. - -**The token is one atomic word, claimed at the earliest transport hook — NOT a -loop-task-only global.** An earlier draft made the token plain loop-side state (the -`g_commandOrigin` argument, [communication.cpp:30-36](../src/communication.cpp)) while -separately requiring callback-side write filtering and an atomic claim at the callback -(R7d). Those are incompatible: `onWrite` fires on the NimBLE host task before any -loop-side admission has run — during a refresh, up to ~16 s before one — so a loop-only -token gives the filter nothing to compare against, and leaves no rule for the unowned -window before first admission. Per R2 the resolution is that the token *is* the -published word: - -- **Claim is a compare-and-swap on the word**, executed at the earliest transport - hook — the BLE connect callback (host task) and the LAN accept (loop task). CAS - success *is* admission; failure marks the instance a contender, which Phase 3's - loop-side scan refuses. This makes R7d's "the claim itself must be atomic at the - callback" a mechanism rather than an aspiration, and it closes the unowned window: - the host task processes a peer's connect before any of its writes, so by the time - the first client's first write reaches `onWrite`, the word already names it owner. -- **The filters read the word with one `__ATOMIC_ACQUIRE` load**: `onWrite` and - `onSubscribe` compare their instance's identity against it on the host task; - `notify()` reads it on the loop task for the target handle. -- **Release stays loop-task-only** (CAS holder → NONE), strictly after the R3a wait. -- **Order in the connect callback:** allocate the epoch, publish the table entry, then - CAS — so a successful claim never names an instance the loop cannot yet see. - -`linkNextEpoch` is `__atomic_fetch_add` — BLE allocates on the host task and LAN on the -loop task, so a plain increment would race the two. The *instance identity* in the -table follows the same publication rule as before: written on the stack callback task, -read on the loop task, atomics discipline per the instance table below. - -Phase 2 establishes only the mechanism and the baseline: the first BLE connect claims -`OWNER_BLE` with its handle and epoch; disconnect releases it (wired into `abort` below). -Deciding what to do with a *second* contender is Phase 3 policy (it refuses; see -[the governing decision](#the-governing-decision-admission-never-evicts)). - -### The instance table and callback-side filtering - -CONNECTION_POLICY R3 requires **six** things at the callback and dispatch boundary. An -earlier draft of this plan had two of them (a handle-bearing connect event, write -filtering) and treated the rest as absent problems; the policy's review of the ESP32 -callbacks found that a contender perturbs shared state *before any loop-side decision -runs*, and the review after that found queued frames outlive their session — so all -six are Phase 2 mechanisms. In table form, against the ground truth above: - -| # | Requirement | Site today | Why it cannot wait for Phase 3 | -|---|---|---|---| -| 1 | **Per-link write filtering** — drop a non-owner's write before the RX ring | `onWrite`, `(void)connInfo` ([:135](../src/ble_transport_esp32.cpp)) | loop-side refusal has not run yet during a ~16 s refresh block; a gatecrasher can inject a full transfer's worth of commands | -| 2 | **Per-link subscribe filtering** — subscription state per instance | `onSubscribe`, `(void)connInfo` ([:129](../src/ble_transport_esp32.cpp)) | a contender's subscribe clears/overwrites the incumbent's apparent notify-readiness, stalling its TX | -| 3 | **Handle-targeted notify** — pass the owner's conn handle | `notify(data, len)` ([:269-277](../src/ble_transport_esp32.cpp)) | closes a **live leak** of the incumbent's responses, auth traffic included | -| 4 | **Identity-bearing disconnect events** | `takeDisconnectedEvent` carries reason + RX boundary, no handle ([ble_transport.h:81](../src/ble_transport.h)) | every consumer must ignore an event whose identity is not the owner's (7b) | -| 5 | **State that survives lost edges** — the instance table | coalescing `volatile bool` pair ([:33-34](../src/ble_transport_esp32.cpp)) | under this policy a lost event is a lost *admission decision* | -| 6 | **Frame identity** — tags on queued frames, re-checked at dispatch | `CommandQueueItem` is `{data, len, pending}`, no identity ([command_queue.h:72-76](../src/command_queue.h)) | a delayed frame from a dead instance is indistinguishable from the new owner at dispatch; a boundary flush cannot save it once the table slot was reused | - -Requirement 3 is worth calling out separately: it is a **one-argument change that closes -a live leak independent of the rest of this plan**, and it is the cheapest item in -Phases 2–4 by a wide margin. It should not wait behind the table. - -Requirements 1–3 all read the same fact — who owns the slot: the write and subscribe -filters on the host task, `notify()` on the loop task. The one-word owner token above -is what makes that read legal from both — a single atomic load, compared against the -callback's own instance identity. None of the filters touches any other loop-side -state. - -The connect event still becomes identity-bearing (handle **and** epoch, per R2), so -loop() can act on a specific newcomer — but under requirement 5 it is a hint, not the -mechanism. - -#### Requirement 5: a fixed per-handle instance table, not an event queue - -Today's coalescing is tolerable because `serviceBleEvents()` decides nothing -per-connection: a connect means "reset `rebootFlag`, update MSD, tune the link," a -disconnect means "flush the RX ring to the boundary, raise the cleanup flag" -([main.cpp:461-500](../src/main.cpp)). Under this policy each event drives an admission -decision about a specific instance, so a lost event is a lost decision — two concrete -failures, both reachable inside one refresh block: - -- **Lost connect → an unrefused contender.** Two centrals connect while `loop()` is - blocked; the flag is set twice and read once. One is refused; the other is connected, - never evaluated, and invisible to the loop. -- **Lost disconnect → the slot held by a ghost.** Owner disconnects, then a contender - connects and disconnects, all within one block. The flag coalesces and the side-band - identity is the *last* writer's. The loop sees a disconnect that does not match the - owner, treats it as inert, and never releases — every new client refused until the - idle timeout reclaims the slot. A device-wide outage of one full timeout. - -**The fix is a table the loop scans, not a queue it drains.** Sized by the connection -cap: 3 on every ESP32 target here, 1 on nRF. Each entry holds `(handle, epoch, -reason)` — **metadata only, ~8 bytes, never frames**, which is what keeps it inside -the one-command-queue constraint. There is no separate `state` field: liveness *is* -the packed `(handle, epoch)` identity word (all-zero = empty), per the publication -rule below. There is no `rxBoundary` field either — requirement 6 retires the boundary -mechanism, which is what lets entries be overwritten freely on churn. Callbacks write their own handle's entry; the -loop compares the table against its own notion of the owner. That inversion dissolves the -overflow question rather than answering it: - -- **It cannot overflow.** State is bounded by the connection cap, not by event rate. - Contender churn overwrites entries for handles already gone. No eviction policy to - specify, because nothing is queued. -- **Lost edges stop mattering.** A contender that connects and disconnects wholly within - a refresh block leaves no entry — correct, since there is nothing left to refuse. -- **Owner release is a comparison, not an event.** If the owner's `(handle, epoch)` is no - longer live in the table, the owner is gone, however many edges were missed. -- **Ghosts stay visible.** Any live entry that is not the owner is a contender still - needing refusal, so a missed refusal self-corrects on the next pass instead of leaking - a slot. - -*Search by handle; do not index by it.* NimBLE allocates from 0 upward in practice, so -direct indexing usually works, but a 3-entry linear search costs the same and cannot be -broken by a stack change that hands out sparse handles. - -**Publication must be atomic — and liveness is part of the identity word.** Entries are -written on the NimBLE host task and read on the loop task. A multi-field `volatile` -struct is not an atomic snapshot — and `volatile` is not an inter-task tool in C++ -regardless. Each entry's packed `(handle, epoch)` word doubles as its liveness: all-zero -means empty, a release-store publishes it at connect, and the disconnect callback -clears it with another release-store — never a separate `state` flag that could race -the identity. The R3a wait's acquire load therefore sees identity and liveness in one -shot, which is what makes "the owner's entry is no longer live" a sound predicate. The -one side field (`reason`) follows the `__atomic_*` discipline the RX ring in this repo -already uses ([command_queue.cpp:62,92](../src/command_queue.cpp)) and is consumed only -after the identity word says down. - -**nRF needs only requirement 4 of the filtering set in practice.** `Bluefruit.begin(1, 0)` -([ble_transport_nrf.cpp:164](../src/ble_transport_nrf.cpp)) configures the SoftDevice for -a single peripheral link, so cross-central injection is unreachable at the link layer; -its one-entry table is degenerate. Its write callback also discards the handle it is -given ([ble_transport_nrf.cpp:148](../src/ble_transport_nrf.cpp)) — latent, not live, but -fix it with the ESP32 filter so the two targets read the same. Requirement 6 applies to -nRF in full, though: a single-link target still queues frames that can outlive their -session across a disconnect/reconnect pair inside one refresh block. - -#### Requirement 6: tagged frames retire the RX boundary - -CONNECTION_POLICY R3 requirement 6, scheduled here. The policy carries the full -rationale (three review findings, one root cause: anonymous frames); this is the -implementation shape: - -- **`bleRxQueuePush` gains a `uint32_t tag` parameter**, and `CommandQueueItem` gains - the field ([command_queue.h:72-76](../src/command_queue.h)) — four bytes × 18–34 - slots is 72–136 B, per-frame metadata in the one ring, not a second ring. `onWrite` - passes the packed identity word it already loaded for the requirement-1 filter, so - the stamp is free; the nRF write callback does the same. The tag is written into - the slot **before** the release-store that publishes the head - ([command_queue.cpp:92](../src/command_queue.cpp)), exactly like `data` and `len`, - or the consumer's acquire load is not guaranteed to see it. -- **Dispatch checks the tag before parsing.** `serviceBleRx()` pops `(frame, tag)`, - drops the frame (counted, logged at debug) if `tag != linkOwnerWord()`, and otherwise - publishes it to the dispatcher as `g_commandInstance` beside `g_commandOrigin` - ([communication.cpp:30-36](../src/communication.cpp)) — loop-task-only single-writer, - the same argument as `g_commandOrigin` itself. LAN dispatch sets `g_commandInstance` - to the LAN owner's word directly; LAN frames never traverse the BLE ring. -- **Retired outright:** `s_rxBoundaryAtDisconnect` - ([ble_transport_esp32.cpp:40,105](../src/ble_transport_esp32.cpp)), the `rxBoundary` - out-param of `takeDisconnectedEvent` - ([ble_transport_esp32.cpp:347-351](../src/ble_transport_esp32.cpp), consumer at - [main.cpp:470-489](../src/main.cpp)), and `bleRxQueueDiscardTo` - ([command_queue.h:112](../src/command_queue.h)). The disconnect consumer keeps - identity and reason; stale frames self-discard at dispatch instead of being flushed - to a boundary that a table overwrite could lose. - -### The activity clock — a recognised command from the owner - -Phase 3's idle drop needs to know how long the owner has been *silent*. Today's -`lastActivityMs` cannot serve: `connCount > 0` re-stamps it every pass -([main.cpp:366](../src/main.cpp)), so a live-but-quiet link never ages. LAN's -`lastLanActivityMs` cannot serve either: it stamps on `got > 0`, i.e. any bytes read -([wifi_service.cpp:946](../src/wifi_service.cpp)), so a flooder holds the slot with -garbage. - -**Activity is defined by CONNECTION_POLICY R4, and only by it:** - -``` -idle := no inbound command from the owner on the owning transport - AND no refresh in progress -``` - -A frame counts only if it reaches the dispatcher and is **recognised as a command from -the current owner**. - -> **This supersedes an earlier draft of this section, which stamped at RX intake** — -> `bleRxQueuePush()`'s success path ([command_queue.cpp:50](../src/command_queue.cpp)) — -> and argued that stamping only queued frames kept a garbage flooder from holding the -> link. That argument does not hold: the queue accepts **any** non-empty payload within -> the size cap ([command_queue.cpp:50-93](../src/command_queue.cpp)), including a -> two-byte malformed frame or an unknown opcode, which the dispatcher only rejects later -> ([communication.cpp:544,754](../src/communication.cpp)). Intake stamping rejects empty, -> oversized and ring-full frames and nothing else, so it leaves a flooder able to hold -> the slot indefinitely — precisely the failure the idle drop exists to prevent. - -**The stamp point is the shared dispatcher.** `imageDataWritten()` -([communication.cpp:541](../src/communication.cpp)) is the single place all three -transports (nRF BLE, ESP32 BLE, ESP32 LAN) dispatch through. Stamp there, after the -`len < 2` guard and gated on two tests: - -- **Recognised:** `commandName(command) != nullptr`. Unknown opcodes return nullptr and - fall to the switch default's "Unknown command" error, so they are not activity. This - reuses the existing recognition predicate rather than adding a second, drift-prone one. -- **From the owner:** the frame's instance identity — its requirement-6 tag, published - to the dispatcher as `g_commandInstance` — equals the owner word. *This supersedes an - earlier draft of this bullet, which compared `g_commandOrigin` - ([communication.cpp:30-36](../src/communication.cpp)) against the owning transport.* - Transport is not identity: a delayed frame from a dead BLE instance is - indistinguishable from the new BLE owner by transport alone, and would stamp the new - owner's clock. In practice the dispatch tag check has already dropped such a frame - before the stamp is reached; the stamp's own full-word test is one redundant compare, - kept because the two sites can otherwise drift. - -Recognition deliberately sits *before* the auth gate: `CMD_AUTHENTICATE` must count as -activity or a client cannot complete a handshake without racing the clock. An -unauthenticated peer that sends recognised-but-rejected commands is therefore held off by -Phase 4's auth-abuse counter, not by this clock — which is the correct division, since -the counter can distinguish "wrong credentials" from "silent." - -**One consequence worth naming: the clock is now loop-task-only.** Intake stamping ran on -the NimBLE host / Bluefruit callback task and needed `__atomic_store_n`/`__atomic_load_n` -(`__ATOMIC_RELAXED`). `imageDataWritten()` runs on the loop task, from `serviceBleRx()` -([main.cpp:513](../src/main.cpp)) and from `handleWiFiServer`, so the clock is a single- -writer plain global — no atomics, same argument as `g_commandOrigin`. The atomics -discipline is still required for the instance table above; it is just not required here. - -**Where it lives: with the token, not in the transport.** The clock is now keyed on -*ownership*, which makes it policy, not link state — and this repo has settled precedent -in that direction: [ble_transport.h:89-93](../src/ble_transport.h) records that the -loop-serviced deferred-work flags were moved out of the transport because they "encode -application policy, not link state, so exporting them from the transport seam was -backwards." So the clock sits beside the owner token in `link_owner.h/.cpp`: - -``` -uint32_t linkMsSinceOwnerCommand(void); // 0 when unowned -void linkStampOwnerCommand(void); // dispatcher, on a recognised owner command -void linkStampRefreshEnd(void); // endRefresh(), see below -``` - -One clock suffices because R1 admits one owner; R4's "each transport enforces its own -timer and constant" is satisfied by the *constants* differing — BLE's 120 s local define -against LAN's `OD_LAN_READ_TIMEOUT_S` — not by duplicating the clock. - -**The clock must not run during a refresh.** `epdRefreshInProgress` brackets a *blocking* -call on the loop task ([display_service.cpp:2446-2467](../src/display_service.cpp), -[:3358-3368](../src/display_service.cpp)): `loop()` does not execute for the refresh's -duration, but wall-clock time passes. A naive `millis() - lastStamp` accrues the whole -refresh and can drop an actively engaged client the instant `loop()` resumes. - -This is also what answers the intake-stamping rationale that has now been dropped. That -draft stamped at intake because a loop-side stamp would record when `loop()` *drained* a -frame rather than when it arrived, inflating silence by whatever the loop was blocked on -— and the thing it is blocked on is a refresh. The refresh exclusion addresses that -directly and correctly; intake timing addressed it only as a side effect, while getting -the definition of activity wrong. - -Implementation requirements for the exclusion, per R4: - -- **A loop-side edge detector cannot see the edge** — both transitions happen inside the - blocking handler. The re-stamp must be invoked *at* the transition, via a single - `endRefresh()` helper that **both** bracket sites call, not by polling the flag. Both - sites currently assign `epdRefreshInProgress = false` inline; the helper replaces both - assignments, so a future third refresh path cannot forget it. -- **Re-stamp the current owner's clock only**, and only if the same instance identity - still owns the slot. -- Re-stamping can only ever *delay* a drop, never cause a spurious one — which is why it - is safe to apply unconditionally at the transition. - -**The baseline is the later of admission, last recognised command, and last refresh -end** — the init fix. A naive "`UINT32_MAX` until first command" would put a freshly -admitted, still-silent client instantly past any timeout: - -``` -linkMsSinceOwnerCommand() := millis() - max(admittedMs, lastCommandMs, refreshEndMs) - // 0 when unowned -``` - -A new client thus gets the full idle window before its first command. On LAN the -baseline is **TLS handshake completion**, not TCP accept (R7a) — handshake traffic is not -a command; see Phase 3. - -### `abortToKnownState(reason, bool dropLink, LinkId ownerId)` - -New `src/session_guard.h/.cpp` (both targets; LAN parts under -`#ifdef OPENDISPLAY_HAS_WIFI`, **not** `TARGET_ESP32` — `esp32-N4` is ESP32 without -WiFi). `ownerId` is the identity the abort acts for, and it is a **parameter, not a -re-derivation**: ordinary callers pass a snapshot of `linkOwnerId()` taken before -calling (or use a two-argument convenience overload that snapshots it); the terminal -caller passes the identity `linkMarkTerminal()` displaced, because by then the word -reads terminal and a re-derivation would act for the wrong identity. Steps 10 and 11 -below consume it. Ordered teardown: - -1. Log first (one line, the reason). -2. Optional client NACK — **skip when `dropLink`** (the link is about to go). -3. `cleanupDirectWriteState(true)` — panel power + touch-resume. -4. `cleanupPartialWriteOnDisconnect()`. -5. `resetPipeWriteState()`. -6. **new** `resetChunkedWriteState()` — a real primitive replacing the open-coded - inline clears in `communication.cpp`; call it here and from those sites. -7. **new** `touchForceResume()` — asserts the suspend counter reached 0 and clears - `directWriteTouchSuspended` even when teardown bypassed `cleanupDirectWriteState`. - A new public idempotent API, not an existing primitive. -8. `clearEncryptionSession()` — **new on the disconnect path**; today crypto state - survives a link drop. -9. **new** ring reset primitives — `bleTxQueueReset` and `bleRxQueueReset`. - Discarding RX outright is sound because callback filtering (requirement 1) means - every frame in it passed the owner check when written. `bleRxQueueReset` follows - the SPSC contract in CONNECTION_POLICY requirement 6: **consumer-side discard - only** — acquire-load the producer's head, release-store that snapshot into the - tail, write neither the head nor any slot — so it cannot race an in-flight push; - and it must never run while a peek is outstanding (every returning abort caller is - loop-side after RX consumption; deep sleep, the one in-dispatch caller, never - returns). A frame the owner writes *after* this step, during step 10's wait, is - deliberately not re-flushed: it carries the departing instance's tag - (requirement 6) and fails the dispatch check once step 11 releases — the same - construction that makes an expired R3a wait harmless. An earlier draft flushed TX - only, which left R6's "both rings drained" unmet and the teardown window open. -10. If `dropLink`: drop **by the owner's transport** — the token records it, and this - routine is not BLE-only (the transfer watchdog that calls it is origin-agnostic). - `OWNER_BLE` → `bleDropAndWait(ownerHandle)`: request termination, then wait - cooperatively until the link is actually down or `OD_BLE_LINK_DOWN_WAIT_MS` - expires (R3a); not the bare seam call. `OWNER_LAN` → a new public - `wifiLanDropOwnedSocket()` seam in `wifi_service.cpp` — needed because - `tlsCloseSession()` is file-static ([wifi_service.cpp:281](../src/wifi_service.cpp)), - so the abort cannot reach the pieces directly. It performs the LAN-local subset of - today's `disconnectWiFiServer()` ([wifi_service.cpp:794-808](../src/wifi_service.cpp)): - `tlsCloseSession()`, `wifiClient.stop()`, `wifiServerConnected = false`, - `tcpReceiveBufferPos = 0` — everything *except* `clearEncryptionSession()` and - `requestTransferSessionCleanup()`, which are this routine's own steps 8 and 3–5, - so the two never nest. A TCP close is synchronous; no wait bound applies on LAN. -11. `linkRelease(ownerId)` — full-triple release, and **strictly after** step 10. - -**Steps 10 and 11 are ordered, and that order is the whole of R3a.** An earlier draft -released the token in the same breath as *requesting* the disconnect, which would let a -new connection be admitted while the old link was still physically up. If the wait in -step 10 expires the release still happens — expiry is an early exit, not a failure — and -the stale link is inert by construction: its writes are filtered as non-owner, and its -late disconnect is inert on stale epoch (7b rows 4 and 9). That is the guarantee that -let R3a drop the cross-pass `DROPPING` state an intermediate draft had introduced. - -Note the asymmetry with step 2: the NACK is skipped when `dropLink` because the link is -about to go, whereas Phase 4's auth-abuse drop must *deliver* its final `FE` first. Phase -4 therefore runs its own bounded TX barrier **before** calling the abort, rather than -asking the abort to hold the link open — see Phase 4. - -**Buzzer and LED are NOT stopped — deliberately.** An earlier draft added -`buzzerStop()` / `ledFlashStop()` as step 8. That is wrong: buzzer and LED are -user-facing *effects*, not session state. A client that fires a buzz and immediately -drops the link is a normal pattern — command, then disconnect to save power — and -truncating the buzz mid-note defeats the command's entire purpose. Nothing about a -playing melody corrupts or confuses a later connection, unlike a half-open pipe -session, a suspended touch input, or a live crypto session. And because this policy -fires the abort far more often than a plain disconnect once did (idle timeout, -transfer watchdog, auth-abuse), the regression would be correspondingly more visible. -Both are bounded and self-terminating (see ground truth), so leaving them running -cannot wedge anything. No stop step is added here. - -**The one exception is not an exception to this.** Deep sleep does silence both, because -sleep stops the clocks the effects run on — but that lives in the deep-sleep path and -calls the wrappers directly. `abortToKnownState` never silences anything, including on -the deep-sleep call in the invocation set below. Keeping the two apart is what stops a -future edit from "unifying" them and quietly truncating every buzz on an idle drop. - -**Panel power is NOT force-killed here — deliberately.** An earlier draft added an -`epdSessionForceOff()` step "unless refreshing". That is wrong: `epdSessionForceOff()` -powers off every state except `PWR_OFF`, **including `PWR_WARM`** (the only early -return is `if (pwrmgmState == PWR_OFF) return`, -[display_service.cpp:420-421](../src/display_service.cpp)) — a disconnect during a -refresh is deferred, so by the time abort runs the panel can be WARM with -`epdRefreshInProgress` false, and the step would kill exactly the panel that must -survive. Panel power is handled correctly by steps 3–5: `cleanupDirectWriteState` -forces off only a `PWR_ACTIVE` (mid-transfer) session and no-ops on WARM, matching -the existing "ACTIVE-only teardown" invariant in `serviceBleDisconnectCleanup`. So a -WARM keep-alive panel survives an abort — including an auth-abuse or idle drop of a -client while the panel is warm from a prior push. - -Idempotent and loop-task-only: every step is either already a no-op when its state -is inactive, or made one. - -### The complete invocation set - -Collected here rather than left implicit across three phases, because the value of a -single shared teardown routine depends entirely on every teardown actually reaching -it. Three callers, and one governing invariant: **`dropLink=false` iff no drop is -wanted from the abort — either the link is already gone (the disconnect-cleanup case) -or the whole stack is about to be torn down with admission terminally gated (the -deep-sleep case, via `linkMarkTerminal()` below)**. - -| Condition | `dropLink` | Phase | -|---|---|---| -| Disconnect event serviced: `s_disconnectCleanupPending && !epdRefreshInProgress && !ownerStillUp`, **and the event's identity matches the owner** (7b) | `false` | 2 | -| Deep sleep, forced or idle — **after `linkMarkTerminal()`, before `ble.end()`** (R7e row 3) | `false` | 2 | -| `serviceIdleTimeout()`: owned **by BLE** `&& !epdRefreshInProgress && linkMsSinceOwnerCommand() > OD_BLE_IDLE_TIMEOUT_MS` — **no** `transferActive()` gate, per R4 (LAN's reclaim is its own `OD_LAN_READ_TIMEOUT_S` path, per R4's per-transport rule) | `true` | 3 | -| Auth-abuse counter reaches its threshold, **after** the bounded TX barrier drains the `FE` or `OD_AUTH_ABUSE_FLUSH_MS` expires | `true` | 4 | - -**Deep sleep is a caller — for teardown uniformity, not for surviving state** -(CONNECTION_POLICY R7e row 3, which carries the twice-corrected rationale in full). -*Earlier drafts justified this with state surviving sleep — RAM in one draft, hardware -in the next; both false against the tree:* wake re-enters `setup()` with RAM reloaded, -only `RTC_DATA_ATTR` survives ([main.cpp:129-150](../src/main.cpp)); and the sleep -path already forces the panel off before sleeping ([main.cpp:812](../src/main.cpp)) -with touch re-initialised on wake ([main.cpp:238](../src/main.cpp)). The real reason: -deep sleep is a **mid-session exit** — forced sleep bypasses the live-link guard -([main.cpp:789](../src/main.cpp)) and the path does not arbitrate a LAN owner — whose -path hand-rolls a private teardown subset (panel force-off, advertising stop, stack -end, effect silencing). Routing the session half through the abort first makes sleep's -teardown identical to every other session end by construction, instead of a parallel -copy that every future session resource must be added to — the same anti-drift -argument that made the transfer watchdog a caller. The sleep path keeps its own sleep -quiescing on top: `epdSessionForceOff()` (WARM included — no panel sleeps powered; -this call must never move into the abort) and the buzzer/LED silencing below. - -**Order: `linkMarkTerminal()` first, then the abort, then `ble.end()`** (the R7e row 3 -ordering trap). Without the gate, the abort's step 11 frees the word while the owner's -link may still be up and advertising is still on — a connect on the host task could -win the freed word in that window and the new owner would be destroyed by `ble.end()` -with no abort ever run for it. With the word exchanged to `OWNER_TERMINAL` first, -claims fail for the rest of the shutdown, and wake reloads RAM clean. -`linkMarkTerminal()` **returns the displaced owner identity**, and that is the -identity the sleep path hands the abort to act for — after the exchange, -`linkOwnerId()` reads terminal, not the departing owner, so the abort must not -re-derive it. Step 11's `linkRelease(displacedId)` then finds the word not matching -and is naturally inert; `linkRelease` matches the full identity and never accepts the -terminal word itself, so nothing can CAS the gate back to zero. `dropLink=false` because -`ble.end()` takes the stack down immediately after; there is no link left to drop -politely, and no loop pass will service the resulting event. - -**Deep sleep also silences buzzer and LED — settled, and it is a *sleep* change, not an -abort one.** The abort leaves both running by design, and deep sleep cuts the clocks they -depend on, so at this one transition "let the effect finish" cannot hold: the effect -*cannot* finish. Of the two consistent resolutions, this plan takes **silence on the way -down** rather than making sleep wait via the `workInFlight` gate -([main.cpp:694-699](../src/main.cpp)) — sleep is never delayed by a playing effect. - -The argument is hardware state rather than symmetry. `enterDeepSleep` runs -`ble.stopAdvertising()` / `delay(200)` / `ble.end()` / `delay(100)`, then -`armButtonWakeSources()` and `powerLatchHoldForSleep()` -([main.cpp:806-836](../src/main.cpp)) — all outside `loop()`, so `buzzerService()` never -ticks through any of it. A tone still on is therefore not a melody playing out; it is a -driven pin held through teardown and into sleep, sounding continuously and drawing current -until the next wake. Waiting would only postpone that. - -Three scoping rules, each of which a careless implementation gets wrong: - -1. **In the deep-sleep path, never in `abortToKnownState`.** R6's carve-out is untouched: - an idle, auth-abuse or watchdog drop still leaves a melody playing. -2. **Deep sleep only — not every terminal transition.** Power-latch off (7e row 4) - deliberately *plays* a chirp on the way down, `passiveBuzzerPowerOffAlert()` immediately - before `powerLatchTriggerOff()` ([device_control.cpp:83](../src/device_control.cpp)). A - blanket "silence at every terminal transition" deletes that alert. -3. **ESP32-only.** `enterDeepSleep` sits inside `#ifdef TARGET_ESP32` - ([main.cpp:757](../src/main.cpp)), so nRF takes on nothing here. - -Placement: silence **before** `armButtonWakeSources()` / `powerLatchHoldForSleep()`, so -pin state is settled before the wake pads and latch hold are configured. Relative order -against `ble.end()` does not matter. The two public wrappers this needs are noted in the -ground truth above; `led_stop_internal`'s `clear_mode` should match `handleLedStop`'s -`true` ([device_control.cpp:587](../src/device_control.cpp)), so the observable result is -the same as the client having sent LED_STOP. - -**The other terminal transitions stay exempt** (R6 exception 2, R7e rows 1, 2, 4): nRF DFU -entry ([device_control.cpp:847-866](../src/device_control.cpp)), ESP32 DFU/reboot -([:880](../src/device_control.cpp)) and power-latch off ([:942](../src/device_control.cpp)) -all disconnect and then leave — the MCU resets, jumps to a bootloader, or loses power — so -"ready for a new connection" is meaningless and no loop pass will ever service the event. -Each may take a synchronous abort instead if that is ever wanted; the exemption is the -default. - -**Explicitly not a caller: refusing a contender.** Admission calls -`ble.disconnect(newHandle)` (or `incoming.stop()` on LAN) and nothing else — no -`abortToKnownState`, no `s_disconnectCleanupPending`, no `linkRelease`. The -incumbent's session must be untouched. This is the case most likely to be got wrong -in implementation, since refusal and teardown sit in the same handler and differ only -in which handle they act on. - -**Also not callers, deliberately.** `clearEncryptionSession()` at -[communication.cpp:66](../src/communication.cpp) (config reload) and -[encryption.cpp:261](../src/encryption.cpp) (session timeout) are crypto lifecycle, -not session aborts; they stay as they are. - -**Resolved: `checkTransferTimeouts()` is a caller.** The 15-minute watchdog -([display_service.cpp:584-638](../src/display_service.cpp)) routes its teardown through -`abortToKnownState(dropLink=true)` and stops carrying its own. There is exactly one -teardown routine, which is the whole point: this plan cites *that very function* as the -reason a shared routine is needed, so exempting it would have argued for the routine -while leaving the original drift source untouched. - -| Condition | `dropLink` | Phase | -|---|---|---| -| `checkTransferTimeouts()` fires on a direct-write or partial transfer past `TRANSFER_WATCHDOG_MS` | `true` | 2 | - -This is a **behaviour change**, deliberately taken, in three ways: - -1. **Crypto is now cleared.** The watchdog previously left the encryption session - intact. It no longer does. -2. **The link is now dropped.** `dropLink=true` rather than `false`, which follows - from (1) rather than being an independent choice: once the session is cleared, a - retained link is a confusing state — the client's next command draws - `RESP_AUTH_REQUIRED` with no event to explain it, and under Phase 4 those refusals - feed the auth-abuse counter until the client happens to re-authenticate. A dropped - link is an unambiguous signal, it frees the exclusive slot (CONNECTION_POLICY R1) - from a demonstrably broken client, and it makes the watchdog's semantics identical - to the idle and auth-abuse drops. The client reconnects and restarts the transfer — - which it had to do anyway, since the transfer state is gone either way. Note the - watchdog is **origin-agnostic** — both branches test transfer state, not origin - ([display_service.cpp:592,609](../src/display_service.cpp)), so a LAN transfer can - time out too — which is why step 10 dispatches on the owner's transport: a - timed-out LAN owner loses its socket, not an unrelated BLE handle. -3. **Teardown is no longer selective.** The two branches previously cleaned one - transfer half each; the abort clears all transfer state. Under one-client - exclusivity the halves are not independently owned, so this is a simplification - rather than a loss. - -The cost is that a legitimately slow-but-progressing transfer, cut off by the -from-START duration bound, now also loses its link and session. That is acceptable -because it must restart regardless, and because the real defect there is the -duration-vs-stall bound itself, recorded under residual risk. - -**Not folded in: the orphaned-pipe healer.** The third branch of -`checkTransferTimeouts()` (`pipeState.active && !pipeState.error && !directWriteActive -&& !partialCtx.active` → `resetPipeWriteState()`) is an *invariant repair*, not a -transfer timeout — it heals an internal inconsistency that should never arise. Dropping -a healthy client's link and session over an internal bookkeeping error would be -disproportionate. It stays as it is, and stays a plain `resetPipeWriteState()`. - -### Wire `serviceBleDisconnectCleanup` through it - -`serviceBleDisconnectCleanup` ([main.cpp:388-423](../src/main.cpp)) already defers -correctly and already checks `ownerStillUp`. Phase 2 routes its teardown body through -`abortToKnownState(..., dropLink=false)` (the link is already gone) so the disconnect -path and the abort path can never drift. Keeping two separate teardown paths is -exactly how the direct-write watchdog once tore down a panel while leaving its pipe -session live — a bug this branch already fixed in `checkTransferTimeouts`, and one a -single shared routine prevents from recurring. - -**No special nRF deferral is needed for the session clear.** An earlier draft called -for deferring `clearEncryptionSession()` on nRF to avoid a `memset(session_key)` -racing an inline `aes_ccm_decrypt`. That race does not exist in the current -architecture: nRF's write callback only *enqueues* -([ble_transport_nrf.cpp:148-156](../src/ble_transport_nrf.cpp)); all decrypt and -dispatch happen on the loop task in `serviceBleRx()` -([main.cpp:513](../src/main.cpp)), and `serviceBleDisconnectCleanup` is already -loop-task. The abort — session clear included — runs on the loop task, never -concurrently with a decrypt. No `nrfSessionClearPending` machinery. - -### Verification - -Disconnect mid-direct-write, mid-partial, mid-pipe, mid-chunked-config-write, and -mid-refresh (WARM survives); assert every flagged state is clean afterward, touch is -resumed, crypto cleared, **both rings reset**; assert a second transfer starts clean. -Deep sleep entered mid-transfer wakes with no residue (the R7e row 3 caller). - -The frame tag (requirement 6): frames queued by a departing instance — including one -written *during* the teardown window, after the step-9 ring reset — never dispatch -once the token is released, and never stamp the new owner's activity clock; a -reconnecting client (fresh epoch, possibly the same handle) starts with a ring whose -stale frames are dropped at dispatch, with the drop counted and visible in the log. - -Callback-boundary mechanisms, each checkable before any Phase 3 policy exists: a second -central's writes are dropped at the callback while the token is held; **its subscribe -does not disturb the incumbent's notify state**; and **it receives no notifications at -all** — the last is the live-leak fix and wants a sniffer or a second bleak client -reading, since a passing incumbent proves nothing about what leaked. - -The clock: `linkMsSinceOwnerCommand()` is 0 when unowned, ages only on true silence, is -**not** refreshed by a malformed or unknown-opcode frame (the R4 correction — send junk -that `bleRxQueuePush` happily accepts and confirm the clock keeps running), and is -re-stamped across a refresh so a client engaged either side of a ~16 s refresh is never -dropped. - -Host-buildable parts get unit tests: the `linkClaim`/`linkRelease` state machine on the -full triple; epoch discrimination — a claim carrying a reused handle with a new epoch -must not match the incumbent, and a release carrying a stale epoch must not release; and -the claim CAS under contention — two racing claims (host threads suffice) must end with -exactly one owner and one contender. Build all envs. - -Three seam-specific bench checks a build cannot cover. **The drop actually drops:** call -the seam from the loop task on both nRF and ESP32 and confirm the link goes down on a -scanner or the client — the 0x09 trap above is precisely a case where the code looks like -it worked, so "it compiled" proves nothing. **The drop waits, on the right predicate:** instrument -`bleDropAndWait()` and confirm it observes the owner's instance-table entry go down -before returning on a live peer, that the token is still held throughout — the R3a -ordering is invisible from outside — and that it returns promptly with a refused -contender still attached, the case where the aggregate `connectedCount()` would have -sat out its full bound. **The reason log is honest:** a real client disconnect logs a -sensible HCI reason, and a NimBLE host-layer reason now logs as `0x0xx` rather than -masquerading as an HCI code. - ---- - -## Phase 3 — Connection-exclusivity policy + idle drop - -**Goal:** the *policy* on top of Phase 2's mechanisms — refuse any contender while the -slot is held, and reclaim the slot from an incumbent that has gone silent. Phase 2 -already makes a second BLE central harmless (its writes, subscribes and notifications -are filtered, and it cannot own the token); Phase 3 makes it *clean* (actively -disconnected) and closes the idle-link hole. It consumes the instance table, the owner -token and `linkMsSinceOwnerCommand()` — all Phase 2 — and adds no new transport state. - -**Phase 3 is CONNECTION_POLICY R7 made executable.** The permutation tables there (7a -admission, 7b disconnect, 7c idle, 7d ordering, 7e terminal) are normative and are not -restated here; this phase says where each is enforced and what changes in the tree. -Any combination the tables do not list is a specification gap to take back to the -policy, not implementer's discretion. - -### The governing decision: admission never evicts - -**A contender is always refused while the slot is held. Reclaiming a slot is the job -of the idle timeout alone, never of the accept path.** These are two independent -mechanisms and this plan deliberately keeps them that way. - -An earlier draft made admission a three-way rule (refuse if the incumbent is -transferring or young-idle; *evict* it if idle past a threshold, then admit the -newcomer). That is rejected. What it bought — a faster reclaim when a stale link -lingers — is not worth what it cost: - -- **It made an incumbent's fate depend on whether someone else happened to knock.** - The same idle client is kept or killed for reasons it cannot observe, which is - hard to reason about and harder to test. -- **It needed a whole extra threshold** (evict-idle age) that this plan's own - residual-risk list already flagged as the one requiring the most conservative - tuning, since too aggressive a value refuses a legitimate reconnect. -- **It put a multi-step teardown at a stack-event boundary** — disconnect incumbent, - `abortToKnownState`, release token, then let the newcomer claim — with the newcomer - already connected throughout. Pure refusal never touches incumbent state at all. - -The cost accepted in exchange is that a returning client waits out the idle timeout -rather than ~10 s. That cost is smaller than it looks, and it differs by transport: - -- **BLE: mostly absorbed below us.** The firmware never sets a supervision timeout — - it takes whatever the central negotiates (commonly ~4–6 s). So an incumbent that is - genuinely *gone* is reaped by the link layer without firmware involvement, and the - idle timeout only has to handle a client that is alive and silent. Refusing a - contender in *that* case is arguably the correct answer anyway. -- **LAN: genuinely dependent on the timeout.** TCP has no supervision timeout; a - half-open socket persists indefinitely without keepalives. `OD_LAN_READ_TIMEOUT_S` - (30 s) is the only reclaim path, which is precisely why LAN already has one. - -### Within-pass ordering (R7d) — normative, not incidental - -**Fix the order first, because everything below depends on it.** The current loop order -is `serviceBleEvents()` → BLE RX → deferred disconnect cleanup → LAN accept/read -([main.cpp:624](../src/main.cpp)), and connect and disconnect flags are consumed -connect-first regardless of actual arrival order -([main.cpp:461-471](../src/main.cpp)) — exactly the ambiguity R7d removes. Without a -stated order, two conforming implementations pick different winners. Within one pass: - -1. **Owner disconnects** (7b) — the abort first, whose *final* step releases, so a - slot freed this pass is available to an admission decision in the *same* pass. - Never release before the abort: a claim CAS can succeed the instant the word is - zeroed, and an abort still running after that would tear down the new session. -2. **Contender refusal, and the LAN accept** (7a). Admission itself is the hook-side - CAS: for BLE it already happened — or failed — in the connect callback, so this - step only *refuses* live instances whose CAS failed; the LAN accept runs here - because the loop is its earliest hook, and its claim is the same CAS. No loop-side - rule picks a winner between transports; the word does. -3. **Inbound traffic**, which stamps the activity clock. -4. **Idle timeout** (7c) — last, so traffic parsed in step 3 counts. This is what - satisfies R4's ordering constraint for LAN, where inbound bytes may be sitting in the - socket when the deadline is evaluated. - -**But the authoritative arbitration point is the earliest transport hook — the BLE -connect callback and the LAN accept — not the loop.** Fixed loop ordering cannot -reconstruct true cross-transport arrival order: a BLE connect during a refresh and a LAN -socket queued in the listen backlog are not comparable by the time `loop()` resumes. The -loop order resolves *ties within a pass* only; the claim itself must be atomic at the -callback — mechanically, the one-word owner CAS from Phase 2 — and where the two -disagree the callback wins. Do not build correctness on step order alone. - -### Enforcement - -- **ESP32 admission — refuse, unconditionally.** Scanning the instance table, any live - entry whose `(handle, epoch)` is not the owner's is a contender: `ble.disconnect(its - handle)` and stop. Do **not** raise `s_disconnectCleanupPending`, do **not** - `linkRelease()`, do **not** inspect the incumbent's state at all — no `transferActive()` - test, no idle-age test. The incumbent's session is untouched by construction rather than - by a guard that could be got wrong. Note this is a **table scan, not an event handler** - (Phase 2 requirement 5): a refusal missed because two connects coalesced self-corrects on - the next pass, where an event-driven version would leak the contender permanently. - Because refusal is idempotent and inert, re-refusing an entry that is already tearing - down costs nothing. nRF gets the same refusal free from `begin(1,0)`; this bullet is the - ESP32 analogue. **The scan never admits**: admission is one CAS at each instance's own - connect hook, decided once and never revisited (7a rows 9–10) — a contender whose - refusal is still pending when the slot frees stays refused, and the freed slot goes to - the next *new* instance. Racing arrivals, including a BLE connect against a LAN accept, - are serialized by the word, not by scan or loop order. - - **7a row 4 is the case to test.** A contender reusing the incumbent's handle after a - stale link must be refused, and the *only* thing distinguishing it from the incumbent is - the epoch — which is why R2 allocates one for every instance, admitted or not. -- **Proactive idle drop — the sole reclaim mechanism.** Since admission never evicts, - this is the *only* way a held slot is ever released short of the client leaving. A - loop-serviced `serviceIdleTimeout()`: if the slot is owned **by BLE**, no refresh is - in progress, and `linkMsSinceOwnerCommand() > OD_BLE_IDLE_TIMEOUT_MS`, call - `abortToKnownState(dropLink=true)` — its step 10 drops the owner's link and waits for - link-down, its step 11 releases; the R3a order, all within the one pass (7c row 1). - This is the BLE side of R4's each-transport-its-own-timer rule: LAN's reclaim stays - its existing `OD_LAN_READ_TIMEOUT_S` path (whose teardown routes through the abort - per R6). An `#ifndef`-guarded define in the file that services it, not a wire/config - field. - - **There is no `!transferActive()` gate**, per - [CONNECTION_POLICY](CONNECTION_POLICY.md) R4, which supersedes an earlier draft of - this bullet. An in-flight transfer confers no protection: a client that goes silent - *during an upload* is precisely the case that wedges the device, and a transfer gate - would exempt exactly it. Idleness excludes only refresh-in-progress — via the - `endRefresh()` re-stamp Phase 2 builds, since `loop()` is blocked throughout a refresh - while wall-clock time passes (7c row 3). The from-START watchdog remains the backstop - for the remaining case: a transfer that keeps sending recognised commands but never - ends. - - *Default: `OD_BLE_IDLE_TIMEOUT_MS = 120000` (120 s).* Set deliberately generous, - and note this is **double** an earlier draft's 60 s — the reasoning inverted when - R4 landed, so the direction of the change is not an oversight: - - - While the idle drop was gated on `!transferActive()`, the timeout could only - ever kill an *idle* client, so erring short was cheap and a shorter value - shortened the lockout. - - R4 removed that gate. The timeout can now terminate an **in-progress upload** - whose client has gone quiet, so erring short no longer costs a stale session — - it costs a legitimate transfer. Conservative is now the safer direction. - - The cost is bounded and falls only on one case: a returning client waits up to - 120 s if a stale-but-*alive* incumbent holds the slot. An incumbent that is - genuinely gone is reaped by the link layer in ~4–6 s (the firmware sets no - supervision timeout, so the central's negotiated value applies), so the 120 s - lockout never applies to a crashed or out-of-range client. - - **120 s is settled.** It is a chosen value rather than a measured one, and it is not - gated on a measurement: implementation proceeds on it. What remains is *drift - detection*, not verification — the `py-opendisplay` assertion below fails if a client - change ever pushes legitimate inter-command silence toward 120 s, which is the same - treatment every other threshold here gets. Record the reasoning, not a pending - confirmation, in the comment on the define. - - *Why it cannot live where its LAN cousin does, and what that costs.* - `OD_LAN_READ_TIMEOUT_S` is **not** a local tunable: it is defined at - [opendisplay_protocol.h:984](../include/opendisplay_protocol.h) and documented at - `:84` and `:945` as a client-visible contract ("the server drops a client only - after `OD_LAN_READ_TIMEOUT_S` with no traffic"). Its home is the wire header - because the client is entitled to know the number. The hard constraint forbids - touching that header, so the BLE timeout is forced local — deliberately - asymmetric with the LAN one, and invisible to clients except through the - client-side CI assertions below. That is the accepted trade, not an oversight: a - wrongly-dropped BLE client reconnects, so the cost of the client not knowing the - exact number is bounded. If the BLE timeout ever needs to be genuinely - client-visible, that is a wire change and goes through `../opendisplay-protocol` - first — at which point it belongs in the protocol header beside its LAN cousin, - not in firmware. - - *Deep sleep:* the idle drop leaves `lastActivityMs` and the deep-sleep quiet window - alone — this is a *link* drop, not a sleep decision. After it `connCount` falls to 0, - `pollActivity` stops re-stamping, and the existing idle/deep-sleep path takes over. - (Separately, deep sleep itself becomes an abort caller — R7e row 3, Phase 2. That is - a change to the *sleep* path, not to this one.) -- **LAN, one consistent model — including LAN-vs-LAN.** The token is connection-level, - so a LAN accept while *any* transport owns the slot is refused (`incoming.stop()`), - and symmetrically a BLE connect while LAN owns is refused. `handleWiFiServer` accept - ([wifi_service.cpp:869-877](../src/wifi_service.cpp)) gains the token check and - `linkClaim({OWNER_LAN, 0, epoch})` — the same CAS the BLE connect callback uses, so - cross-transport arbitration is the word itself, not loop ordering. - - **The claim happens at TCP accept, before the TLS handshake** (R7a row 2). The - handshake is driven incrementally across later loop passes - ([wifi_service.cpp:905-920](../src/wifi_service.cpp)), so deferring the claim until it - completes would leave the slot free for a BLE connect or a second socket in the - meantime — a race the accept-time claim closes. Three consequences follow, and each is - a real code change on that path: - - - A second accept *during* the handshake is refused (rows 5/7 apply) — it does not get - to displace a half-established session. - - **TLS handshake failure is an owner disconnect**: the existing - `disconnectWiFiServer()` at [wifi_service.cpp:918](../src/wifi_service.cpp) must now - run R6's abort and release the token, or a failed handshake strands the slot until - the idle timeout. - - **Handshake traffic is not activity.** The idle baseline starts at handshake - completion, which the code already stamps ([wifi_service.cpp:910](../src/wifi_service.cpp)). - - **No separate handshake deadline is added, deliberately.** An earlier reading required - one. It is unnecessary: because the baseline does not start until the handshake - completes, a handshake that never finishes leaves the clock at its accept-time stamp and - the existing 30 s `OD_LAN_READ_TIMEOUT_S` drop fires. A dedicated deadline would only - tighten that window — not worth a second tunable until something shows 30 s is too slow. - - **This is a behaviour change for LAN, not just a new cross-transport check.** Today - that path is unconditional last-in-wins: a second TCP accept tears down TLS, clears - crypto and stops the previous client, with no test of what it was doing. Under the - rule above it becomes a refusal, which matters more on LAN than on BLE because TLS - bypasses app-layer auth by design — so today *any* host on the network can kill an - in-flight display push simply by opening a socket, with no credentials. Refusing - closes that. - - **A pre-existing bug on the same path, fixed by the same change.** The accept-side - eviction clears TLS/crypto but never calls `requestTransferSessionCleanup()` — unlike - `disconnectWiFiServer()`, which does ([wifi_service.cpp:807](../src/wifi_service.cpp)). - So an evicted client's in-flight direct-write/pipe/partial state stays live, and - because both clients are `ORIGIN_LAN`, `frameOwnsSession()` does not stop the *new* - client's frames from landing in the *evicted* one's transfer — the same class of hole - as the ESP32 multi-central case. Making the path refuse rather than evict removes the - bug by removing the eviction; nothing is left needing the cleanup call. - - LAN's reclaim path is unchanged in *mechanism* and remains `OD_LAN_READ_TIMEOUT_S` - ([wifi_service.cpp:952](../src/wifi_service.cpp)), which already drops an idle client - after 30 s and is already ungated by transfer state — so LAN needs no new timer, only - R4's two semantic corrections. `lastLanActivityMs` is close to a true activity clock - already: stamped at connect, TLS-handshake completion, bytes read and frame dispatch - ([wifi_service.cpp:886,910,946,972](../src/wifi_service.cpp)) and — unlike BLE's - `lastActivityMs` — never re-stamped merely for being connected. - - *The `got > 0` stamp must go.* `:946` stamps on **any bytes read**, not on a - recognised frame, so a plain-mode flooder defeats both the 30 s read timeout and any - policy built on that clock. This is the same defect the BLE clock had in intake form, - and Phase 2 fixes both at once: stamping moved to `imageDataWritten()`, which LAN also - dispatches through, so LAN inherits "recognised command from the owner" without a - second implementation. Delete the `:946` stamp rather than adding a parallel one — - two clocks for one rule is how they drift. - - *The refresh exclusion applies to LAN too.* `endRefresh()` re-stamps the owner's - clock whoever the owner is; a LAN client mid-push across a ~16 s refresh is exposed to - exactly the same spurious drop as a BLE one. - - *The 30 s constant is settled and unchanged.* `OD_LAN_READ_TIMEOUT_S` satisfies R4 as - it stands: it is already ungated by transfer state, which is the substance of the - rule, and R4 governs only its **semantics** — which stamp counts as activity, and the - refresh exclusion — both firmware-local and both fixed above. Its **value** is a - wire-header contract and out of bounds here, so "does 30 s satisfy R4" is not an open - question but a closed one: yes, with the two stamping corrections applied. The - asymmetry with BLE's 120 s is deliberate and follows from where each constant is - allowed to live. - -### One thing to get right (easy to assume wrong) - -The ESP32 central cap **cannot** be forced to 1 with a `-D` build flag — the -`CONFIG_BT_NIMBLE_MAX_CONNECTIONS = 3` in the precompiled `sdkconfig.h` wins, and a -local override is silently inert. Exclusivity must be enforced in firmware, as above, -not by config. R1 is therefore phrased in terms of **admission, not physical links**, -and that is not a weakening: NimBLE establishes a second central's link *before* it -calls `onConnect` ([ble_transport_esp32.cpp:81-93](../src/ble_transport_esp32.cpp)) and -the server API has no pre-connection filter, so a transient second *physical* link -necessarily exists while it is being refused. What R1 constrains is what is -*serviceable*; what R3's callback-side filtering constrains is what that transient link -can touch, which is nothing. An implementation that reports "two links were briefly up" -is conforming; one where the second link moved any shared state is not. - -(`serviceBleDisconnectCleanup`'s `ownerStillUp` guard is **already** unconditional as of -PR `#132`, [main.cpp:404-409](../src/main.cpp) — Phase 3 adds policy, not that -restructuring.) - -### Verification - -Admission (7a): two centrals against one ESP32, second always refused whatever the -incumbent is doing; **row 4** — a contender reusing the incumbent's handle after a stale -link is refused, which is the epoch's whole justification and the one case a -handle-only implementation passes by accident; **row 10** — a contender still connected -when the incumbent departs is *not* admitted: it stays refused and the slot goes to the -next fresh connect; BLE⇄LAN arbitration both directions; a -second LAN client refused rather than evicted, with the first's transfer surviving. - -Refusal is inert (R3), the property most likely to be got wrong since refusal and -teardown sit in the same handler: a refused stranger's connect **and** disconnect leave -the incumbent's transfer, crypto session, notify state and panel power untouched — check -the `esp32-N4` no-WiFi path specifically. Two coalesced connects (both arriving inside one -refresh block) still end with both contenders refused, which is the table-scan property -rather than an event-handler one. - -Idle drop (7c): a client that connects, authenticates and idles past the timeout is -dropped; a fresh client gets the full window before its first command (the init fix); a -streaming client is not dropped; a keepalive-sending client is not; **a client that goes -silent mid-upload IS dropped** — the R4 case, and the one an earlier `!transferActive()` -gate would have exempted; a client engaged either side of a ~16 s refresh is **not** -dropped (the `endRefresh()` re-stamp). After a drop the device returns to -advertising/idle, and the slot is claimable by a new client in the same pass a disconnect -freed it (R7d step 1 before step 2). - ---- - -## Phase 4 — Auth-abuse disconnect - -**Goal:** drop the link after a bounded run of BLE commands that never authenticate, -so an unauthenticated peer cannot hold the exclusive slot (on ESP32, the *only* slot -the owner token would otherwise hand it) indefinitely. - -### Design (fresh — a prototype exists off-branch but is not adopted wholesale) - -`feat/nonce-replay-and-auth-guard` carries `fbc7ab2`/`b4fafb5`, which implement this -but (a) drop the link **inline** on nRF — flagged as loop-starving — and (b) place -two `serviceBleAuthAbuseDisconnect()` call sites in the per-target loop arms that -`#132` then merged, so they no longer have a home. Reuse the *counter* logic; drop -the placement. - -- **Count only BLE.** A per-session counter of consecutive commands answered with - `RESP_AUTH_REQUIRED`, incremented **only when `g_commandOrigin == ORIGIN_BLE`**. - The generic auth gate at [communication.cpp:584,591](../src/communication.cpp) and - the config-write sites at `:410,472` are also reachable via the LAN-TLS bypass, - where app-layer auth is intentionally unnecessary; counting those without the - origin gate would let LAN-TLS traffic increment a counter that disconnects **BLE**. - Reset to 0 on any authenticated command. -- **Threshold 10** (justify against the client's legitimate handshake, which - authenticates within one exchange — 10 is generous). Overflow raises - `s_authAbuseDropPending`; a loop-serviced `serviceBleAuthAbuseDisconnect()` handles - it. One placement, both targets — the whole reason Phase 2's seam and the unified - loop exist. Per the threshold discipline above, the count lives `#ifndef`-guarded in - `communication.cpp` beside the auth gate that increments it, and - `OD_AUTH_ABUSE_FLUSH_MS` beside the servicer that enforces it — not in a shared - header. -- **Best-effort delivery of the final `FE` before dropping — a real barrier, not one - flush, and honestly not a guarantee.** The last `00 xx FE` *should* reach the client - so it is not dropped without a stated reason. A single `serviceBleTx()` then - disconnect does not even get the frame to the stack reliably: TX deliberately - retains an entry on mbuf backpressure or a missing CCCD - ([command_queue.cpp:190](../src/command_queue.cpp)), and the final response may not - even enqueue if the 10-slot ring is full. So the drop is gated on a bounded barrier: - `serviceBleAuthAbuseDisconnect()` drains TX each loop pass and proceeds only once the - TX ring has drained the `FE` **or** a bounded deadline (`OD_AUTH_ABUSE_FLUSH_MS`, - ~500 ms) elapses — then it drops regardless, so a wedged/un-draining client cannot keep - the abuser attached. **An empty ring proves stack acceptance, not receipt**: the ring - advances when `notify()` returns true ([command_queue.cpp:190-199](../src/command_queue.cpp)), - which means NimBLE queued an *unacknowledged* notification — nothing confirms it went - on air. So after the drain, the servicer dwells - `min(remaining deadline, one negotiated connection interval + margin)` before - dropping. The interval is the central's choice, not ours; today both targets read the - negotiated value only inside link-tune *logging* - ([ble_transport_esp32.cpp:74-77](../src/ble_transport_esp32.cpp), - [ble_transport_nrf.cpp:81](../src/ble_transport_nrf.cpp)) and `BleTransport` exposes - no accessor — so Phase 2's transport work adds one (`connIntervalMs(handle)`, or a - value published at the link-tune callback), with a conservative fallback - (`OD_AUTH_ABUSE_DWELL_FALLBACK_MS`, ~50 ms) for when no negotiated value has been - seen. **Any dwell truncated by the deadline — including to zero — is the best-effort - case and may forfeit the `FE`**; only a drain early enough for the full - interval-plus-margin dwell makes on-air delivery *expected* rather than hoped for. - That is as far as best-effort can go without an indication — a wire change this plan - is forbidden. - Then `abortToKnownState(dropLink=true)`, whose step 10 is itself the R3a bounded - wait for link-down before the token is released. - - **Two bounded waits in sequence, and they compose rather than conflict** — this is the - shape CONNECTION_POLICY R3a predicts. The flush barrier runs *before* the abort because - the abort's step 2 deliberately skips the client NACK when `dropLink` (the link is about - to go); asking the abort to also hold the link open for a response would put two - contradictory jobs in one routine. So the ordering is: drain the `FE` (bounded) → - `abortToKnownState(dropLink=true)` → request termination and wait for link-down - (bounded) → release. Both waits are bounded, both proceed on expiry, and neither - treats expiry as a failure — but their mechanics differ: the flush barrier spans - loop passes (`serviceBleAuthAbuseDisconnect()` drains TX each pass), while the R3a - wait inside the abort ticks on its plain bounded `delay()`. - -### Depends on - -Phase 2 (the seam and its R3a wait, `abortToKnownState`, the owner token) and Phase 3 -(it slots into the same admission/idle policy layer). - -**Phase 4 is now an OPTIMISATION, not a correctness requirement — this changed -during Phase 3.** An earlier revision of this section said Phase 3 depended on Phase 4 -for one case: because the activity clock stamped any *recognised* command before the -auth gate, a peer flooding recognised-but-never-authenticating commands kept its clock -fresh forever, so only the auth-abuse counter could reach it. The two were called -exhaustive. - -They were not, and the fix removed the dependency rather than patching it. That rule -let `CMD_FIRMWARE_VERSION` pin the slot too — it is dispatched *ahead* of the auth -gate, so it never drew `RESP_AUTH_REQUIRED` and would never have incremented the -counter either. Phase 3 therefore narrowed what counts as activity: the two -handshake/discovery opcodes never stamp the clock in any configuration, and where an -auth gate exists a command must be past it. - -The consequence for this phase: a peer that never authenticates now ages normally and -**the idle timeout drops it after `OD_BLE_IDLE_TIMEOUT_MS`**. Phase 4 no longer closes -a hole; it shortens a 120 s reclaim to roughly one exchange, and gives the client an -explicit reason (`RESP_AUTH_REQUIRED`, then a deliberate drop) instead of a silent -timeout. Worth having, and cheap — but it should be scheduled on that value, not on a -correctness argument that no longer applies. - -### Verification - -A BLE peer sending N unauthenticated commands is dropped at the threshold with the -`FE` observed **on air** first *when the drain and the full interval-plus-margin dwell -both complete inside the deadline* (a sniffer, necessarily — ring state proves only -stack acceptance, and the barrier is the subtle part); a deadline-truncated dwell may -forfeit the `FE` by design; -the drop still happens within the deadline if the client stops reading; a legitimate -client authenticating on its first exchange is never dropped; the counter resets -across a good command; **LAN-TLS traffic never increments it**; on nRF the drop is not -loop-starved. - ---- - -## Verification model - -Every phase distinguishes two states, because "the code merged" and "the gap -closed" are not the same claim. Phase 1 is the live example: it is shipped and -host-tested, yet its entire hardware matrix is unrun — landed, not closed. - -- **Landed** = builds on all envs + host tests pass. A phase may merge here. -- **Closed** = its companion HIL script has passed on **both** an nRF and an ESP32 - board. The plan tracks a phase as open until then. - -The HIL scripts are the executable form of each Verification section, under -`tests/`, pytest driving a real device through `py-opendisplay`/bleak -(`tests/serial_stall_test.py` is the existing template): - -| Phase | Script | Asserts | -|---|---|---| -| 1 (retroactive) | `test_nonce_gap.py` | a transfer survives a forced >256 forward counter gap; a nonce-dropped `0x0081` frame is repaired by the client's SACK path and the upload completes | -| 2 | `test_abort_state.py` | disconnect mid-{direct, partial, pipe, chunked-config, refresh}; every flagged state clean afterward, touch resumed, crypto cleared, both rings reset, WARM panel survives; a frame written by the departing owner during the teardown window never dispatches after release (the requirement-6 tag); a buzzer melody and LED pattern in flight at the abort **keep playing to completion**; deep sleep entered mid-transfer wakes with no residue (R7e row 3) and **silences a playing buzzer/LED without waiting for it** — with the pin confirmed quiet through sleep, not merely the state flag cleared; power-latch off still sounds its shutdown chirp; the drop holds the token until link-down (R3a) | -| 2 | `test_link_isolation.py` | a gatecrasher's writes are dropped at the callback while the token is held; its subscribe does not move the incumbent's notify state; **it receives no notifications** — the live-leak fix, needs a second reader or a sniffer; `linkMsSinceOwnerCommand()` is 0 when unowned, ages on true silence, is **not** refreshed by malformed or unknown-opcode frames, and is re-stamped across a refresh; a stale-epoch frame left queued across a reconnect neither dispatches nor stamps the clock | -| 3 | `test_exclusivity.py` | two centrals against one ESP32 → second always refused, incumbent idle or transferring; **7a row 4** — a contender reusing the incumbent's handle after a stale link is refused (the epoch case a handle-only build passes by accident); **7a row 10** — a contender still connected when the incumbent departs stays refused, and the slot goes to the next fresh connect; two connects coalesced inside one refresh block still end with both refused (the table-scan property); a second LAN client is refused, not evicted, and the first's transfer survives; BLE⇄LAN arbitration both directions; refused-stranger connect **and** disconnect do not tear down the incumbent (the `esp32-N4` no-WiFi path) | -| 3 | `test_idle_drop.py` | a fresh silent client survives its first window then is dropped; a streaming client is not; a keepalive-sending client is not; **a client silent mid-upload IS dropped** (the R4 case a transfer gate would exempt); a client engaged either side of a ~16 s refresh is not; a LAN flooder sending unrecognised bytes is dropped at 30 s despite the traffic; the device returns to advertising after the drop | -| 4 | `test_auth_abuse.py` | N unauthenticated BLE commands → drop at the threshold with the `FE` on air first when the drain and full dwell complete inside the deadline (sniffer — ring state proves only stack acceptance); drop still occurs within the deadline if the client stops reading, forfeiting the `FE` by design; a first-exchange auth is never dropped; the counter resets across a good command; LAN-TLS never increments it; on nRF the drop is not loop-starved | - -**Threshold drift is caught in the client's CI, not ours.** These thresholds -assume specific `py-opendisplay` behaviours (handshake authenticates -within one exchange; retransmits carry fresh, higher counters; keepalive cadence). -Add an assertion of each to `py-opendisplay`'s test suite, so a client change that -would invalidate a firmware constant breaks *there* — the same move already used -for the `0x04`-NACK reasoning recorded in `sendPipeNack()`. Every -threshold-triggered drop also logs at WARN with the measured value, so field tuning -has data rather than guesses. - -## Cross-cutting: what still has no watchdog - -Two distinct gaps, both out of scope, both named here rather than assumed away. - -**A stuck refresh (CONNECTION_POLICY R5).** R4 excludes refresh from idleness — the -`endRefresh()` re-stamp is precisely that exclusion — so **a refresh that never completes -is not caught by the idle timeout, by construction**. That is a deliberate trade, not an -oversight: without the exclusion, an actively engaged client is dropped the instant a -~16 s refresh ends. But it means the exposure moves rather than closing, and on FastEPD -targets it is total: `fastepd_wait_refresh()` ignores its timeout argument outright -([display_fastepd.cpp:277-280](../src/display_fastepd.cpp)), so the naive "panel never -signals done" case is fully unbounded. The `bb_epaper` path is bounded at 60 s -([display_service.cpp:803-831](../src/display_service.cpp)), which is a bound but not a -useful one for a session policy. - -R5 names the shape of the fix and this plan does not build it: no loop-serviced watchdog -can observe a stuck refresh, because `loop()` is blocked for its entire duration, so it -needs an independent timebase (hardware WDT fed from `loop()`, a timer ISR, or a separate -task); recovery must run from a safe context, which realistically means an MCU reset -rather than panel/SPI teardown from an ISR; and there is no refresh start timestamp in the -tree, so the watchdog must add one. The one thing Phase 2 contributes toward it is -`endRefresh()`: a single helper both bracket sites call is the natural place a future -start/stop timestamp pair lands. - -**Loop liveness.** None of Phases 2–4 add a loop-liveness monitor either. A `loop()` -genuinely wedged inside a non-yielding operation is still uncaught on nRF (no watchdog) -and on ESP32 (`loop()` unsubscribed from the TWDT). The realistic mitigation — subscribe -`loop()` to the ESP32 TWDT and add an nRF hardware WDT fed from `loop()` — is a separate -effort whenever it is -taken up; it is the true "supervisor," and it is none of the four phases here. - -## Deliberately not changed - -- No wire/protocol/config-schema change (hard constraint). -- No `include/opendisplay_protocol.h` or `include/opendisplay_structs.h` edit — which is - what forces `OD_BLE_IDLE_TIMEOUT_MS` to be firmware-local while `OD_LAN_READ_TIMEOUT_S` - stays a client-visible contract in the wire header. -- **No per-connection command queue.** CONNECTION_POLICY's hard constraint: one RX ring - and one TX ring, shared by all transports. The instance table this plan adds is - metadata only (~8 bytes per slot); nothing that holds frames is ever replicated per - connection. Callback-side write filtering (Phase 2 requirement 1) is what makes that - possible — with only the owner's frames entering the ring there is never a second - client's traffic to separate. The requirement-6 identity tag adds four bytes per - slot — per-frame metadata in the one ring, never a second ring — and supersedes - `bleRxQueueDiscardTo`'s boundary flush, which Phase 2 retires. -- The from-START transfer watchdog stays as the backstop; Phase 3's idle drop is - additive, not a replacement. -- The orphaned-pipe healer in `checkTransferTimeouts()` stays a plain - `resetPipeWriteState()` — it repairs an internal invariant, and dropping a healthy - client's link over a bookkeeping error would be disproportionate. -- The nonce subsystem (Phase 1) is not reopened. - -## Residual risk (honest list) - -These are the gaps this plan **cannot** design away, distinct from the ones it now -tracks as work (HIL verification, and the client-side drift assertions — those have owners -and exit criteria above, so they are no longer "risk"). Threshold *selection* is no longer -on either list: every value except the R3a wait bound is settled above. - -- **No loop-liveness watchdog** (see the watchdog section above). A `loop()` - wedged inside a non-yielding operation is still uncaught on nRF, and a true hard - fault is unrecoverable there. Deliberately left as a separate future effort. -- **No refresh watchdog, and the exposure is now *explicit* rather than latent** - (CONNECTION_POLICY R5). R4's refresh exclusion is a deliberate hole in the idle - timeout: a refresh that never completes is not caught, and cannot be, since the clock - is re-stamped at the transition. On FastEPD targets there is no bound anywhere in the - path. This plan makes the situation no worse — the exclusion only ever *delays* a drop - — but it does make the idle timeout unable to serve as an accidental backstop, which - before R4 it arguably was. R5 is the named owner of the gap; it is not scheduled here. -- **The R3a wait can expire with the link still up.** The bound is sized for a few - connection intervals, so an unresponsive peer can outlast it. This is the least - consequential item on the list because expiry is an early exit rather than a failure: - the abort runs regardless, and the stale link is inert by construction — its writes are - filtered as non-owner and its late disconnect is inert on stale epoch. The residual - exposure is a physical link lingering until the link layer reaps it at ~4–6 s, holding - no slot and touching nothing. -- **Thresholds remain heuristics even though they are settled.** *Settled* means decided - and not gated on a measurement — it does not mean proven. The mandatory - client-behaviour comment on each define, plus the client-side assertions, make the - assumptions legible and drift-detectable, but the numbers are still judgement calls - against a client that can change. The auth-abuse drop is self-limiting (a - wrongly-dropped client reconnects). The one that carries real weight is - `OD_BLE_IDLE_TIMEOUT_MS` (120 s): with admission refusing rather than evicting, it is - the sole path by which a held slot is ever reclaimed, and with R4 removing the transfer - gate it can also terminate a live upload. It is set generously precisely because the - second error is the worse one — but that trade is a judgement, and it is the number to - revisit first if field behaviour disappoints. The residual exposure it accepts is a - returning client waiting up to 120 s behind a stale-but-alive incumbent. -- **A wedged transfer is now mostly caught, but not entirely.** CONNECTION_POLICY R4 - removed the `!transferActive()` gate, so the common wedge — a client that starts a - transfer and *goes silent* — is dropped by the idle timeout like any other silent - client. What remains uncaught is narrower: a client that keeps sending recognised - commands while its transfer never completes. That one is still bounded only by - `TRANSFER_WATCHDOG_MS`, because the from-START watchdog is a total-duration bound - rather than a stall timeout. The full fix is a genuine stall timeout gating on - *transfer active **and** progressing*, using the same activity clocks Phase 2 and LAN - already provide; it is a candidate for the next phase after this plan, alongside the - loop-liveness watchdog. -- **"Closed" depends on hardware nobody has run yet.** The verification model makes - this explicit rather than papering over it: until the HIL scripts pass on both an - nRF and an ESP32 board, every phase — including Phase 1 — is landed, not closed. diff --git a/docs/PLAN_FREEZE_PROOFING_2026-07-26.md b/docs/PLAN_FREEZE_PROOFING_2026-07-26.md deleted file mode 100644 index e04425c..0000000 --- a/docs/PLAN_FREEZE_PROOFING_2026-07-26.md +++ /dev/null @@ -1,296 +0,0 @@ -# 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_NRF_HARDWARE_WATCHDOG_2026-08-01.md b/docs/PLAN_NRF_HARDWARE_WATCHDOG_2026-08-01.md deleted file mode 100644 index d51da8d..0000000 --- a/docs/PLAN_NRF_HARDWARE_WATCHDOG_2026-08-01.md +++ /dev/null @@ -1,420 +0,0 @@ -# nRF Hardware Watchdog — Implementation Plan - -**Date:** 2026-08-01 -**Revision:** 2 — rewritten after an adversarial review found four blocking errors in rev 1. -Corrections are marked **[R1]** throughout; §10 lists them. -**Target:** the watchdog itself is `TARGET_NRF` only (nRF52840, Bluefruit / S140 v7.3.0). -The **module is portable** (W-0): a target-neutral `watchdog.h` with two self-gated -implementations, ESP32 stubbed (W-6). ESP32 behaviour is unchanged except that its existing -reset-reason decode relocates out of `main.cpp`. -**Goal:** recover the device when an *unbounded* wait — in `loop()`, or below it in a -vendored driver we cannot instrument — wedges the panel permanently. - ---- -**STATUS UPDATE (2026-08-03): timeout changed to 120 s, below this plan's D-1 value.** -`OPENDISPLAY_NRF_WDT_S` was dropped from the 300 s this plan derives and validates (§3.2, -§10 D-1) to **120 s**. This is a deliberate, confirmed choice made outside this plan's -analysis, not a correction to it — everything below about the 240 s worst case, the 1.25× -margin at 300 s, and W-2's pre-call feed policy is still accurate background, but the -headline numbers ("300 s", "1.25× margin") no longer describe the shipped value. - -**Consequence: the margin this plan relies on is gone.** At 120 s, a healthy -`REFRESH_FULL` on the 7-colour split-buffer panel (§3.1's ~240 s worst case) will trip the -watchdog *mid-refresh* on a device that isn't wedged. T2 (§8) — measuring that panel's real -span — is now a prerequisite for shipping to it, not a confirmation exercise; the "possible -sizing error" residual in §10 is elevated from unlikely to expected. Do not ship this -timeout to a 7-colour split-buffer panel without re-deriving it. - -**Also since rev 2:** two idle breadcrumb phases (`IDLE_OFF`/`IDLE_WARM`) replaced the -single shared `OD_WDT_PHASE_IDLE` used at `epdSessionForceOffLocked()`/`epdSessionRelease()`, -and four more (`PWRMGM_AXP2101`/`RAIL`/`PINS`/`WIRE`) were added inside `pwrmgm()` itself -(`main.cpp`) after a watchdog reset landed there — `pwrmgm()` had no breadcrumb coverage in -the original design. All 16 phase values in the 4-bit field are now in use (`watchdog.h`). -See the retained-breadcrumb reset-reason logging fix below for a related gap that was -losing the very reset-reason line this plan's boot log depends on. ---- - -## 1. What this closes - -Two accepted residuals and one new finding converge on the same gap. - -**Accepted residual 1 — nRF's unbounded I2C spins.** -[PLAN_PHASE2_BOUND_WAITS_2026-07-26.md](PLAN_PHASE2_BOUND_WAITS_2026-07-26.md) decision -**D-L** found that `Wire_nRF52.cpp:166-181` / `:230-247` spin on TWIM events with no deadline -and no yield, and chose option **(a) accept and document**. - -**Accepted residual 2 — the scheduler-starving fault class.** -Decision **D-K** accepted that `millis()` bounds all fail together if the scheduler stalls, on -the grounds that it is "a fault class we cannot recover from anyway." A hardware watchdog is -what makes that class recoverable, so D-K's premise no longer holds once this lands. - -**New finding (2026-08-01) — the same shape on SPI.** -`nrfx_spim.c:598` blocks in `while (!nrf_spim_event_check(p_spim, NRF_SPIM_EVENT_END)){}` with -no timeout and no yield, because `SPIClass` initialises nrfx with a NULL handler -(`SPI.cpp:101`). On nRF the Arduino API drives the panel **one byte at a time** -(`arduino_io.inl:214-221`), so the firmware enters that spin ~96,000 times per full refresh. - -Underlying all three: no firmware-armed watchdog exists on nRF. See **V1** for the corrected -statement of what exists on ESP32 — rev 1 got that wrong. - -## 2. Verification done before writing this plan - -Checked against source in this workspace. Rows corrected after review are marked **[R1]**. - -| # | Fact | Evidence | -|---|---|---| -| V1 **[R1]** | nRF has **no** watchdog. ESP32's IDF task watchdog **is enabled and initialised** (`CONFIG_ESP_TASK_WDT_EN=y`, `INIT=y`, `TIMEOUT_S=5`) — but **watches nothing that would catch a wedged `loop()`**: S3/classic set only `CHECK_IDLE_TASK_CPU0=y` while `CONFIG_ARDUINO_RUNNING_CORE=1` puts `loopTask` on CPU1; C3/C6 subscribe no idle task at all | `framework-arduinoespressif32-libs/{esp32s3,esp32,esp32c3,esp32c6}/sdkconfig`. Rev 1 claimed "no watchdog on either target" — the *conclusion* for `loop()` holds, the *reason* was wrong | -| V2 **[R1]** | Neither the core nor Bluefruit **operates** `NRF_WDT` | grep over `cores/`+`libraries/` finds only MDK register definitions. Rev 1 said "only MDK defs exist"; `drivers/include/nrfx_wdt.h` is also installed — header only | -| V3 **[R1]** | The nrfx WDT **implementation** is not compiled | no `drivers/src/nrfx_wdt.c`; `NRFX_WDT_ENABLED` absent from `nrfx_config.h`. The *header* `nrfx_wdt.h` does exist. Use `hal/nrf_wdt.h` | -| V4 **[R1]** | ⚠️ **`RESETREAS` is read and cleared by the core before `setup()`**, and exposed via `readResetReason()` | `cores/nRF5/wiring.c:37-40` — `_reset_reason = NRF_POWER->RESETREAS;` then `RESETREAS \|= RESETREAS` (write-1-to-clear); getter at `:72`. **Rev 1's `sd_power_reset_reason_get()` design would read zero forever**, and its "bits accumulate across boots" claim was false | -| V5 | `GPREGRET` (id 0) is already taken by the DFU handshake | [device_control.cpp:868-869](../src/device_control.cpp#L868-L869) | -| V6 **[R1]** | `GPREGRET2` (id 1) is reachable under the SD and unused by app + framework source | `nrf_soc.h:641-666`. **Not fully verified against the installed bootloader image**, which is not in these sources — see risk in W-3 | -| V7 **[R1]** | No `.noinit` section exists in the packaged linker scripts | `grep -l noinit cores/nRF5/linker/*.ld` → none. Rev 1 concluded "package edit or nothing" — **wrong**: PlatformIO supports `board_build.ldscript` (`platforms/nordicnrf52/builder/frameworks/arduino/adafruit.py:190-196`). GPREGRET2 is still preferred, but as a choice, not a necessity | -| V8 **[R1]** | WDT is clocked from LFCLK — but **LFCLK is already started by the core before `setup()`** | `wiring.c:45-57` (`TASKS_LFCLKSTART = 1`). **Rev 1's conclusion that this forces arming after `ble.begin()` was wrong**; arm ordering is now a policy choice (W-3), not a clock constraint | -| V9 | Reload uses a fixed magic and per-register enables | `nrf_wdt.h:56` `NRF_WDT_RR_VALUE 0x6E524635`; `WDT_RREN_RR0_*`, `nrf52840_bitfields.h:17376` | -| V10 | Behaviour in sleep / debug-halt is configurable | `WDT_CONFIG_SLEEP_Pos = 0`, `WDT_CONFIG_HALT_Pos = 3`, `nrf52840_bitfields.h:17385,17391` | -| V11 **[R2]** | **The WDT cannot be stopped once started.** The register block has no `TASKS_STOP` and no `ENABLE`, and `CRV`/`RREN`/`CONFIG` latch at `START`. **Which resets clear it is NOT established** — a power-on reset certainly does; whether a soft/`DOG`/pin reset does is unverified, so the code feeds any watchdog it finds running regardless (W-1) | nRF52840 architectural property; the HAL exposes START and reload, no stop. Constrains W-3 and §5's DFU residual | -| V12 **[R1]** | **Worst-case uninstrumentable span = ~240 s.** The 8.1" Spectra is `BBEP_SPLIT_BUFFER \| BBEP_7COLOR`; its init list holds **4** `BUSY_WAIT` entries, and `REFRESH_FULL` sends the whole sequence to CS1 **and again to CS2** | `bb_ep.inl:3704-3726` (4 × `BUSY_WAIT`), `:4373-4380` (CS1 then CS2), `:3967-3969` (30 s cap for 3/4/7-colour). 8 × 30 s = 240 s inside **one** `bbepRefresh()` call | -| V13 **[R1]** | `enterDFUMode()` **jumps to the bootloader without a system reset** | [device_control.cpp:868-884](../src/device_control.cpp#L868-L884) — `sd_softdevice_disable()`, vector-table move, `bootloader_util_app_start()`. No `NVIC_SystemReset()`. With **V11**, an armed WDT keeps counting into the bootloader | - -## 3. The central design problem - -A watchdog is easy to get catastrophically wrong here, because this firmware **legitimately -blocks for minutes** on a healthy device. - -### 3.1 Blocking-span inventory - -| Span | Worst case | Feed possible inside? | -|---|---|---| -| **`bbepRefresh(REFRESH_FULL)` on a 7-colour split-buffer panel** | **~240 s** (**V12**) | **No** — one libdep call | -| `waitforrefresh(60)` | ~126 s — 6000 iterations × ~21 ms (`delay(10)` + `bbepIsBusy`'s own `delay(10)`+`delay(1)`, `bb_ep.inl:3984-3986`). The argument is **not** seconds | **Yes** — [display_service.cpp:857-870](../src/display_service.cpp#L857-L870) | -| `bbepSendCMDSequence` (init only) | N × 5 s (B/W) or N × 30 s (multicolour) | No — libdep | -| `pwrmgm(true)` | 900 ms | Yes | -| `nrfx_spim` / `Wire` spins | **unbounded** (the fault) | No — and deliberately not (§W-2) | - -### 3.2 Why 300 s works — but only with a pre-call feed **[R1]** - -**Rev 1 claimed 300 s "dominates every span" with >3× headroom, from an assumed N=3 and a -single command sequence. That was wrong** (**V12**): the real worst case is 240 s, and rev 1's -margin was ~1.25×, erasable by any work preceding the call in the same handler invocation. - -**D-1 is confirmed at 300 s**, and is made safe by a change to the feed policy rather than to -the timeout: **feed immediately before every call that enters bb_epaper's blocking region** -(W-2). Since the 240 s is a single uninterruptible call, feeding on entry means the watchdog -faces exactly that span and nothing else. - -- Longest uncovered span becomes **240 s** — the single worst libdep call, not a sum. -- Margin **60 s (1.25×)**, and everything before the call is irrelevant because the counter is - freshly reloaded. -- **This margin is thin and must be respected**: any future increase in `BUSY_WAIT` count, - controller count, or per-wait cap eats directly into it. Recorded as a residual in §5, with - T2 measuring the real figure on hardware. - -## 4. Design - -### W-0 — Module shape: portable header, two self-gated implementations - -Not an nRF-only module, and the reason is `#ifdef` count in shared code. Every feed site and -breadcrumb stamp lives in a file that compiles for both targets: `loop()`/`idleDelay` in -[main.cpp](../src/main.cpp) (*"One loop body for both targets"*), and `waitforrefresh` plus the -bb_epaper entry points in [display_service.cpp](../src/display_service.cpp). An nRF-only API -would put `#ifdef TARGET_NRF` around ~20 call sites in shared files — what -[PLAN_UNIFY_NRF_ESP32_LOOP_BLE_2026-07-27.md](PLAN_UNIFY_NRF_ESP32_LOOP_BLE_2026-07-27.md) -worked to remove. - -Follow the transport pattern, whose reasoning `ble_transport.h` already records: *"Exactly one -implementation is linked per build... a plain class rather than an abstract base: virtual -dispatch would cost a vtable and indirect calls for zero benefit"*, and *"The whole file is -gated on TARGET_NRF, so an ESP32 build compiles it to an empty translation unit — no -build_src_filter changes needed."* - -``` -src/watchdog.h portable. No nrf_wdt.h, no esp_task_wdt.h — any TU may include it. -src/watchdog_nrf.cpp whole file #ifdef TARGET_NRF — real -src/watchdog_esp32.cpp whole file #ifdef TARGET_ESP32 — stubs (W-6) -``` - -Free functions; no state worth exposing. - -```c -void odWatchdogBootInit(void); // decode reset reason, evaluate strike counter -bool odWatchdogInSafeMode(void); // [R1] main.cpp gates initDisplay() on this -void odWatchdogArm(void); // once, before the boot panel path (W-3) -void odWatchdogFeed(void); // W-2 feed sites -void odWatchdogBreadcrumb(uint8_t phase); // panel-phase stamps (W-4) -``` - -#### Deliberately NOT in the API - -| Omitted | Why | -|---|---| -| `stop()` / `disable()` | nRF WDT **cannot be stopped once started** (**V11**); ESP32's TWDT can. Exposing it would advertise a capability one target cannot honour | -| runtime timeout parameter | `CRV` must be written **before** `START` and is immutable after. Compile-time only | -| task registration | ESP32's TWDT is per-task; nRF's reload registers are not an analogue. Contract is **one loop task, one watchdog**, stated in the header | - -#### Bonus: this removes `#ifdef`s rather than adding them - -The ESP32 reset decode already exists inline in `main.cpp` under `#ifdef TARGET_ESP32` -([:27-44](../src/main.cpp#L27-L44), [:82-84](../src/main.cpp#L82-L84)). Moving it into -`watchdog_esp32.cpp` is a net `#ifdef` reduction in `main.cpp`. - -### W-1 — Watchdog configuration (nRF) - -Use the Nordic HAL (`nrf_wdt.h`): header-only inline functions already on the include path, no -driver to enable (**V3**), and it supplies `NRF_WDT_RR_VALUE` (**V9**) instead of a magic -constant. - -- `CRV = (OPENDISPLAY_NRF_WDT_S × 32768) − 1`. At 300 s: `9,830,399` (`0x0095FFFF`). -- **[R1] Validate the flag at compile time**, since it is a public build knob feeding a - hardware register: `static_assert` that it is either `0` (disabled) or within - `[60, 3600]` seconds. The lower bound keeps it above §3.1's spans; the upper stays well - inside `CRV`'s 32-bit ceiling (~131,072 s). Rev 1's "no overflow guard needed" was valid - only for the literal 300. -- `RREN = RR0` only — one reload register, one feeder. -- `CONFIG.SLEEP = 1` — keep counting while the CPU sleeps; `idleDelay` feeds every ≤100 ms - chunk. Without it, a device that hangs while idle is never recovered. -- `CONFIG.HALT = 0` — do not count while halted by a debugger. -- **[R1] Check `RUNSTATUS` before configuring.** There is no way to disarm or reconfigure a - running WDT: the register block exposes `TASKS_START` and `RR[8]` only — **no `TASKS_STOP`, - no `ENABLE`** (`nrf52840.h:2044-2062`; contrast SPIM/TWI/UART, which have `ENABLE` at - `0x500`) — and `CRV`/`RREN`/`CONFIG` are latched at `START`, so later writes are ignored. - - Consequence: if the **bootloader** left the WDT running, our configuration is silently - discarded and we inherit its timeout — possibly far shorter than 300 s — while believing we - set our own. `odWatchdogArm()` must read `RUNSTATUS` first, and if the WDT is already - running, **log loudly and skip configuration** rather than pretend. Feeding must then still - happen (the inherited dog is real). Confirm behaviour on hardware in T7. - -### W-2 — Feed policy - -**Principle: only feed from a site whose execution proves forward progress.** - -| Site | Why | -|---|---| -| `loop()` top, beside `epdSessionTick()` ([main.cpp:896](../src/main.cpp#L896)) | primary liveness proof | -| `idleDelay()` chunk loop ([main.cpp:1031-1041](../src/main.cpp#L1031-L1041)) | a long idle wait is healthy | -| `waitforrefresh()` poll loop ([display_service.cpp:857](../src/display_service.cpp#L857)) | a 126 s refresh is healthy | -| **[R1] immediately before each bb_epaper entry point** — the **15** call sites in `display_service.cpp` — `bbepRefresh` ×3, `bbepSendCMDSequence` ×3, `bbepWakeUp` ×3, `bbepSleep` ×1, `bbepFill` ×2 (consecutive), and **`bbepInitIO` ×3**. Two counting errors were caught in review: `bbepFill` appears twice, and `bbepInitIO` was omitted entirely even though it sends `pInitFull` internally — twice on a split-buffer panel — making it a ~240 s span in its own right: `bbepWakeUp` ([:365](../src/display_service.cpp#L365), [:476](../src/display_service.cpp#L476), [:500](../src/display_service.cpp#L500)), `bbepSendCMDSequence` ([:366](../src/display_service.cpp#L366), [:479](../src/display_service.cpp#L479), [:503](../src/display_service.cpp#L503)), `bbepSleep` ([:451](../src/display_service.cpp#L451)), `bbepRefresh` ([:562](../src/display_service.cpp#L562), [:2503](../src/display_service.cpp#L2503), [:3340](../src/display_service.cpp#L3340)), `bbepFill` ([:3368](../src/display_service.cpp#L3368)) | **This is what makes 300 s safe** (§3.2). Reloading on entry means the dog faces the single 240 s call, not that call plus everything before it | - -**Explicitly NOT fed from:** - -- **Any ISR, timer, or SoftDevice callback.** An interrupt-fed watchdog verifies the interrupt - controller is alive, not the program. Non-negotiable. -- **`nrfx_spim`'s or `Wire`'s spins** — unreachable without forking the package, and *we do not - want to*: those spins are the fault. - -**[R1] Corrected property statement.** Rev 1 claimed "every wait we have bounded feeds the dog; -every wait we have not, does not." That is false — `bbepWaitBusy` is bounded (5/30 s) and gets -no feed. The accurate statement is: **every span we can reach is fed at its boundary; spans we -cannot reach must individually fit inside the timeout.** V12 is the largest such span. - -**[R1] A stuck BUSY pin does not trip the watchdog** and is not meant to: `waitforrefresh` -keeps feeding for its 6,000 iterations, returns `false`, and `loop()` resumes. That is correct -behaviour — a failed refresh is an error to report, not a wedge to reset. T4 must therefore -distinguish "BUSY stuck" (no reset expected) from a non-returning call inside one iteration -(reset expected). - -### W-3 — Boot-loop containment **[R1] — redesigned** - -Rev 1's design was **internally contradictory** and is replaced wholesale. Its faults: - -1. It armed the watchdog *after* the boot panel path, so a wedge *in* that path could never - produce a `DOG` reset — yet it then claimed a "panel-safe-mode escape after 3 of them." - Strikes could never accumulate from the very failure the escape existed for. -2. It cleared the strike counter on "first successful refresh." Every ordinary boot performs a - successful boot refresh, so each recurring runtime wedge would clear the previous strike and - the count would never reach 3. -3. In safe mode no refresh occurs, so "first successful refresh" could never fire and the - device could never leave safe mode. -4. It put a persistent counter and an overwritten breadcrumb in the same 8-bit register with no - bit allocation, so a breadcrumb write would destroy the counter. - -**Redesigned:** - -- **Arm before the boot panel path**, immediately after `odWatchdogBootInit()`. Boot wedges are - now covered, which is what makes the strike counter meaningful. **V8** removed the clock - reason for arming late, so nothing prevents this. The `bootdiag` `while (!Serial)` gate at - [main.cpp:58](../src/main.cpp#L58) still precedes any sane arm point and stays uncovered. -- **Clear the counter on sustained uptime, not on panel success.** Clear once the device has - run `WDT_HEALTHY_MS` (propose **10 minutes**, ≥2× the timeout) since boot with the watchdog - armed. This is panel-independent, so it works identically in safe mode — solving faults 2 - and 3 together. Strikes accumulate only when resets come *fast*, which is exactly the - boot-loop condition safe mode exists for; a device that survives 10 minutes between wedges is - not boot-looping and should keep retrying the panel. -- **Safe mode is self-exiting.** After 10 healthy minutes in safe mode the counter clears, so - the next reset boots normally and retries the panel. Worst case is a bounded oscillation — - 3 fast resets, a long safe-mode period, one retry — rather than a permanent brick. -- **[R1] Explicit GPREGRET2 bit allocation** (8 bits, `nrf52840_bitfields.h`): - - | Bits | Field | - |---|---| - | 7:6 | validity tag `0b10` — distinguishes a real value from cold-boot garbage | - | 5:4 | strike counter, 0–3, saturating | - | 3:0 | breadcrumb phase, 0–15 | - - Breadcrumb writes are read-modify-write over bits 3:0 only — - `sd_power_gpregret_clr(1, 0x0F)` then `sd_power_gpregret_set(1, phase & 0x0F)`. Two SVCs, not - atomic; safe because only the loop task writes it. Document that in the header. -- **[R1] Risk (V6):** GPREGRET2 is unused by application and framework source, but the - installed **bootloader image was not inspected**. If the bootloader writes it, the validity - tag causes a stale value to be discarded rather than misread — the counter resets, degrading - containment but not causing a wrong action. Verify on hardware (T7). -- **[R1] No MSD status bit.** Rev 1 promised to surface safe mode in the manufacturer data. - Status bit 3 is reserved and "must be 0" in `include/opendisplay_structs.h`, which is - **vendored byte-for-byte** from `opendisplay-protocol`; per CLAUDE.md such a change must - originate in the canonical repo. Out of scope here — safe mode is reported via the boot log - only. Surfacing it on the wire is follow-up work in the protocol repo. - -### W-4 — Observability - -**[R1] Reset-reason decode must use `readResetReason()`, not the peripheral.** Per **V4** the -core has already read and cleared `RESETREAS` before `setup()`. Rev 1's -`sd_power_reset_reason_get()` design would have read zero on every boot and silently reported -"power-on" for every watchdog reset — defeating the entire purpose of step 1. Decode the saved -word's `RESETPIN / DOG / SREQ / LOCKUP / OFF / DIF` bits. No clearing is needed or possible; -the core already did it, which also means bits do **not** accumulate across boots. - -**Breadcrumb.** One-byte phase code in GPREGRET2 bits 3:0, stamped at panel-phase transitions -(`IDLE`, `ACQUIRE_COLD`, `ACQUIRE_WARM`, `INIT_SEQ`, `FILL`, `STREAM`, `REFRESH_WAIT`, -`RELEASE`, `FORCE_OFF`) — 9 values, inside the 16 the field allows. Logged next to the reset -reason at boot. - -Payoff: a freeze stops being "it wedged somewhere" and becomes -`reset=DOG breadcrumb=INIT_SEQ`, naming the wedged wait directly. - -**`TIMEOUT` ISR (D-3).** Fires ~61 µs (2 LFCLK cycles) before the reset — enough to stamp a -final breadcrumb, not enough to write flash or drain a UART. Strictly best-effort; the -boot-side decode is the mechanism we rely on. - -### W-5 — Build-flag control - -`-DOPENDISPLAY_NRF_WDT_S=300` in `[env:nrf52840custom]`, `0` = disabled, validated per W-1. - -- Default **on**. A watchdog that ships disabled is not a watchdog. -- The three other nRF envs inherit via `${env:nrf52840custom.build_flags}` — no separate - entries. `CONFIG.HALT = 0` keeps breakpoints safe in the debug env (**D-4**). -- `bootdiag` is safe: its `while (!Serial)` gate precedes the arm point (W-3). -- **No ESP32 flag** — `watchdog_esp32.cpp` is stubbed (W-6). - -### W-6 — The ESP32 stub **[R1]** - -- `odBootReasonLog()` / `odWatchdogBootInit()` — **real**: the existing `resetReasonName()` + - `esp_reset_reason()` logic relocated from `main.cpp`. -- `odWatchdogArm()` — no-op that logs, **once**, the *accurate* state: the IDF task watchdog - is enabled at 5 s but **no task that would catch a wedged `loop()` is subscribed** (**V1**). -- `odWatchdogFeed()`, `odWatchdogBreadcrumb()` — empty. `odWatchdogInSafeMode()` returns false. - -Rev 1 planned to log "no watchdog armed", which **V1** shows would be false on S3 and classic -ESP32. The accurate message is more useful anyway: it names the specific gap -(`CHECK_IDLE_TASK_CPU0` vs `ARDUINO_RUNNING_CORE=1`) that a future implementation must close, -which is a one-line `esp_task_wdt_add(NULL)` on the loop task plus the feed sites this plan -already wires up. - -## 5. What this does not fix - -- **It does not fix any unbounded wait.** `nrfx_spim.c:598` and the six `Wire_nRF52` spins are - untouched. A permanent hang becomes a periodic reset — better and observable, but the device - still drops its link, loses transfer state, and pays a cold bring-up. -- **Recovery is slow, by choice.** Up to 5 minutes dead before reset; with **D-2**'s threshold - of 3, up to ~15 minutes to reach safe mode. Right trade for e-paper, where five minutes late - is invisible but resetting a healthy device mid-refresh is a visible regression. -- **[R1] The 240 s / 300 s margin is thin (1.25×).** Any growth in `BUSY_WAIT` count, - controller count, or the multicolour cap eats it directly. If a future panel exceeds it, - healthy devices get reset mid-refresh — the main way this change can do harm. T2 measures the - real figure; treat V12 as a number to re-check whenever a panel is added. -- **[R1] DFU can take a `DOG` reset — accepted, out of scope.** Per **V13**, `enterDFUMode()` - jumps to the bootloader without a system reset, and per **V11** the watchdog cannot be - stopped, so it keeps counting into a bootloader that will not feed it. A DFU session lasting - >300 s from the jump will be reset. Assessment: the reset re-enters the bootloader (GPREGRET - id 0 still holds `0xB1`), so the expected outcome is an interrupted transfer the host must - retry, not a brick — **unless** the reset lands mid-flash-write, which is not analysed here. - The fix, if it is ever wanted, is to replace the direct jump with `NVIC_SystemReset()`, - matching the core's own `enterUf2Dfu()` (`wiring.c:76-80`). **[R2] Caveat:** whether a - running nRF52840 WDT survives a non-power-on reset could not be established from any source - in this workspace. If it survives, `NVIC_SystemReset()` would **not** help and only a power - cycle stops it. The implementation is written to be correct either way (W-1's unconditional - inherit-detection); `RUNSTATUS` logged at boot settles it empirically. Explicitly descoped. - - **[R1] This residual may not exist at all.** Many Nordic/Adafruit bootloaders feed the WDT in - their main loop precisely because the application may have armed one. The installed - bootloader is a flashed binary, absent from these sources, so this could not be verified. - **T9 answers it empirically** — if DFU survives, the residual is void. -- **It says nothing about brownout.** POFCON is not enabled (`grep POFCON src/` → nothing). -- **It does not verify the panel rail power-cycles on reset** (was D-6, descoped). -- **ESP32 gains no watchdog.** The stub's boot log makes that explicit rather than assumed. - -## 6. Decisions — all resolved - -| ID | Decision | Resolution | -|---|---|---| -| **D-1** | Timeout value | ✅ **300 s**, re-confirmed after **V12** revealed a 240 s worst case. Made safe by W-2's pre-call feed, not by the timeout alone. Margin 1.25× — residual in §5 | -| **D-2** | Panel-safe-mode threshold | ✅ **3 consecutive `DOG` resets**, with the redesigned clear rule in W-3 | -| **D-3** | `TIMEOUT` ISR for a final breadcrumb? | ⚠️ **Deferred — NOT implemented.** Phase-transition breadcrumbs are stamped eagerly at every panel-phase entry, so the retained value is already correct when the reset lands; the ISR would only add ~61 µs of redundancy. Revisit if a real failure shows a phase gap | -| **D-4** | Watchdog in the debug envs? | ✅ **Enabled**, inherited; `bootdiag` included (its Serial gate precedes the arm point) | -| **D-5** | Ship observability before arming? | ✅ **Yes — split.** Step 1 lands W-0/W-4/W-6 with nothing armed | -| **D-6** | Does the panel rail drop on a WDT reset? | ✅ **Descoped** — §5 | -| **D-7 [R1]** | Fix the DFU jump so the watchdog cannot reset a DFU session? | ✅ **No — out of scope by decision.** Recorded as an accepted residual in §5 with its severity assessment | - -## 7. Test plan - -| # | Test | Expected | -|---|---|---| -| T1 | Normal operation, many push cycles | No reset; `DOG` never appears in the decode | -| T2 **[R1]** | Full refresh on the **slowest supported panel** (7-colour split-buffer if available), cold, full-frame | No reset, and **log the measured span** — this validates V12's 240 s and the real margin against 300 s. The single most important test | -| T3 | Injected infinite loop in `loop()` behind a debug command | Reset within timeout; boot logs `reset=DOG` + breadcrumb | -| T4 **[R1]** | (a) BUSY held asserted through a refresh; (b) non-returning call inside one `waitforrefresh` iteration | (a) **no** reset — `waitforrefresh` returns false and `loop()` resumes; (b) reset. Distinguishing these is the point | -| T5 | Injected wedge in the SPIM path (transfer with SPIM disabled) | Reset with `breadcrumb=STREAM`/`INIT_SEQ` — the target case | -| T6 | Long `idleDelay` at max `sleep_timeout_ms`, battery | No reset — validates `CONFIG.SLEEP=1` + the idle feed | -| T7 **[R1]** | Force 3 fast consecutive `DOG` resets; then let the device run >10 min. Log `RUNSTATUS` at every boot | Safe mode entered, device advertises and is DFU-reachable; counter clears after the healthy window; next reset boots normally. Also confirms (a) GPREGRET2 survives a `DOG` reset and the bootloader does not clobber it (**V6**), and (b) `RUNSTATUS` reads *not running* at boot — i.e. the bootloader did not leave a WDT armed that would silently override our config (W-1) | -| T8 | Debugger breakpoint held >5 min | No reset — validates `CONFIG.HALT=0` | -| T9 **[R1]** | Enter DFU and idle in the bootloader >5 min | **Outcome unknown — that is the point of the test.** If the bootloader feeds the WDT, no reset and the **D-7** residual is void. If it does not, a reset is expected; confirm the device re-enters DFU rather than bricking. Either way, record the result against §5 | -| T10 | `bootdiag`, no USB host, left >5 min | No reset — the Serial gate must stay outside coverage | - -## 8. Files touched - -| File | Change | -|---|---| -| `src/watchdog.h` | **new** — portable interface, five free functions (W-0). No vendor headers | -| `src/watchdog_nrf.cpp` | **new** — `#ifdef TARGET_NRF`. HAL config, feed, `readResetReason()` decode, GPREGRET2 bit-allocated counter + breadcrumb, safe-mode state | -| `src/watchdog_esp32.cpp` | **new** — `#ifdef TARGET_ESP32`. Real boot decode relocated from `main.cpp`; accurate `Arm()` log; empty feed/breadcrumb (W-6) | -| `src/main.cpp` | feed at `loop()` top and in `idleDelay`; `odWatchdogBootInit()` + `odWatchdogArm()` before the boot panel path; gate `initDisplay()` on `odWatchdogInSafeMode()`. **Net `#ifdef` reduction** — `resetReasonName()` and its call site move out | -| `src/display_service.cpp` | feed in `waitforrefresh` **and before all 15 bb_epaper entry points** (W-2); breadcrumb stamps. No `#ifdef`s | -| `platformio.ini` | `-DOPENDISPLAY_NRF_WDT_S=300` in `[env:nrf52840custom]`; three other nRF envs inherit | -| `docs/TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md` | §1.1/§1.2 are **wrong** per **V1** — the ESP32 TWDT is enabled, just not watching `loop()`. Correct both | -| `docs/PLAN_PHASE2_BOUND_WAITS_2026-07-26.md` | note D-L/D-K residuals are now recoverable (not fixed) | - -## 9. Sequencing - -1. **W-0 + W-4 + W-6 — module and observability, nothing armed** (**D-5**). Create the header - and both implementations; relocate the ESP32 decode; add the nRF `readResetReason()` decode - and the GPREGRET2 breadcrumb; wire all feed sites so they exist and compile while `Arm()` is - inert. Zero brick risk. **Independently valuable**: answers whether field units are resetting - or hanging, and lands the `main.cpp` `#ifdef` cleanup regardless of the rest. -2. **T2 first, then W-1 + W-2** — measure the real worst-case refresh *before* arming, since - §3.2's margin is only 1.25×. Then arm at 300 s. -3. **W-3** — arm-before-boot-panel-path, GPREGRET2 bit allocation, strike counter with the - uptime-based clear, safe mode. - -Steps 2 and 3 must land together: step 2 alone is the configuration with the boot-loop hazard -W-3 exists to contain. - -## 10. Review corrections (rev 1 → rev 2) - -Rev 1 was reviewed adversarially; findings were re-verified against source before acceptance. - -**Blocking errors, all confirmed:** -1. **V4** — `RESETREAS` is cleared by the core before `setup()`; rev 1's SoftDevice-API design - would have read zero forever and reported every watchdog reset as a power-on. -2. **V12 / §3.2** — worst case is 240 s (4 `BUSY_WAIT` × 2 controllers × 30 s), not the assumed - ≤90 s. Rev 1's ">3× headroom" was wrong; 300 s is only safe with W-2's pre-call feed. -3. **W-3** — rev 1's containment was self-contradictory in four distinct ways (§W-3); redesigned. -4. **V13 / D-7** — DFU jumps to the bootloader without a reset, so rev 1's T9 ("the system reset - clears the WDT") was factually wrong. Now an accepted residual. - -**Corrected but non-blocking:** V1 (ESP32 TWDT *is* enabled — conclusion held, reason wrong), -V2/V3 (imprecise inventory), V6 (bootloader not inspected), V7 (`board_build.ldscript` exists), -V8 (LFCLK already started before `setup()`), W-1 (flag needs validation), W-2 (property -statement was false; stuck-BUSY behaviour clarified), W-6 (stub message would have been false). - -**Verified correct and unchanged:** V5, V9, V10, V11, and §3.1's ~126 s `waitforrefresh` -arithmetic. diff --git a/docs/PLAN_PHASE0_LINK_DROP_SEAM_2026-07-31.md b/docs/PLAN_PHASE0_LINK_DROP_SEAM_2026-07-31.md deleted file mode 100644 index de91bad..0000000 --- a/docs/PLAN_PHASE0_LINK_DROP_SEAM_2026-07-31.md +++ /dev/null @@ -1,159 +0,0 @@ -# Phase 0 — BLE Link-Drop Seam (2026-07-31) - -> **SUPERSEDED 2026-07-31 — do not implement from this document.** -> -> Both deliverables were folded into **Phase 2 (BLE-HAL foundation)** of -> [`PLAN_FREEZE_HARDENING_2026-07-31.md`](PLAN_FREEZE_HARDENING_2026-07-31.md), which -> is the live plan. Read that instead; this file is kept only for the reasoning trail. -> -> Two things here are **out of date** and were corrected in the fold: -> -> - **The seam signature.** This document specifies `disconnect(uint8_t reason)`. The -> live plan specifies `disconnect(uint16_t handle)` with 0x13 hard-coded and *no* -> reason parameter. Both stacks were read to settle it: Bluefruit's -> `disconnect(uint16_t conn_hdl)` (`bluefruit.h:171`) has no reason parameter at all -> — it delegates to `sd_ble_gap_disconnect(_conn_hdl, BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION)` -> (`BLEConnection.cpp:206`) — and NimBLE already *defaults* its reason to 0x13 -> (`NimBLEServer.h:66`). A handle is what both stacks genuinely take, and Phase 3's -> admission policy needs to drop a *specific* link, not "the current one". -> - **The phase numbering.** References below to "Phases 2, 4 and 5" are the earlier -> five-phase draft. The live plan has four phases; the seam is in Phase 2. -> -> Deliverable 2 (the ESP32 disconnect-reason truncation fix) and the deferred -> `OdDiscReason` classifier carried over unchanged, and now live in the live plan's -> Phase 2 seam section. - -The foundational seam for [`PLAN_FREEZE_HARDENING_2026-07-31.md`](PLAN_FREEZE_HARDENING_2026-07-31.md). -Phases 2, 4 and 5 all need to **drop a BLE link from the loop task**, and none can -today. This phase adds that one capability, plus the minimal fix to stop the -disconnect-reason log from lying. - -No wire change (a disconnect reason is an HCI byte, not an app-protocol field). - -## Scope decision — why this is small - -An earlier draft of this phase also normalized the *inbound* disconnect reason into -a six-value enum. That was cut after checking what actually consumes it: **nothing -in Phases 2–5 branches on why a link dropped.** - -- Phase 2 (owner token) releases and tears down on *any* disconnect. -- Phase 3 (abort) runs the same teardown regardless of reason. -- Phases 4 and 5 *initiate* the drop, and their authoritative "did I cause this" is - a `*DropPending` flag (see [Deliverable 1](#deliverable-1)), not the reason byte. - -So the normalized reason would feed a log line and nothing else. A classification -layer nothing consumes is not worth its surface. It is deferred to -[Deferred](#deferred-until-something-consumes-it) — a small header and a `switch`, -cheap to add the day a phase branches on a reason (repeated-MIC-failure handling is -the likely first customer). - -What is **not** deferred is the outbound drop, and the one honest bug in the -current reason handling. - -## Deliverable 1 — `BleTransport::disconnect(uint8_t reason)` - -Add to the abstraction ([ble_transport.h](../src/ble_transport.h)); implement per -target; call **only from the loop task**. - -- **ESP32:** `s_server->disconnect(s_connHandle, reason)` when `s_connHandle != - BLE_HS_CONN_HANDLE_NONE`. Return the call's bool; log WARN on failure. -- **nRF:** `Bluefruit.disconnect(s_connHandle)` when `s_connHandle != - BLE_CONN_HANDLE_INVALID`; keep `restartOnDisconnect(true)` (unlike the DFU path at - [device_control.cpp:857](../src/device_control.cpp), which disables it). - -**Reason to send: `0x13`** (`BLE_ERR_REM_USER_CONN_TERM` / -`BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION`, identical in both stacks and in the -Core Spec's legal `HCI_Disconnect` allowlist). **Do NOT send `0x09`** (`CONN_LIMIT`): -it is not a legal host-disconnect reason, so the controller rejects it (0x12) while -the code looks like it worked and the gatecrasher stays connected. Neither constant -exists in `src/` today; add one named constant with a comment carrying the 0x09 -trap. - -**Loop-task only.** The call is made from a loop-serviced helper (or inline in the -existing `serviceBle*` helpers), never a stack callback — a callback that severs its -own link mid-dispatch is exactly the class of bug `#132` removed. The phases that -request a drop do so by raising a `*DropPending` flag; the loop services it. That -flag — not any reason byte read back afterward — is the authoritative record of a -self-initiated drop. - -## Deliverable 2 — stop the disconnect-reason log from lying (ESP32) - -Not a new feature; a correctness fix to what is already logged at -[main.cpp:472](../src/main.cpp). Today ESP32 stores the reason wrong: - -```cpp -// ble_transport_esp32.cpp:35,99 -static volatile uint8_t s_disconnectReason = 0; -... -s_disconnectReason = (uint8_t)reason; // int -> uint8_t: truncates -``` - -NimBLE's `onDisconnect(int reason)` uses two ranges: HCI reasons wrapped as -`BLE_HS_ERR_HCI_BASE + code` (`0x200 + code`), and host-layer `BLE_HS_E*` codes in -`1..31`. The `uint8_t` cast keeps only the low byte, so: - -- an HCI reason survives by luck (`0x213 & 0xFF == 0x13`), but -- a host code like `BLE_HS_ENOTCONN = 7` truncates to `0x07`, which reads back as - the unrelated HCI code "memory capacity exceeded". The stored byte is ambiguous - and the log can name the wrong reason. - -nRF is unaffected — it stores a raw HCI `uint8_t` from the SoftDevice with no -wrapping. - -**Fix (ESP32 only, ~3 lines):** widen `s_disconnectReason` to `uint16_t` so the -`0x200` offset survives capture, and log the raw value as-is: - -```cpp -static volatile uint16_t s_disconnectReason = 0; -... -s_disconnectReason = (uint16_t)reason; // keep the full value, no truncation -``` - -`takeDisconnectedEvent`'s out-param widens to `uint16_t*` -([ble_transport.h:81](../src/ble_transport.h), one caller at -[main.cpp:471](../src/main.cpp)), and the log line becomes -`"Disconnect reason: 0x%03X"` so a wrapped HCI reason (`0x213`) and a host reason -(`0x007`) are visibly distinct rather than colliding on `0x13`/`0x07`. No enum, no -classifier, no interpretation — just stop discarding half the value. - -## Files touched - -| File | Change | -|---|---| -| `src/ble_transport.h` | add `disconnect(uint8_t)` + the `0x13`/`0x09` reason constant & comment; widen `takeDisconnectedEvent`'s reason out-param to `uint16_t*` | -| `src/ble_transport_nrf.cpp` | implement `disconnect()`; reason storage unchanged (already a raw HCI byte, widened only to match the signature) | -| `src/ble_transport_esp32.cpp` | implement `disconnect()`; widen `s_disconnectReason` to `uint16_t`, drop the truncating cast | -| `src/main.cpp` | update the one `takeDisconnectedEvent` caller + its log line | - -No new file, no host test (nothing here is pure logic worth a standalone test — the -drop needs a board; the widening is a type change verified by build + bench log). - -## Verification - -- **Build** all envs. -- **Bench (closes the phase):** on nRF and ESP32, call `disconnect(0x13)` from the - loop task and confirm the link actually drops (the `0x09` trap means "it - compiled" is not enough — watch for the disconnect on a scanner or the client - side). Confirm a real client disconnect logs a sensible reason, and that a - NimBLE host-layer reason now logs as `0x0xx` rather than masquerading as an HCI - code. - -## Deferred until something consumes it - -The normalized inbound reason (a `src/ble_disc_reason.h` with an `OdDiscReason` -enum — `SUCCESS / REMOTE / LOCAL / TIMEOUT / MIC_FAILURE / OTHER` — a pure -`od_disc_classify(uint8_t hci)` switch, the ESP32 `0x200`-offset normalization, and -a host test) is **not built here**. It is deferred until a phase branches on a -reason rather than just logging it. The most likely trigger is MIC-failure-driven -behaviour (0x3D signals encryption desync — the failure class this whole effort -targets), e.g. forcing re-auth after repeated MIC failures. When that lands, the -enum is a small header and a `switch`; the `uint16_t` raw value this phase already -preserves is exactly the input the classifier needs, so nothing here has to be -redone. - -## Out of scope - -- Any behaviour that *acts* on a link drop — that is Phases 2/4/5. Phase 0 only - makes the drop possible and the reason log honest. -- LAN disconnects; the owner token (Phase 2) handles LAN, and TCP has no HCI reason - to preserve. diff --git a/docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md b/docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md deleted file mode 100644 index f06ecff..0000000 --- a/docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md +++ /dev/null @@ -1,1083 +0,0 @@ -# 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 deleted file mode 100644 index 21f72ce..0000000 --- a/docs/PLAN_PHASE2_BOUND_WAITS_2026-07-26.md +++ /dev/null @@ -1,1465 +0,0 @@ -# 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 deleted file mode 100644 index 60f9664..0000000 --- a/docs/PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md +++ /dev/null @@ -1,278 +0,0 @@ -# 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 deleted file mode 100644 index 10ebc53..0000000 --- a/docs/PLAN_PHASE3_SESSION_GUARD_2026-07-26.md +++ /dev/null @@ -1,1469 +0,0 @@ -# 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 deleted file mode 100644 index ab45de9..0000000 --- a/docs/TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md +++ /dev/null @@ -1,1182 +0,0 @@ -# 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.