From 87f69caef657ab3fb6210be5adb68793c69bfb03 Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:07:22 -0400 Subject: [PATCH 01/11] fix(nonce): replace 512 B replay value ring with a 32 B sliding bitmap Step 1 of docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. New dependency-free src/nonce_window.h holds the whole window state machine as static inline functions over plain values: no Arduino, no mbedtls, no millis(), no logging, no session. That is what tools/test_nonce_window.cpp targets (Decision D), and it keeps nonceCheck/nonceCommit file-static in encryption.cpp (Decision C) at no cost to testability. Representation is IPsec/DTLS shifting style (RFC 4303 / RFC 6347), not the circular RFC 6479 / WireGuard form (Decision B): bit i == "counter (last_seen - i) consumed", bit 0 == last_seen. Eviction and falling out of window become the same event, so the hand-maintained D >= 2W coupling between constants in two files ceases to exist. That representation deletes two defects outright rather than patching them: D3 - no reserved sentinel, so "not seen" is a clear bit and the counter_diff != 0 exemption that made the highest-seen frame replayable is gone. [H3]: that exemption also flushed the ring, unlocking the last 32 genuine counters, not just the last one. D4 - a bitmap has no insertion point, so the function-static replay_window_index cannot recur. OD_NONCE_BACKWARD_BITS 256 (uint64_t[4], 32 B) is kept strictly greater than OD_NONCE_FORWARD_CAP 128 so a legal forward slide can never exceed the bitmap width, keeping the wholesale-clear branch off the normal path. Cap is 128, not 64 [C1]: the old derivation (PIPE_MAX_W + MAX_PTO = 35, asserted as a hard bound) missed the client's selective-repair transmit site, which spends no window credit; the reachable gap is ~96 at blocks_per_ack = 1. Under a bitmap the cap is a comparison, not storage, so the width is free. encryptionSession shrinks by exactly 480 B (verified: sizeof 0x118 on esp32-N4). --- src/encryption_state.h | 9 ++- src/nonce_window.h | 136 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 src/nonce_window.h diff --git a/src/encryption_state.h b/src/encryption_state.h index 88e8f63..3c972ca 100644 --- a/src/encryption_state.h +++ b/src/encryption_state.h @@ -4,6 +4,7 @@ #include #include #include "structs.h" +#include "nonce_window.h" #ifdef TARGET_ESP32 #include "mbedtls/ccm.h" #endif @@ -18,7 +19,13 @@ struct EncryptionSession { #endif uint64_t nonce_counter; uint64_t last_seen_counter; - uint64_t replay_window[64]; + // Anti-replay: bit i == "counter (last_seen_counter - i) has been consumed". + // Bit 0 is last_seen_counter itself. The backward window is implicitly + // OD_NONCE_BACKWARD_BITS - 1; there is no separate window constant to keep + // in step, and no insertion index to reset. Replaces a uint64_t[64] value + // ring (512 B -> 32 B). See src/nonce_window.h and + // docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md Step 1 / Decision B. + uint64_t replay_bitmap[OD_NONCE_BITMAP_WORDS]; uint32_t last_activity; uint8_t integrity_failures; uint32_t session_start_time; diff --git a/src/nonce_window.h b/src/nonce_window.h new file mode 100644 index 0000000..d08b524 --- /dev/null +++ b/src/nonce_window.h @@ -0,0 +1,136 @@ +#ifndef NONCE_WINDOW_H +#define NONCE_WINDOW_H + +// Anti-replay sliding window — pure state machine, ZERO dependencies. +// +// This header deliberately includes nothing from Arduino, mbedtls, or the +// firmware logging layer, and it touches no global state. Everything here +// operates on plain values passed in by the caller, so the whole state machine +// can be compiled and exercised on a host under UBSan/ASan by +// tools/test_nonce_window.cpp. See docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md +// Decision D. +// +// Representation (RFC 4303 / RFC 6347 "shifting" style, as opposed to the +// circular RFC 6479 / WireGuard style — see Decision B): +// +// bit i of the bitmap == "counter (last_seen - i) has been consumed". +// bit 0 is last_seen itself. +// +// The backward window is therefore implicitly OD_NONCE_BACKWARD_BITS - 1; there +// is no separate window constant to keep in step, and no insertion index to +// reset. "Not seen" is a clear bit rather than a reserved sentinel value, so a +// fresh session (last_seen = 0, all-zero bitmap) accepts counter 0 exactly once +// with no has_seen_counter flag. + +#include +#include + +// Width of the backward (out-of-order tolerance) window, in bits. +// uint64_t[4] = 32 B. Kept strictly greater than OD_NONCE_FORWARD_CAP so that a +// legal forward slide can never exceed the bitmap width — that keeps the +// wholesale-clear branch in od_nonce_commit() off the normal path. +#define OD_NONCE_BACKWARD_BITS 256 + +// Largest forward jump that is accepted. This is a HEURISTIC, not an invariant +// firmware can prove: the real bound lives in the client's retransmit budget +// (max_retx = max(3*W, n/2)) and its blocks_per_ack setting, both of which live +// in another repo and one of which is a user-facing Home Assistant option. See +// Decision A in docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. +#define OD_NONCE_FORWARD_CAP 128 + +#define OD_NONCE_BITMAP_WORDS (OD_NONCE_BACKWARD_BITS / 64) + +enum NonceResult { + NONCE_OK = 0, + NONCE_BAD_SESSION, + NONCE_OUT_OF_WINDOW, + NONCE_REPLAY +}; + +static inline bool od_nonce_bit_test(const uint64_t* bm, uint64_t bit) { + return ((bm[(size_t)(bit >> 6)] >> (unsigned)(bit & 63u)) & 1ull) != 0ull; +} + +static inline void od_nonce_bit_set(uint64_t* bm, uint64_t bit) { + bm[(size_t)(bit >> 6)] |= (1ull << (unsigned)(bit & 63u)); +} + +// Pure: decides whether `counter` may be accepted. Writes nothing. +// +// 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 require fwd + back <= 384 == 0 (mod 2^64), +// true only when both are zero — the case the first test already consumed. A +// counter far from the window in either direction leaves both huge and falls +// through to NONCE_OUT_OF_WINDOW. Do not reorder. +// +// Unsigned wrapping arithmetic only: the counter is parsed off the wire BEFORE +// the CCM tag is verified, so an unauthenticated attacker controls both +// operands. Converting a uint64_t >= 2^63 to int64_t is implementation-defined +// before C++20, the signed subtraction can overflow, and negating INT64_MIN is +// UB — three ways to be undefined on attacker-chosen input. Unsigned overflow +// is defined as modular arithmetic, making this total over all 2^64 inputs. +static inline NonceResult od_nonce_check(const uint64_t* bm, uint64_t last_seen, uint64_t counter) { + 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 == 0u) return od_nonce_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 od_nonce_bit_test(bm, back) ? NONCE_REPLAY : NONCE_OK; + return NONCE_OUT_OF_WINDOW; +} + +// Shift the bitmap left by `shift` bits (bit i -> bit i + shift), clearing the +// vacated low bits. Handles shift == 0 and shift >= 64 explicitly: `x << 64` is +// undefined behaviour in C/C++ and is the classic bug in this pattern. +static inline void od_nonce_bitmap_shift_left(uint64_t* bm, uint64_t shift) { + if (shift == 0u) return; + if (shift >= OD_NONCE_BACKWARD_BITS) { + memset(bm, 0, sizeof(uint64_t) * OD_NONCE_BITMAP_WORDS); + return; + } + const size_t word_shift = (size_t)(shift >> 6); + const unsigned bit_shift = (unsigned)(shift & 63u); + for (size_t i = OD_NONCE_BITMAP_WORDS; i-- > 0;) { + uint64_t v = 0u; + if (i >= word_shift) { + v = bm[i - word_shift] << bit_shift; + if (bit_shift != 0u && i > word_shift) { + v |= bm[i - word_shift - 1] >> (64u - bit_shift); + } + } + bm[i] = v; + } +} + +// Records `counter` as consumed. MUST only be called after the frame carrying +// it has been authenticated (CCM tag verified) — that is the D2 fix. +// +// Total for every input, including inputs od_nonce_check() would have rejected: +// the host test calls it directly, and a future OD_NONCE_FORWARD_CAP increase +// must not be able to produce an over-wide shift. The wholesale-clear path is +// unreachable through od_nonce_check() today (cap 128 < 256 bits) but is still +// correct when it fires: every counter it discards is then >= 256 behind and is +// rejected on width, never mis-reported as unseen. +static inline void od_nonce_commit(uint64_t* bm, uint64_t* last_seen, uint64_t counter) { + const uint64_t fwd = counter - *last_seen; + const uint64_t back = *last_seen - counter; + + if (fwd == 0u) { + od_nonce_bit_set(bm, 0); + return; + } + if (back < OD_NONCE_BACKWARD_BITS) { + /* backward, inside the window: last_seen does not move. + Unambiguous: fwd and back are complements mod 2^64, so back < 256 + forces fwd >= 2^64 - 255 — they can never both be small. */ + od_nonce_bit_set(bm, back); + return; + } + /* forward */ + od_nonce_bitmap_shift_left(bm, fwd); + *last_seen = counter; + od_nonce_bit_set(bm, 0); +} + +#endif // NONCE_WINDOW_H From 5a9d4370298a5b6cbe64eba043665d73b9fa59da Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:07:22 -0400 Subject: [PATCH 02/11] fix(nonce): split check from commit; stop counting packet loss as tampering Steps 2-4 of docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. verifyNonceReplay() is deleted outright (Decision C - no compatibility wrapper), along with its declarations in encryption.h and the duplicate in main.h. D1 - a nonce rejection no longer touches integrity_failures. The rule in one line: only a CCM tag failure is evidence of tampering; a nonce failure is evidence of a lossy link. Previously a lost window put the next frame out of range and each such frame counted toward session destruction, after which the device answered RESP_AUTH_REQUIRED to everything until reconnect. D2 - nonceCheck() is pure. It writes nothing to encryptionSession on any path, so replay state is advanced only by nonceCommit(), which runs as the FIRST statement of decryptCommand's success arm - after aes_ccm_decrypt. Placement is load-bearing [L2]: there is an early return in that arm for a decrypted-but-malformed payload_length, and that frame is authentic (it passed the tag). Today's unconditional commit does record it; committing after the early return would silently leave an authentic frame replayable. M1 - unsigned wrapping deltas only, no signed arithmetic. The 8 counter bytes are parsed off the wire before the tag is verified, so an unauthenticated attacker controls both operands: (int64_t)counter for >= 2^63 is implementation-defined pre-C++20, the subtraction can overflow, and negating INT64_MIN is UB. Unsigned overflow is defined as modular arithmetic, making the expression total over all 2^64 inputs. The four tests in od_nonce_check are ordered and the order is load-bearing - fwd and back are complements mod 2^64 and cannot both be small. M3 - resetNonceState() names all four shared fields explicitly. The two callers share only these four and are opposite on everything else, so a helper described loosely as "the bitmap and last_seen_counter" would invite dropping nonce_counter = 0 - which would carry the device's outbound counter across a re-auth while the client restarts at 0, walking into the [H2] keystream reuse against itself. L7 - both nonce-rejection logs demoted from ERROR to WARN and rate-limited to one per 5 s (one shared budget, so alternating between them cannot bypass it). The out-of-window log now fires routinely on a lossy link, and with counting removed nothing else throttles a peer driving the session-id line, which also no longer dumps two full session IDs. Routing NONCE_BAD_SESSION to "integrity_failures untouched" is a deliberate policy change, not a consequence of D1: a mismatched session id is usually a stale client talking to a device that re-authenticated. decryptCommand gains a NonceResult* reason out-param (internal signature only, nothing on the wire) so its single caller can distinguish nonce rejection from tag failure. Step 4b uses it. --- src/encryption.cpp | 133 +++++++++++++++++++++++++++++---------------- src/encryption.h | 10 +++- src/main.h | 3 +- 3 files changed, 95 insertions(+), 51 deletions(-) diff --git a/src/encryption.cpp b/src/encryption.cpp index 0b9ac75..1ebbae8 100644 --- a/src/encryption.cpp +++ b/src/encryption.cpp @@ -112,48 +112,70 @@ void deriveSessionId(const uint8_t* session_key, const uint8_t* client_nonce, } } -bool verifyNonceReplay(uint8_t* nonce) { - if (!encryptionSession.authenticated) return false; +// Reset ONLY the four nonce/replay fields. Named explicitly rather than +// described loosely ([M3]): clearEncryptionSession() and handleAuthenticate()'s +// fresh-session block share exactly these four and are *opposite* on everything +// else (authenticated false vs true, timestamps zeroed vs stamped, keys wiped vs +// populated). In particular nonce_counter — the device's OWN outbound counter — +// must keep being zeroed here: a device that carried it across a re-auth while +// the client restarts at 0 would reproduce the [H2] keystream reuse against +// itself. See docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md Step 1. +static void resetNonceState(void) { + encryptionSession.nonce_counter = 0; /* device's OWN outbound counter */ + encryptionSession.last_seen_counter = 0; + encryptionSession.integrity_failures = 0; + memset(encryptionSession.replay_bitmap, 0, sizeof(encryptionSession.replay_bitmap)); +} + +// Rate limiter for the two nonce-rejection logs. Once nonce failures stop +// counting toward integrity_failures ([L7]) nothing else throttles a peer that +// drives these lines, and out-of-window now fires routinely on a lossy link. +static uint32_t nonce_log_last_ms = 0; +static bool nonceLogAllowed(void) { + uint32_t now = millis(); + if (nonce_log_last_ms != 0 && (uint32_t)(now - nonce_log_last_ms) < 5000u) return false; + nonce_log_last_ms = now; + return true; +} + +// PURE. Parses the counter out of the 16-byte nonce and decides whether the +// frame may be accepted. Writes NOTHING to encryptionSession on any path — that +// is the property the host test asserts by memcmp, and it is what "only a +// CCM-verified frame may advance replay state" (D2) rests on. +// +// File-static by design (Decision C): nothing outside decryptCommand should be +// able to reach the commit side. The pure logic lives in src/nonce_window.h. +static NonceResult nonceCheck(const uint8_t* nonce, uint64_t* counter_out) { uint8_t nonce_session_id[8]; uint64_t nonce_counter = 0; memcpy(nonce_session_id, nonce, 8); for (int i = 0; i < 8; i++) { nonce_counter = (nonce_counter << 8) | nonce[8 + i]; } + if (counter_out != nullptr) *counter_out = nonce_counter; + if (!constantTimeCompare(nonce_session_id, encryptionSession.session_id, 8)) { - od_log_error("ERROR: Nonce session_id mismatch\n Nonce ID: %02X%02X%02X%02X%02X%02X%02X%02X\n Expected: %02X%02X%02X%02X%02X%02X%02X%02X", - nonce_session_id[0], nonce_session_id[1], nonce_session_id[2], nonce_session_id[3], - nonce_session_id[4], nonce_session_id[5], nonce_session_id[6], nonce_session_id[7], - encryptionSession.session_id[0], encryptionSession.session_id[1], encryptionSession.session_id[2], encryptionSession.session_id[3], - encryptionSession.session_id[4], encryptionSession.session_id[5], encryptionSession.session_id[6], encryptionSession.session_id[7]); - return false; - } - int64_t counter_diff = (int64_t)nonce_counter - (int64_t)encryptionSession.last_seen_counter; - if (counter_diff < -32 || counter_diff > 32) { - od_log_error("ERROR: Nonce counter outside replay window (counter=%llu, last_seen=%llu, diff=%lld)", - (unsigned long long)nonce_counter, (unsigned long long)encryptionSession.last_seen_counter, (long long)counter_diff); - return false; - } - if (nonce_counter <= encryptionSession.last_seen_counter && counter_diff != 0) { - bool already_seen = false; - for (int i = 0; i < 64; i++) { - if (encryptionSession.replay_window[i] == nonce_counter) { - already_seen = true; - break; - } - } - if (already_seen) { - od_log_error("ERROR: Nonce counter already seen (replay detected)"); - return false; + // [L7] Demoted from ERROR + rate-limited, and the full session-id dump + // is gone: a mismatched session id is usually a stale client talking to + // a device that re-authenticated, i.e. confusion rather than attack. + // The CCM tag remains the only tamper oracle. + if (nonceLogAllowed()) { + od_log_warn("Nonce session_id mismatch (%02X%02X.. vs %02X%02X..) - frame dropped, session kept", + nonce_session_id[0], nonce_session_id[1], + encryptionSession.session_id[0], encryptionSession.session_id[1]); } + return NONCE_BAD_SESSION; } - if (nonce_counter > encryptionSession.last_seen_counter) { - encryptionSession.last_seen_counter = nonce_counter; - } - static uint8_t replay_window_index = 0; - encryptionSession.replay_window[replay_window_index] = nonce_counter; - replay_window_index = (replay_window_index + 1) % 64; - return true; + return od_nonce_check(encryptionSession.replay_bitmap, + encryptionSession.last_seen_counter, nonce_counter); +} + +// Records a counter as consumed. Called from exactly one place: as the FIRST +// statement of decryptCommand's success arm, i.e. only after aes_ccm_decrypt +// has verified the tag (the D2 fix). +static void nonceCommit(uint64_t counter) { + od_nonce_commit(encryptionSession.replay_bitmap, + &encryptionSession.last_seen_counter, counter); } void getCurrentNonce(uint8_t* nonce) { @@ -208,14 +230,11 @@ void clearEncryptionSession() { memset(encryptionSession.server_nonce, 0, 16); memset(encryptionSession.pending_server_nonce, 0, 16); encryptionSession.authenticated = false; - encryptionSession.nonce_counter = 0; - encryptionSession.last_seen_counter = 0; - encryptionSession.integrity_failures = 0; + resetNonceState(); encryptionSession.session_start_time = 0; encryptionSession.last_activity = 0; encryptionSession.auth_attempts = 0; encryptionSession.server_nonce_time = 0; - memset(encryptionSession.replay_window, 0, sizeof(encryptionSession.replay_window)); od_log_info("Encryption session cleared"); } @@ -660,12 +679,9 @@ bool handleAuthenticate(uint8_t* data, uint16_t len) { // good handshake followed by one more rejection would drop a client that // had just authenticated. resetAuthAbuseCounter(); - encryptionSession.nonce_counter = 0; - encryptionSession.last_seen_counter = 0; - encryptionSession.integrity_failures = 0; + resetNonceState(); encryptionSession.session_start_time = currentTime; encryptionSession.last_activity = currentTime; - memset(encryptionSession.replay_window, 0, sizeof(encryptionSession.replay_window)); memset(encryptionSession.pending_server_nonce, 0, 16); encryptionSession.server_nonce_time = 0; uint8_t server_response[16]; @@ -694,13 +710,30 @@ bool handleAuthenticate(uint8_t* data, uint16_t len) { } bool decryptCommand(uint8_t* ciphertext, uint16_t ciphertext_len, uint8_t* plaintext, - uint16_t* plaintext_len, uint8_t* nonce_full, uint8_t* auth_tag, uint16_t command_header) { + uint16_t* plaintext_len, uint8_t* nonce_full, uint8_t* auth_tag, uint16_t command_header, + NonceResult* reason_out) { + // reason_out reports WHY the frame was rejected so the caller can tell a + // nonce rejection (ordinary packet loss) from a CCM tag failure (tamper + // evidence). NONCE_OK means "not rejected for a nonce reason" — every + // non-nonce failure path below leaves it at NONCE_OK. See Step 4b. + if (reason_out != nullptr) *reason_out = NONCE_OK; if (!isAuthenticated()) return false; - if (!verifyNonceReplay(nonce_full)) { - encryptionSession.integrity_failures++; - if (encryptionSession.integrity_failures >= 3) { - od_log_warn("Too many integrity failures, clearing session"); - clearEncryptionSession(); + + // Nonce failures are evidence of a LOSSY LINK, not of tampering, so they do + // NOT touch integrity_failures (the D1 fix). Only the CCM tag is a tamper + // oracle. [L7]: routing NONCE_BAD_SESSION here too is a deliberate policy + // change — a session-id mismatch is what a stale client sends after the + // device re-authenticated. See Step 4. + uint64_t nonce_counter = 0; + NonceResult nr = nonceCheck(nonce_full, &nonce_counter); + if (nr != NONCE_OK) { + if (reason_out != nullptr) *reason_out = nr; + if (nr != NONCE_BAD_SESSION && nonceLogAllowed()) { + od_log_warn("Nonce %s (counter=%llu last_seen=%llu fwd=%llu) - frame dropped, session kept", + (nr == NONCE_REPLAY) ? "replay" : "out-of-window", + (unsigned long long)nonce_counter, + (unsigned long long)encryptionSession.last_seen_counter, + (unsigned long long)(nonce_counter - encryptionSession.last_seen_counter)); } return false; } @@ -723,6 +756,12 @@ bool decryptCommand(uint8_t* ciphertext, uint16_t ciphertext_len, uint8_t* plain ad, 2, ciphertext, encrypted_len, decrypted_with_length, auth_tag, ENCRYPTION_TAG_SIZE); if (success) { + // [L2] FIRST statement of the success arm — deliberately ahead of the + // early return for a malformed payload_length below. That frame is + // *authentic* (it passed the CCM tag) and today's unconditional commit + // does record it; committing after the early return would silently + // leave an authentic frame replayable. + nonceCommit(nonce_counter); uint8_t payload_length = decrypted_with_length[0]; if (payload_length > encrypted_len - 1) { od_log_error("ERROR: Invalid payload length in decrypted data"); diff --git a/src/encryption.h b/src/encryption.h index 374cd6e..f17c93c 100644 --- a/src/encryption.h +++ b/src/encryption.h @@ -3,6 +3,7 @@ #include #include +#include "nonce_window.h" bool deriveSessionKey(const uint8_t* master_key, const uint8_t* client_nonce, const uint8_t* server_nonce, uint8_t* session_key); @@ -14,12 +15,17 @@ bool isAuthenticated(); void clearEncryptionSession(); bool checkEncryptionSessionTimeout(); void updateEncryptionSessionActivity(); -bool verifyNonceReplay(uint8_t* nonce); void getCurrentNonce(uint8_t* nonce); void incrementNonceCounter(); bool handleAuthenticate(uint8_t* data, uint16_t len); +/// Decrypt one CCM-enveloped command. On failure, *reason_out (if non-null) +/// reports whether the frame was rejected for a NONCE reason — ordinary packet +/// loss, which the caller must NOT answer with a fatal NACK on the pipe path +/// (Step 4b) — or is NONCE_OK, meaning the failure was a tag failure or a +/// malformed frame, which keeps today's NACK. bool decryptCommand(uint8_t* ciphertext, uint16_t ciphertext_len, uint8_t* plaintext, - uint16_t* plaintext_len, uint8_t* nonce, uint8_t* auth_tag, uint16_t command_header); + uint16_t* plaintext_len, uint8_t* nonce, uint8_t* auth_tag, uint16_t command_header, + NonceResult* reason_out); bool encryptResponse(uint8_t* plaintext, uint16_t plaintext_len, uint8_t* ciphertext, uint16_t* ciphertext_len, uint8_t* nonce, uint8_t* auth_tag); /// Derive the 16-byte TLS-PSK for the LAN TLS channel from the configured master diff --git a/src/main.h b/src/main.h index dbb0a43..d9f93e9 100644 --- a/src/main.h +++ b/src/main.h @@ -257,9 +257,8 @@ void clearEncryptionSession(); bool checkEncryptionSessionTimeout(); void updateEncryptionSessionActivity(); bool handleAuthenticate(uint8_t* data, uint16_t len); -bool decryptCommand(uint8_t* ciphertext, uint16_t ciphertext_len, uint8_t* plaintext, uint16_t* plaintext_len, uint8_t* nonce, uint8_t* auth_tag, uint16_t command_header); +bool decryptCommand(uint8_t* ciphertext, uint16_t ciphertext_len, uint8_t* plaintext, uint16_t* plaintext_len, uint8_t* nonce, uint8_t* auth_tag, uint16_t command_header, NonceResult* reason_out); bool encryptResponse(uint8_t* plaintext, uint16_t plaintext_len, uint8_t* ciphertext, uint16_t* ciphertext_len, uint8_t* nonce, uint8_t* auth_tag); -bool verifyNonceReplay(uint8_t* nonce); void incrementNonceCounter(); void getCurrentNonce(uint8_t* nonce); From f7894bc94e907a7cbd36fc8b92742a7e1cd8aa86 Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:07:38 -0400 Subject: [PATCH 03/11] fix(pipe): do not answer a nonce-dropped 0x0081 frame with a fatal NACK Step 4b / [H1] of docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. Without this, Phase 1 saves the device but still loses the transfer. Every decryptCommand failure - nonce and tag alike - produced the same unencrypted 3-byte RESP_NACK, and the client turns that shape into IntegrityCheckError before it ever reaches pipe-frame classification (device.py:833-838). The pipe send loop's only except is BLETimeoutError, so one out-of-window frame aborts the whole upload - and the frame the client would have repaired is the one whose NACK killed the transfer. No forward cap is wide enough to fix that. Now: NONCE_OUT_OF_WINDOW / NONCE_REPLAY on CMD_PIPE_WRITE_DATA sends NOTHING. Silence is already a first-class signal on the pipe path - it means "lost", the seq is absent from the next SACK mask, the client retransmits, and the transfer continues. This is a conformance fix, not a protocol change. pipe-write-protocol.md 5.2 already reserves NACKs for unrecoverable conditions, "not ordinary packet loss", and 5.1 makes an 0x81 NACK unconditionally fatal - so today's firmware violates the pipe spec as written. No opcode, response code, or envelope changes; no canonical-header edit. Deliberately narrow: tag failures keep the NACK (tamper evidence, not loss), and 0x0071 legacy DIRECT_WRITE_DATA is left alone - different ACK discipline, not analysed here, and the field failure lives on the pipe path. Step 6 - the stale comment at the 0x0081 case rewritten. Per [C1] it now states the MECHANISM rather than a number: the forward gap is bounded by the client's retransmit budget max_retx = max(3*W, n/2) and by blocks_per_ack, which live in another repo and one of which is a user-facing Home Assistant option, so OD_NONCE_FORWARD_CAP is a heuristic with headroom, not an invariant firmware can prove. A number written here would be falsified silently by a client-side config change. --- src/communication.cpp | 48 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/communication.cpp b/src/communication.cpp index 4101e3f..7ca4123 100644 --- a/src/communication.cpp +++ b/src/communication.cpp @@ -763,7 +763,30 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin // screen. It moves to the failure path below, where it is the only thing that // separates a replay-window jump from nonce reuse from a wrong session key, // and where nRF (which has no RX hex line at all) would otherwise be blind. - if (!decryptCommand(data + BLE_CMD_HEADER_SIZE + ENCRYPTION_NONCE_SIZE, encrypted_data_len, plaintext, &plaintext_len, nonce_full, auth_tag, command)) { + NonceResult decrypt_reason = NONCE_OK; + if (!decryptCommand(data + BLE_CMD_HEADER_SIZE + ENCRYPTION_NONCE_SIZE, encrypted_data_len, plaintext, &plaintext_len, nonce_full, auth_tag, command, &decrypt_reason)) { + // Step 4b / [H1]: a pipe DATA frame rejected because its nonce fell + // outside the window IS ordinary packet loss - it is the direct + // consequence of frames having been dropped. pipe-write-protocol.md + // §5.2 already reserves NACKs for unrecoverable conditions, "not + // ordinary packet loss", and §5.1 makes a 0x81 NACK unconditionally + // fatal, so answering one here violates the spec as written. Send + // NOTHING: silence is a first-class signal on the pipe path. The seq + // is absent from the next SACK mask, the client retransmits it, and + // the transfer continues. Answering with the 3-byte NACK instead + // makes the client raise IntegrityCheckError, which its pipe send + // loop does not catch, killing the whole upload on the first + // rejected frame. + // + // Deliberately narrow: + // - TAG failures keep the NACK. They are tamper evidence, not loss. + // - 0x0071 (legacy DIRECT_WRITE_DATA) is left alone on purpose: it + // has a different ACK discipline that has not been analysed, and + // the field failure lives on the pipe path. Not an oversight. + const bool nonce_loss = (decrypt_reason == NONCE_OUT_OF_WINDOW || decrypt_reason == NONCE_REPLAY); + if (nonce_loss && command == CMD_PIPE_WRITE_DATA) { + return; + } // 16 bytes render as 47 chars + NUL, an exact fit in 48; sized past that // so a future ENCRYPTION_NONCE_SIZE bump truncates nothing. char nonceHex[64]; @@ -846,10 +869,25 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin handlePipeWriteStart(data + 2, len - 2); break; case CMD_PIPE_WRITE_DATA: // 0x0081 - // The replay counter (verifyNonceReplay) already advanced at decrypt time, - // above this switch, for every 0x0081 frame — including ones the handler - // then queues or discards — so drops/dupes never desync it and the counter - // delta stays within in-flight <= W <= 32 <= the +-32 replay window. + // Replay state is committed at decrypt time (nonceCommit, first + // statement of decryptCommand's success arm) for every 0x0081 frame + // that authenticates — including ones this handler then queues or + // discards — so drops/dupes never desync it. + // + // What bounds the forward gap is NOT a firmware constant. The client + // burns a nonce counter per *transmission*, landed or not, and its + // three transmit sites are new sends (window-credit-limited), PTO + // probes, and selective repair — and selective repair spends no + // window credit at all. The only ceiling is the client's retransmit + // budget max_retx = max(3*W, n/2), scaled by blocks_per_ack, which + // is a user-facing Home Assistant option (1..32) in another repo. + // OD_NONCE_FORWARD_CAP (src/nonce_window.h) is therefore a HEURISTIC + // with headroom over the realistic worst case, not an invariant this + // firmware can prove; a client-side blocks_per_ack change would + // silently falsify any specific number written here. A gap beyond + // the cap is no longer fatal: the frame is dropped silently (Step 4b + // above) and repaired by the normal SACK path. See Decision A / [C1] + // in docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. handlePipeWriteData(data + 2, len - 2); break; case CMD_PIPE_WRITE_END: // 0x0082 From bd61ab35a59bcd3fbfe540cea6d50c1efd5f29d9 Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:09:18 -0400 Subject: [PATCH 04/11] ci: separate host-tests job for the nonce-window state machine [L5] of docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. A top-level job, NOT a step inside the existing `build` job - that is an 11-entry matrix and would run the host test eleven times. Needs no toolchain beyond the runner's stock g++. -fsanitize=undefined,address is the point of the gate, not decoration: it is what catches the `x << 64` UB in the bitmap shift automatically rather than relying on the test author to predict it. tools/ is invisible to every firmware build (build_src_filter is relative to src_dir and no env adds tools/), so the test file cannot perturb the matrix. --- .github/workflows/main.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 5b5c400..1376122 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -8,6 +8,26 @@ permissions: contents: read jobs: + # Separate top-level job on purpose ([L5]): the `build` job below is an + # 11-entry matrix, so a step added there would run the host test eleven times. + # This needs no toolchain beyond the runner's stock g++ and gates every push + # alongside the firmware builds. tools/ is invisible to every firmware build + # (build_src_filter is relative to src_dir and no env adds tools/), so the + # test file cannot affect the matrix. + host-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build and run the nonce-window host test + # -fsanitize=undefined is not decoration: it is what catches the `x << 64` + # UB in the bitmap shift automatically rather than relying on the test + # author to predict it. See Decision D in + # docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. + run: | + g++ -std=c++17 -Wall -Wextra -Werror -O1 -fsanitize=undefined,address \ + tools/test_nonce_window.cpp -o /tmp/test_nonce_window + /tmp/test_nonce_window + # The environment list lives in .github/firmware-targets.json, shared with # release.yml. Keeping one list means CI can't build a target the release # forgets to ship (which is how esp32-N4 went unreleased) or vice versa. From f02e7044e27b8046b6b54eb966996f878d6a879c Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:10:24 -0400 Subject: [PATCH 05/11] test: host test for the nonce sliding-window state machine Step 5 / Decision D of docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. Standalone, no framework, no PlatformIO env and no test/ directory - so the 11-env matrix and a bare `pio run` are untouched. Includes ONLY src/nonce_window.h. 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 -> PASSED 38199 checks Coverage: - Purity of od_nonce_check (D2) - the single most valuable assertion here. Snapshot the state, call every result class, memcmp byte-for-byte after. "The tag is the only thing that may advance replay state" rests on it. - D3 at fwd == 0: re-presenting a committed counter is REPLAY, including the case today's firmware exempts. - Fresh session: counter 0 accepted exactly once from a virgin state, with no has_seen_counter sentinel anywhere. - Shift edges: fwd = 0, 1, 63, 64, 65, 127, 128 through od_nonce_check (the reachable range), plus 129, 191, 192, 255, 256, 257 driven directly against od_nonce_commit for the word boundaries and the wholesale-clear guard. - Wholesale slide asserts the EXACT enum: previously-seen counters come back OUT_OF_WINDOW, not REPLAY. Both reject, so conflating them would be invisible in behaviour and would hide a genuine slide bug. - Bit-index invariant stated directly: the bit denoting (L - i) must sit at i + d under L' = L + d. - [M1] counter arithmetic at counter = 2^63 / last_seen = 1 and around UINT64_MAX including the wrap - the inputs that make the signed form UB. UBSan makes this self-checking. - Differential test against a std::set oracle, fixed mt19937_64 seed, with a full backward-window sweep at the end of each sequence. The oracle prunes counters that fall out of the window so it agrees on REPLAY vs OUT_OF_WINDOW, not merely on accept vs reject. __ubsan_on_report is overridden so a UBSan report exits nonzero: UBSan defaults to print-and-continue, which would let a reintroduced signed-arithmetic regression pass CI with a runtime-error line nobody reads. Mutation-checked: breaking the backward bit test, the cap comparison, the fwd == 0 bit test, or the cross-word shift carry all fail the test. --- tools/test_nonce_window.cpp | 702 ++++++++++++++++++++++++++++++++++++ 1 file changed, 702 insertions(+) create mode 100644 tools/test_nonce_window.cpp diff --git a/tools/test_nonce_window.cpp b/tools/test_nonce_window.cpp new file mode 100644 index 0000000..c73862c --- /dev/null +++ b/tools/test_nonce_window.cpp @@ -0,0 +1,702 @@ +// Host test for src/nonce_window.h — standalone, no test framework. +// +// Build and run from the repo root: +// +// g++ -std=c++17 -Wall -Wextra -Werror -O1 -fsanitize=undefined,address +// tools/test_nonce_window.cpp -o /tmp/test_nonce_window +// /tmp/test_nonce_window +// +// This file is as much a written-down statement of the intended semantics of the +// anti-replay window as it is a test. Each block names the coverage item from +// docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md, Decision D, that it discharges. +// +// The representation under test (see the header): +// bit i of the bitmap == "counter (last_seen - i) has been consumed" +// bit 0 == last_seen itself +// so the backward window is OD_NONCE_BACKWARD_BITS wide (indices 0..255) and the +// forward acceptance window is OD_NONCE_FORWARD_CAP wide. + +#include "../src/nonce_window.h" + +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Make UBSan reports fatal +// +// UBSan defaults to "print and keep going", so a regression that reintroduces +// signed counter arithmetic would emit a runtime-error line and still exit 0 — +// invisible to CI. __ubsan_on_report is the sanitizer's weak notification hook; +// overriding it turns any report into a nonzero exit without needing the caller +// to remember UBSAN_OPTIONS=halt_on_error=1. +// +// Defined unconditionally: libubsan declares it weak, so this overrides it when +// the sanitizer is linked in, and is simply unreferenced when it is not. (It +// cannot be guarded on a macro — GCC only started defining __SANITIZE_UNDEFINED__ +// in GCC 14, and this must work on older CI runners.) +// --------------------------------------------------------------------------- + +extern "C" void __ubsan_on_report(void); +extern "C" void __ubsan_on_report(void) { + std::fputs("FAIL: undefined behaviour reported by UBSan (see the runtime error above)\n", + stderr); + std::fflush(stderr); + std::_Exit(EXIT_FAILURE); +} + +// --------------------------------------------------------------------------- +// Minimal check harness +// --------------------------------------------------------------------------- + +static unsigned long g_checks = 0; +static unsigned long g_failures = 0; + +// Free-form context describing the enclosing loop iteration, printed on failure +// so that a failing case inside a data-driven loop is identifiable. +static char g_context[256] = ""; + +static void set_context(const char* fmt, ...) __attribute__((format(printf, 1, 2))); + +static void set_context(const char* fmt, ...) { + va_list ap; + va_start(ap, fmt); + vsnprintf(g_context, sizeof(g_context), fmt, ap); + va_end(ap); +} + +static void clear_context(void) { g_context[0] = '\0'; } + +static const char* res_name(NonceResult r) { + switch (r) { + case NONCE_OK: return "NONCE_OK"; + case NONCE_BAD_SESSION: return "NONCE_BAD_SESSION"; + case NONCE_OUT_OF_WINDOW: return "NONCE_OUT_OF_WINDOW"; + case NONCE_REPLAY: return "NONCE_REPLAY"; + } + return "NONCE_"; +} + +static void report_failure(const char* file, int line, const char* expr) { + ++g_failures; + if (g_context[0] != '\0') { + std::printf("FAIL %s:%d [%s]: %s\n", file, line, g_context, expr); + } else { + std::printf("FAIL %s:%d: %s\n", file, line, expr); + } +} + +#define CHECK(expr) \ + do { \ + ++g_checks; \ + if (!(expr)) report_failure(__FILE__, __LINE__, #expr); \ + } while (0) + +// Compares two NonceResult values and prints both symbolic names on mismatch. +#define CHECK_RES(actual, expected) \ + do { \ + ++g_checks; \ + const NonceResult check_a_ = (actual); \ + const NonceResult check_e_ = (expected); \ + if (check_a_ != check_e_) { \ + ++g_failures; \ + if (g_context[0] != '\0') { \ + std::printf("FAIL %s:%d [%s]: %s -> got %s, want %s\n", \ + __FILE__, __LINE__, g_context, #actual, \ + res_name(check_a_), res_name(check_e_)); \ + } else { \ + std::printf("FAIL %s:%d: %s -> got %s, want %s\n", __FILE__, \ + __LINE__, #actual, res_name(check_a_), \ + res_name(check_e_)); \ + } \ + } \ + } while (0) + +// --------------------------------------------------------------------------- +// State container +// --------------------------------------------------------------------------- + +struct NonceState { + uint64_t bm[OD_NONCE_BITMAP_WORDS]; + uint64_t last_seen; +}; + +// All members are uint64_t, so the struct has no padding and can be compared +// byte-for-byte by the purity test below. +static_assert(sizeof(NonceState) == sizeof(uint64_t) * (OD_NONCE_BITMAP_WORDS + 1), + "NonceState must be padding-free for the byte-identical purity check"); + +static void state_reset(NonceState* s, uint64_t last_seen) { + std::memset(s->bm, 0, sizeof(s->bm)); + s->last_seen = last_seen; +} + +static NonceResult check(const NonceState* s, uint64_t counter) { + return od_nonce_check(s->bm, s->last_seen, counter); +} + +static void commit(NonceState* s, uint64_t counter) { + od_nonce_commit(s->bm, &s->last_seen, counter); +} + +// Accept-and-record, the way firmware uses the pair: check, and only commit if +// the frame would be accepted (and, in firmware, only after the CCM tag verifies). +static NonceResult check_and_commit(NonceState* s, uint64_t counter) { + const NonceResult r = check(s, counter); + if (r == NONCE_OK) commit(s, counter); + return r; +} + +// --------------------------------------------------------------------------- +// Decision D coverage: fresh session (last_seen = 0, empty bitmap) +// +// "Not seen" is a clear bit, not a reserved sentinel, so counter 0 on a virgin +// state is accepted exactly once with no has_seen_counter flag anywhere. +// --------------------------------------------------------------------------- + +static void test_fresh_session(void) { + NonceState s; + state_reset(&s, 0); + + CHECK_RES(check(&s, 0), NONCE_OK); + commit(&s, 0); + CHECK_RES(check(&s, 0), NONCE_REPLAY); + + // last_seen has not moved: committing at fwd == 0 only sets bit 0. + CHECK(s.last_seen == 0); + CHECK(od_nonce_bit_test(s.bm, 0)); + + // Counter 1 is one ahead and cannot have been seen. + CHECK_RES(check(&s, 1), NONCE_OK); + + // Counter UINT64_MAX is one *behind* 0 under modular arithmetic (back == 1), + // inside the backward window, and its bit is clear. + CHECK_RES(check(&s, UINT64_MAX), NONCE_OK); + + // ...and 256 behind is off the end of the window. + CHECK_RES(check(&s, 0u - (uint64_t)OD_NONCE_BACKWARD_BITS), NONCE_OUT_OF_WINDOW); + CHECK_RES(check(&s, 0u - (uint64_t)(OD_NONCE_BACKWARD_BITS - 1)), NONCE_OK); +} + +// --------------------------------------------------------------------------- +// Decision D coverage: D3 — re-presenting a committed counter is REPLAY, +// including at fwd == 0, the case today's firmware exempts. +// --------------------------------------------------------------------------- + +static void test_d3_same_counter_replay(void) { + const uint64_t base = 1000000u; + + // fwd == 0: the exempted case. + { + NonceState s; + state_reset(&s, base); + CHECK_RES(check(&s, base), NONCE_OK); // virgin bit 0 + commit(&s, base); + CHECK_RES(check(&s, base), NONCE_REPLAY); + CHECK_RES(check(&s, base), NONCE_REPLAY); // and it stays REPLAY + } + + // Forward acceptance then immediate re-presentation: the accepted counter + // becomes the new last_seen, so the replay lands on fwd == 0 again. + { + NonceState s; + state_reset(&s, base); + commit(&s, base); + CHECK_RES(check_and_commit(&s, base + 5), NONCE_OK); + CHECK(s.last_seen == base + 5); + CHECK_RES(check(&s, base + 5), NONCE_REPLAY); + // The old last_seen is now 5 behind and still recorded. + CHECK_RES(check(&s, base), NONCE_REPLAY); + // A gap counter between them was never committed. + CHECK_RES(check(&s, base + 3), NONCE_OK); + } + + // Backward, in-window acceptance then re-presentation. + { + NonceState s; + state_reset(&s, base); + commit(&s, base); + CHECK_RES(check_and_commit(&s, base - 100), NONCE_OK); + CHECK(s.last_seen == base); // backward commits never move last_seen + CHECK_RES(check(&s, base - 100), NONCE_REPLAY); + CHECK_RES(check(&s, base - 99), NONCE_OK); + CHECK_RES(check(&s, base - 101), NONCE_OK); + } +} + +// --------------------------------------------------------------------------- +// Decision D coverage: purity of od_nonce_check (D2) +// +// THE most important assertion in this file. "The tag is the only thing that may +// advance replay state" rests entirely on od_nonce_check writing nothing, so a +// pre-authentication call on attacker-controlled input cannot poison the window. +// Every result class is exercised against one snapshot and the whole state is +// compared byte-for-byte afterwards. +// --------------------------------------------------------------------------- + +static void test_check_is_pure(void) { + const uint64_t base = 500000u; + + NonceState s; + state_reset(&s, base); + // Build a non-trivial bitmap: bits at word boundaries and in between. + commit(&s, base); + const uint64_t seen_offsets[] = {1, 2, 63, 64, 65, 127, 128, 191, 192, 254, 255}; + for (size_t i = 0; i < sizeof(seen_offsets) / sizeof(seen_offsets[0]); ++i) { + commit(&s, base - seen_offsets[i]); + } + + unsigned char snapshot[sizeof(NonceState)]; + std::memcpy(snapshot, &s, sizeof(snapshot)); + + // One input per result class produced by od_nonce_check. + struct Case { + uint64_t counter; + NonceResult expected; + const char* what; + }; + const Case cases[] = { + {base, NONCE_REPLAY, "fwd == 0, bit 0 set -> REPLAY"}, + {base + 1, NONCE_OK, "forward by 1 -> OK"}, + {base + OD_NONCE_FORWARD_CAP, NONCE_OK, "forward at the cap -> OK"}, + {base - 3, NONCE_OK, "backward, unseen -> OK"}, + {base - 64, NONCE_REPLAY, "backward, seen -> REPLAY"}, + {base + OD_NONCE_FORWARD_CAP + 1, NONCE_OUT_OF_WINDOW, "past the forward cap"}, + {base - OD_NONCE_BACKWARD_BITS, NONCE_OUT_OF_WINDOW, "past the backward window"}, + {base + (1ull << 62), NONCE_OUT_OF_WINDOW, "far ahead"}, + {base - (1ull << 62), NONCE_OUT_OF_WINDOW, "far behind"}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + set_context("purity case: %s", cases[i].what); + CHECK_RES(check(&s, cases[i].counter), cases[i].expected); + } + clear_context(); + + CHECK(std::memcmp(snapshot, &s, sizeof(snapshot)) == 0); + + // The fwd == 0 OK class needs a state where bit 0 is clear; check purity there too. + NonceState fresh; + state_reset(&fresh, base); + unsigned char fresh_snapshot[sizeof(NonceState)]; + std::memcpy(fresh_snapshot, &fresh, sizeof(fresh_snapshot)); + CHECK_RES(check(&fresh, base), NONCE_OK); + CHECK(std::memcmp(fresh_snapshot, &fresh, sizeof(fresh_snapshot)) == 0); + + // Repeated calls on every input class, in bulk, still change nothing. + for (int rep = 0; rep < 4; ++rep) { + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + (void)check(&s, cases[i].counter); + } + } + CHECK(std::memcmp(snapshot, &s, sizeof(snapshot)) == 0); +} + +// --------------------------------------------------------------------------- +// Decision D coverage: shift edges +// +// For each forward delta d, seed a window with a spread of previously consumed +// counters, slide forward by d, and then require: +// - every seeded counter whose new distance (d + i) is < OD_NONCE_BACKWARD_BITS +// is still reported REPLAY (the bit moved with the window, it did not fall +// off early and it did not land on the wrong index), and +// - every seeded counter whose new distance is >= OD_NONCE_BACKWARD_BITS is +// reported OUT_OF_WINDOW, never REPLAY. +// +// d = 0, 1, 63, 64, 65, 127, 128 are reachable through od_nonce_check (capped by +// OD_NONCE_FORWARD_CAP). d = 129, 191, 192, 255, 256, 257 are driven directly +// against od_nonce_commit to cover the word boundaries and the wholesale-clear +// guard, which no reachable input can hit while the cap stays below the width. +// --------------------------------------------------------------------------- + +static const uint64_t kSeedOffsets[] = {0, 1, 2, 63, 64, 65, 127, 128, 129, 191, 192, 254, 255}; +static const size_t kSeedCount = sizeof(kSeedOffsets) / sizeof(kSeedOffsets[0]); + +static void seed_window(NonceState* s, uint64_t base) { + state_reset(s, base); + for (size_t i = 0; i < kSeedCount; ++i) { + commit(s, base - kSeedOffsets[i]); + } + // Sanity: everything seeded reads back as consumed, before any slide. + for (size_t i = 0; i < kSeedCount; ++i) { + CHECK_RES(check(s, base - kSeedOffsets[i]), NONCE_REPLAY); + } + CHECK(s->last_seen == base); +} + +static void run_shift_edge(uint64_t d, bool reachable_through_check) { + const uint64_t base = 1000000u; + NonceState s; + seed_window(&s, base); + + set_context("shift edge d=%" PRIu64, d); + + if (reachable_through_check) { + // d == 0 lands on the fwd == 0 branch, whose bit is already set by the seed. + CHECK_RES(check(&s, base + d), d == 0 ? NONCE_REPLAY : NONCE_OK); + commit(&s, base + d); + } else { + // Beyond the forward cap: od_nonce_check rejects it, so drive commit directly. + CHECK_RES(check(&s, base + d), NONCE_OUT_OF_WINDOW); + commit(&s, base + d); + } + + CHECK(s.last_seen == base + d); + // The newly committed counter is always recorded. + CHECK_RES(check(&s, base + d), NONCE_REPLAY); + + for (size_t i = 0; i < kSeedCount; ++i) { + const uint64_t counter = base - kSeedOffsets[i]; + const uint64_t new_back = d + kSeedOffsets[i]; + const bool wholesale_clear = (d >= OD_NONCE_BACKWARD_BITS); + set_context("shift edge d=%" PRIu64 ", seeded offset %" PRIu64 + " (new back %" PRIu64 ")", + d, kSeedOffsets[i], new_back); + if (new_back < OD_NONCE_BACKWARD_BITS && !wholesale_clear) { + CHECK_RES(check(&s, counter), NONCE_REPLAY); + } else { + CHECK_RES(check(&s, counter), NONCE_OUT_OF_WINDOW); + } + } + + // Counters that were never seeded and are still inside the backward window + // must read as unseen, not as bits smeared by the shift. + for (uint64_t back = 1; back < OD_NONCE_BACKWARD_BITS; ++back) { + const uint64_t counter = (base + d) - back; + // Is this counter one of the seeded ones (or the pre-slide last_seen)? + bool seeded = false; + for (size_t i = 0; i < kSeedCount; ++i) { + if (counter == base - kSeedOffsets[i]) seeded = true; + } + const bool expect_replay = seeded && (d < OD_NONCE_BACKWARD_BITS); + set_context("shift edge d=%" PRIu64 ", back=%" PRIu64, d, back); + CHECK_RES(check(&s, counter), expect_replay ? NONCE_REPLAY : NONCE_OK); + } + + // Exactly at the window edge and beyond it: rejected on width. + set_context("shift edge d=%" PRIu64 ", window edge", d); + CHECK_RES(check(&s, (base + d) - OD_NONCE_BACKWARD_BITS), NONCE_OUT_OF_WINDOW); + CHECK_RES(check(&s, (base + d) - (OD_NONCE_BACKWARD_BITS + 1)), NONCE_OUT_OF_WINDOW); + + clear_context(); +} + +static void test_shift_edges(void) { + const uint64_t reachable[] = {0, 1, 63, 64, 65, 127, 128}; + for (size_t i = 0; i < sizeof(reachable) / sizeof(reachable[0]); ++i) { + run_shift_edge(reachable[i], true); + } + const uint64_t direct[] = {129, 191, 192, 255, 256, 257}; + for (size_t i = 0; i < sizeof(direct) / sizeof(direct[0]); ++i) { + run_shift_edge(direct[i], false); + } +} + +// --------------------------------------------------------------------------- +// Decision D coverage: wholesale slide +// +// A forward jump of at least OD_NONCE_BACKWARD_BITS clears the bitmap outright +// (x << 64 is UB, and shifting a 256-bit map by >= 256 has no surviving bits). +// Everything previously consumed must then come back OUT_OF_WINDOW, NOT REPLAY. +// Both reject, so conflating them is invisible in behaviour — and would hide a +// genuine slide bug where the map was cleared when it should not have been. +// --------------------------------------------------------------------------- + +static void test_wholesale_slide(void) { + const uint64_t base = 1000000u; + const uint64_t jumps[] = {OD_NONCE_BACKWARD_BITS, OD_NONCE_BACKWARD_BITS + 1, 300, 100000, + (1ull << 40)}; + + for (size_t j = 0; j < sizeof(jumps) / sizeof(jumps[0]); ++j) { + const uint64_t d = jumps[j]; + NonceState s; + seed_window(&s, base); + set_context("wholesale slide d=%" PRIu64, d); + + commit(&s, base + d); + CHECK(s.last_seen == base + d); + + // Bitmap holds exactly one bit: the counter just committed. + CHECK(s.bm[0] == 1ull); + for (size_t w = 1; w < OD_NONCE_BITMAP_WORDS; ++w) { + CHECK(s.bm[w] == 0ull); + } + + for (size_t i = 0; i < kSeedCount; ++i) { + CHECK_RES(check(&s, base - kSeedOffsets[i]), NONCE_OUT_OF_WINDOW); + } + // The whole new backward window is unseen except bit 0. + CHECK_RES(check(&s, base + d), NONCE_REPLAY); + for (uint64_t back = 1; back < OD_NONCE_BACKWARD_BITS; ++back) { + CHECK_RES(check(&s, (base + d) - back), NONCE_OK); + } + } + clear_context(); +} + +// --------------------------------------------------------------------------- +// Decision D coverage: bit index correctness after a forward commit +// +// Direct statement of the shift invariant, below the od_nonce_check layer: the +// bit that denoted counter (L - i) under last_seen = L must denote exactly the +// same counter under L' = L + d, i.e. it must sit at index i + d. Bits pushed to +// index >= OD_NONCE_BACKWARD_BITS are gone. +// --------------------------------------------------------------------------- + +static void test_bit_indices_after_shift(void) { + const uint64_t base = 1000000u; + const uint64_t deltas[] = {1, 63, 64, 65, 127}; + + for (size_t k = 0; k < sizeof(deltas) / sizeof(deltas[0]); ++k) { + const uint64_t d = deltas[k]; + NonceState s; + seed_window(&s, base); + + bool before[OD_NONCE_BACKWARD_BITS]; + for (uint64_t i = 0; i < OD_NONCE_BACKWARD_BITS; ++i) { + before[i] = od_nonce_bit_test(s.bm, i); + } + + commit(&s, base + d); + + for (uint64_t i = 0; i < OD_NONCE_BACKWARD_BITS; ++i) { + set_context("bit index d=%" PRIu64 ", old index %" PRIu64, d, i); + if (i + d < OD_NONCE_BACKWARD_BITS) { + // Same counter, new index. + CHECK(od_nonce_bit_test(s.bm, i + d) == before[i]); + } + // The vacated low bits are all clear except bit 0, which the commit set. + if (i < d) { + CHECK(od_nonce_bit_test(s.bm, i) == (i == 0)); + } + } + clear_context(); + } +} + +// --------------------------------------------------------------------------- +// Decision D coverage: counter arithmetic [M1] +// +// Expectations here are derived from the modular definition in the header, not +// from intuition about "before" and "after": +// fwd = counter - last_seen (mod 2^64) +// back = last_seen - counter (mod 2^64) +// and the ordered tests fwd == 0, fwd <= CAP, back < BITS, else OUT_OF_WINDOW. +// UBSan makes the whole file self-checking against the signed formulation, in +// which these same inputs are undefined (int64_t conversion of values >= 2^63, +// signed overflow, negating INT64_MIN). +// --------------------------------------------------------------------------- + +static void test_counter_arithmetic_extremes(void) { + const uint64_t two63 = 1ull << 63; + + // counter = 2^63, last_seen = 1. The input that makes the signed form UB. + // fwd = 2^63 - 1 -> > CAP + // back = 1 - 2^63 = 2^63 + 1 -> >= BITS + // so it is out of the window in both directions, which is the only sane + // answer for a counter half the space away. + { + NonceState s; + state_reset(&s, 1); + CHECK(two63 - 1u > (uint64_t)OD_NONCE_FORWARD_CAP); + CHECK(1u - two63 == two63 + 1u); + CHECK_RES(check(&s, two63), NONCE_OUT_OF_WINDOW); + } + // The mirror image: last_seen = 2^63, counter = 1. + { + NonceState s; + state_reset(&s, two63); + CHECK_RES(check(&s, 1), NONCE_OUT_OF_WINDOW); + } + // And 2^63 apart in the other direction, from a high last_seen. + { + NonceState s; + state_reset(&s, UINT64_MAX); + CHECK_RES(check(&s, UINT64_MAX + two63), NONCE_OUT_OF_WINDOW); + CHECK_RES(check(&s, UINT64_MAX - two63), NONCE_OUT_OF_WINDOW); + } + + // Near UINT64_MAX, including the wrap past it. last_seen = UINT64_MAX - 2. + { + const uint64_t L = UINT64_MAX - 2u; + NonceState s; + state_reset(&s, L); + + CHECK_RES(check(&s, L), NONCE_OK); // fwd == 0, bit 0 clear + commit(&s, L); + CHECK_RES(check(&s, L), NONCE_REPLAY); + + // Forward, no wrap yet. + CHECK_RES(check(&s, UINT64_MAX - 1u), NONCE_OK); // fwd == 1 + CHECK_RES(check(&s, UINT64_MAX), NONCE_OK); // fwd == 2 + // Forward, wrapping past UINT64_MAX. fwd = 0 - (2^64 - 3) = 3. + CHECK(0u - L == 3u); + CHECK_RES(check(&s, 0), NONCE_OK); + CHECK(1u - L == 4u); + CHECK_RES(check(&s, 1), NONCE_OK); + // Still forward at the cap, wrapped. + CHECK_RES(check(&s, L + (uint64_t)OD_NONCE_FORWARD_CAP), NONCE_OK); + CHECK_RES(check(&s, L + (uint64_t)OD_NONCE_FORWARD_CAP + 1u), NONCE_OUT_OF_WINDOW); + // Backward across the low end of the space: L - 1 == UINT64_MAX - 3. + CHECK_RES(check(&s, L - 1u), NONCE_OK); + CHECK_RES(check(&s, L - (uint64_t)(OD_NONCE_BACKWARD_BITS - 1)), NONCE_OK); + CHECK_RES(check(&s, L - (uint64_t)OD_NONCE_BACKWARD_BITS), NONCE_OUT_OF_WINDOW); + + // Accept a wrapped counter and slide the window across the wrap point. + CHECK_RES(check_and_commit(&s, 1), NONCE_OK); + CHECK(s.last_seen == 1u); + + // The pre-wrap counter L is now back = 1 - L = 4 behind, and is recorded. + CHECK(1u - L == 4u); + CHECK_RES(check(&s, L), NONCE_REPLAY); + // Its never-committed neighbours across the wrap are unseen. + CHECK_RES(check(&s, UINT64_MAX - 1u), NONCE_OK); // back == 3 + CHECK_RES(check(&s, UINT64_MAX), NONCE_OK); // back == 2 + CHECK_RES(check(&s, 0), NONCE_OK); // back == 1 + CHECK_RES(check(&s, 1), NONCE_REPLAY); // fwd == 0, committed + + // The backward window reaches BITS-1 below zero and stops there. + CHECK_RES(check(&s, 1u - (uint64_t)(OD_NONCE_BACKWARD_BITS - 1)), NONCE_OK); + CHECK_RES(check(&s, 1u - (uint64_t)OD_NONCE_BACKWARD_BITS), NONCE_OUT_OF_WINDOW); + + // Commit a backward, wrapped counter and read it back. + CHECK_RES(check_and_commit(&s, UINT64_MAX), NONCE_OK); + CHECK(s.last_seen == 1u); // backward commits do not move last_seen + CHECK_RES(check(&s, UINT64_MAX), NONCE_REPLAY); + CHECK_RES(check(&s, UINT64_MAX - 1u), NONCE_OK); + } +} + +// --------------------------------------------------------------------------- +// Decision D coverage: differential / property test against a naive oracle +// +// The oracle is the obvious-but-unshippable implementation: the exact set of +// counters consumed, plus last_seen. It reproduces od_nonce_check's decision +// directly from the definition, and prunes counters that have fallen out of the +// backward window when last_seen advances — that pruning is what makes it agree +// on REPLAY versus OUT_OF_WINDOW rather than only on accept versus reject. +// +// Counters stay far from 0 and from 2^64 so the oracle needs no wrap handling; +// the wrap cases are covered exhaustively by test_counter_arithmetic_extremes(). +// --------------------------------------------------------------------------- + +struct Oracle { + std::set seen; + uint64_t last_seen; +}; + +static NonceResult oracle_check(const Oracle& o, uint64_t counter) { + const uint64_t fwd = counter - o.last_seen; + const uint64_t back = o.last_seen - counter; + if (fwd == 0u) return o.seen.count(counter) ? NONCE_REPLAY : NONCE_OK; + if (fwd <= (uint64_t)OD_NONCE_FORWARD_CAP) return NONCE_OK; + if (back < (uint64_t)OD_NONCE_BACKWARD_BITS) { + return o.seen.count(counter) ? NONCE_REPLAY : NONCE_OK; + } + return NONCE_OUT_OF_WINDOW; +} + +static void oracle_commit(Oracle* o, uint64_t counter) { + const uint64_t fwd = counter - o->last_seen; + const uint64_t back = o->last_seen - counter; + if (fwd == 0u) { + o->seen.insert(counter); + return; + } + if (back < (uint64_t)OD_NONCE_BACKWARD_BITS) { + o->seen.insert(counter); + return; + } + // Forward: the window slides, and everything now at or past the far edge is + // forgotten — exactly the bits the shift pushes off the end of the bitmap. + o->last_seen = counter; + o->seen.insert(counter); + while (!o->seen.empty() && + (o->last_seen - *o->seen.begin()) >= (uint64_t)OD_NONCE_BACKWARD_BITS) { + o->seen.erase(o->seen.begin()); + } +} + +static void test_differential_against_oracle(void) { + std::mt19937_64 rng(0xD15EA5EULL); // fixed seed: this test must be reproducible + + const int kSequences = 40; + const int kStepsPerSequence = 200; + + for (int seq = 0; seq < kSequences; ++seq) { + const uint64_t base = (1ull << 40) + (uint64_t)seq * 4096u; + + NonceState s; + state_reset(&s, base); + Oracle o; + o.last_seen = base; + + for (int step = 0; step < kStepsPerSequence; ++step) { + const unsigned bucket = (unsigned)(rng() % 100u); + uint64_t counter; + if (bucket < 40u) { + // Forward inside the cap, including fwd == 0 (a replay probe). + counter = s.last_seen + (rng() % (uint64_t)(OD_NONCE_FORWARD_CAP + 1)); + } else if (bucket < 60u) { + // Forward past the cap: a gap too large to accept. + counter = s.last_seen + (uint64_t)OD_NONCE_FORWARD_CAP + 1u + + (rng() % 1000u); + } else if (bucket < 90u) { + // Backward inside or just outside the window. + counter = s.last_seen - (1u + rng() % (uint64_t)(OD_NONCE_BACKWARD_BITS + 64)); + } else { + // Far behind. + counter = s.last_seen - (uint64_t)OD_NONCE_BACKWARD_BITS - + (rng() % 100000u); + } + + set_context("differential seq=%d step=%d counter=%" PRIu64 + " last_seen=%" PRIu64, + seq, step, counter, s.last_seen); + + const NonceResult got = check(&s, counter); + const NonceResult want = oracle_check(o, counter); + CHECK_RES(got, want); + CHECK(o.last_seen == s.last_seen); + + if (got == NONCE_OK) { + commit(&s, counter); + oracle_commit(&o, counter); + CHECK(o.last_seen == s.last_seen); + } + } + + // End of sequence: sweep the entire backward window and compare, so that + // any bit the two models disagree about is caught even if the random + // walk never probed it. + for (uint64_t back = 0; back < (uint64_t)OD_NONCE_BACKWARD_BITS + 8u; ++back) { + const uint64_t counter = s.last_seen - back; + set_context("differential sweep seq=%d back=%" PRIu64, seq, back); + CHECK_RES(check(&s, counter), oracle_check(o, counter)); + } + } + clear_context(); +} + +// --------------------------------------------------------------------------- + +int main(void) { + test_fresh_session(); + test_d3_same_counter_replay(); + test_check_is_pure(); + test_shift_edges(); + test_wholesale_slide(); + test_bit_indices_after_shift(); + test_counter_arithmetic_extremes(); + test_differential_against_oracle(); + + if (g_failures != 0u) { + std::printf("FAILED %lu of %lu checks\n", g_failures, g_checks); + return EXIT_FAILURE; + } + std::printf("PASSED %lu checks\n", g_checks); + return EXIT_SUCCESS; +} From 4ac65006be9c95fb49e69e2c4ae4d6e3ab2ea555 Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:17:25 -0400 Subject: [PATCH 06/11] fix(nonce): review follow-ups - split log budgets, readable replay log, honest comments Findings from an independent adversarial review of the Phase 1 diff against docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. No behaviour change to the window state machine; all 38199 host-test checks and all 11 firmware envs still pass. 1. One rate-limit budget PER LOG SITE, not one shared. A stale client spamming session-id mismatches could otherwise silence the out-of-window line for 5 s at a time - and out-of-window is precisely the condition Step 5's hardware tests 0-2 exist to observe. The shared budget would have masked the measurement the plan depends on. 2. The rejection log now prints `back` for a replay and `fwd` for out-of-window. A replayed counter is normally BEHIND last_seen, where the (correctly) wrapping fwd delta printed as a 20-digit number - unreadable in the case the line fires in most. 3. Decision C's "linkage enforces that only decryptCommand may commit state" is softened to what is actually true. nonceCheck/nonceCommit are file-static, so it holds for them - but encryption_state.h must include nonce_window.h for OD_NONCE_BITMAP_WORDS and main.h includes encryption_state.h, so the raw od_nonce_commit() primitive is visible in every TU next to the extern encryptionSession. Unavoidable while the struct needs the width macro; recorded rather than left as a claim the code does not support. 4. od_nonce_commit's totality comment now states its sharp edge instead of implying it away: a counter more than OD_NONCE_BACKWARD_BITS *behind* last_seen also lands in the forward branch (fwd and back are complements), so it clears the bitmap and REWINDS last_seen, un-seeing everything. Unreachable through od_nonce_check - which returns OUT_OF_WINDOW so it is never committed - but it is the edge to watch if a caller ever commits without checking. 5. nonce_window.h includes , so its "zero dependencies" claim also holds for a C translation unit. --- src/encryption.cpp | 42 ++++++++++++++++++++++++++++++++---------- src/nonce_window.h | 23 +++++++++++++++++------ 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/encryption.cpp b/src/encryption.cpp index 1ebbae8..1f2b197 100644 --- a/src/encryption.cpp +++ b/src/encryption.cpp @@ -127,14 +127,19 @@ static void resetNonceState(void) { memset(encryptionSession.replay_bitmap, 0, sizeof(encryptionSession.replay_bitmap)); } -// Rate limiter for the two nonce-rejection logs. Once nonce failures stop +// Rate limiters for the two nonce-rejection logs. Once nonce failures stop // counting toward integrity_failures ([L7]) nothing else throttles a peer that // drives these lines, and out-of-window now fires routinely on a lossy link. -static uint32_t nonce_log_last_ms = 0; -static bool nonceLogAllowed(void) { +// +// One budget PER SITE, deliberately, not one shared budget: a stale client +// spamming session-id mismatches must not be able to silence the out-of-window +// line, which is the condition Step 5's hardware tests 0-2 exist to observe. +static uint32_t nonce_log_badsession_ms = 0; +static uint32_t nonce_log_window_ms = 0; +static bool nonceLogAllowed(uint32_t* last_ms) { uint32_t now = millis(); - if (nonce_log_last_ms != 0 && (uint32_t)(now - nonce_log_last_ms) < 5000u) return false; - nonce_log_last_ms = now; + if (*last_ms != 0 && (uint32_t)(now - *last_ms) < 5000u) return false; + *last_ms = now; return true; } @@ -145,6 +150,15 @@ static bool nonceLogAllowed(void) { // // File-static by design (Decision C): nothing outside decryptCommand should be // able to reach the commit side. The pure logic lives in src/nonce_window.h. +// +// Caveat on how strong that is. Decision C says linkage enforces the rule, and +// for nonceCheck/nonceCommit it does. But encryption_state.h must include +// nonce_window.h for OD_NONCE_BITMAP_WORDS, and main.h includes +// encryption_state.h — so od_nonce_commit(), a static-inline header primitive, +// is visible in every TU alongside the extern encryptionSession. Nothing stops +// a determined caller from committing state directly. That is unavoidable while +// the struct needs the width macro; the rule is enforced by linkage for the +// session-aware wrappers and by convention for the raw primitive. static NonceResult nonceCheck(const uint8_t* nonce, uint64_t* counter_out) { uint8_t nonce_session_id[8]; uint64_t nonce_counter = 0; @@ -159,7 +173,7 @@ static NonceResult nonceCheck(const uint8_t* nonce, uint64_t* counter_out) { // is gone: a mismatched session id is usually a stale client talking to // a device that re-authenticated, i.e. confusion rather than attack. // The CCM tag remains the only tamper oracle. - if (nonceLogAllowed()) { + if (nonceLogAllowed(&nonce_log_badsession_ms)) { od_log_warn("Nonce session_id mismatch (%02X%02X.. vs %02X%02X..) - frame dropped, session kept", nonce_session_id[0], nonce_session_id[1], encryptionSession.session_id[0], encryptionSession.session_id[1]); @@ -728,12 +742,20 @@ bool decryptCommand(uint8_t* ciphertext, uint16_t ciphertext_len, uint8_t* plain NonceResult nr = nonceCheck(nonce_full, &nonce_counter); if (nr != NONCE_OK) { if (reason_out != nullptr) *reason_out = nr; - if (nr != NONCE_BAD_SESSION && nonceLogAllowed()) { - od_log_warn("Nonce %s (counter=%llu last_seen=%llu fwd=%llu) - frame dropped, session kept", - (nr == NONCE_REPLAY) ? "replay" : "out-of-window", + if (nr != NONCE_BAD_SESSION && nonceLogAllowed(&nonce_log_window_ms)) { + // A replayed counter is normally BEHIND last_seen, where the wrapping + // fwd delta prints as a 20-digit number. Report the distance in the + // direction that is actually small, so the line is readable in the + // case it fires in. + const bool behind = (nr == NONCE_REPLAY); + od_log_warn("Nonce %s (counter=%llu last_seen=%llu %s=%llu) - frame dropped, session kept", + behind ? "replay" : "out-of-window", (unsigned long long)nonce_counter, (unsigned long long)encryptionSession.last_seen_counter, - (unsigned long long)(nonce_counter - encryptionSession.last_seen_counter)); + behind ? "back" : "fwd", + (unsigned long long)(behind + ? (encryptionSession.last_seen_counter - nonce_counter) + : (nonce_counter - encryptionSession.last_seen_counter))); } return false; } diff --git a/src/nonce_window.h b/src/nonce_window.h index d08b524..4442edc 100644 --- a/src/nonce_window.h +++ b/src/nonce_window.h @@ -22,6 +22,7 @@ // fresh session (last_seen = 0, all-zero bitmap) accepts counter 0 exactly once // with no has_seen_counter flag. +#include #include #include @@ -106,12 +107,22 @@ static inline void od_nonce_bitmap_shift_left(uint64_t* bm, uint64_t shift) { // Records `counter` as consumed. MUST only be called after the frame carrying // it has been authenticated (CCM tag verified) — that is the D2 fix. // -// Total for every input, including inputs od_nonce_check() would have rejected: -// the host test calls it directly, and a future OD_NONCE_FORWARD_CAP increase -// must not be able to produce an over-wide shift. The wholesale-clear path is -// unreachable through od_nonce_check() today (cap 128 < 256 bits) but is still -// correct when it fires: every counter it discards is then >= 256 behind and is -// rejected on width, never mis-reported as unseen. +// Defined (never UB) for every input, including inputs od_nonce_check() would +// have rejected: the host test calls it directly, and a future +// OD_NONCE_FORWARD_CAP increase must not be able to produce an over-wide shift. +// The wholesale-clear path is unreachable through od_nonce_check() today (cap +// 128 < 256 bits) but is still correct when it fires on a FORWARD jump: every +// counter it discards is then >= 256 behind and is rejected on width, never +// mis-reported as unseen. +// +// Sharp edge, stated rather than hidden: a counter more than +// OD_NONCE_BACKWARD_BITS *behind* last_seen also lands in the forward branch +// (fwd and back are complements, so its fwd is enormous), clearing the bitmap +// and RE-WINDING last_seen to that counter — un-seeing everything. That is +// unreachable through od_nonce_check(), which returns NONCE_OUT_OF_WINDOW for +// such a counter so it is never committed, and the contract above is that +// commit runs only for frames that both checked OK and authenticated. It is the +// edge to watch if a future caller ever commits without checking first. static inline void od_nonce_commit(uint64_t* bm, uint64_t* last_seen, uint64_t counter) { const uint64_t fwd = counter - *last_seen; const uint64_t back = *last_seen - counter; From 1e616e40b89b571a786ed5e54d064454ce2ca745 Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:51:01 -0400 Subject: [PATCH 07/11] docs(pipe): a nonce gap beyond the forward cap is not recoverable Two comments claimed the silent drop was repaired by the normal SACK path. That holds for a rejection inside the window; it is false past OD_NONCE_FORWARD_CAP. Once a frame is rejected for fwd > cap, nothing commits, so last_seen never advances. Every retransmission re-encrypts with a fresh, higher counter (py-opendisplay _write_pipe_frame never resends the original ciphertext), so each one is rejected at a greater distance than the last. The session cannot recover without re-authenticating, and the transfer stalls until the stuck-transfer timeout releases the panel. The cap's job is to put that state out of reach, not to make it recoverable. Dropping silently still beats a 0x81 NACK, which is unconditionally fatal and kills the upload immediately -- but it does not rescue the transfer, and the comments should not have implied it did. Comments only; no behaviour change. nrf52840custom builds. --- src/communication.cpp | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/communication.cpp b/src/communication.cpp index 7ca4123..0c563cb 100644 --- a/src/communication.cpp +++ b/src/communication.cpp @@ -771,11 +771,13 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin // §5.2 already reserves NACKs for unrecoverable conditions, "not // ordinary packet loss", and §5.1 makes a 0x81 NACK unconditionally // fatal, so answering one here violates the spec as written. Send - // NOTHING: silence is a first-class signal on the pipe path. The seq - // is absent from the next SACK mask, the client retransmits it, and - // the transfer continues. Answering with the 3-byte NACK instead - // makes the client raise IntegrityCheckError, which its pipe send - // loop does not catch, killing the whole upload on the first + // NOTHING: silence is a first-class signal on the pipe path. For a + // rejection INSIDE the window the seq is absent from the next SACK + // mask, the client retransmits it, and the transfer continues. A gap + // beyond OD_NONCE_FORWARD_CAP is NOT repaired this way -- see the + // CMD_PIPE_WRITE_DATA note below. Answering with the 3-byte NACK + // instead makes the client raise IntegrityCheckError, which its pipe + // send loop does not catch, killing the whole upload on the first // rejected frame. // // Deliberately narrow: @@ -884,10 +886,18 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin // OD_NONCE_FORWARD_CAP (src/nonce_window.h) is therefore a HEURISTIC // with headroom over the realistic worst case, not an invariant this // firmware can prove; a client-side blocks_per_ack change would - // silently falsify any specific number written here. A gap beyond - // the cap is no longer fatal: the frame is dropped silently (Step 4b - // above) and repaired by the normal SACK path. See Decision A / [C1] - // in docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. + // silently falsify any specific number written here. The cap's job is + // to put overflow out of REACH, not to make it RECOVERABLE: once a + // frame is rejected for fwd > cap nothing commits, so last_seen never + // advances, and every retransmission -- which re-encrypts with a + // fresh, HIGHER counter (py-opendisplay _write_pipe_frame), never + // resending the original ciphertext -- is rejected at a greater + // distance still. The session cannot recover without + // re-authenticating. Dropping silently (Step 4b above) only avoids the + // immediate fatal teardown a 0x81 NACK would cause; the transfer then + // stalls until the stuck-transfer timeout (pipe-write-protocol.md + // §5.3) releases the panel. See Decision A / [C1] in + // docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. handlePipeWriteData(data + 2, len - 2); break; case CMD_PIPE_WRITE_END: // 0x0082 From b9a5f851015a04247d202c28ac504c80818b283a Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:23:09 -0400 Subject: [PATCH 08/11] fix(nonce): remove the forward cap; order counters numerically OD_NONCE_FORWARD_CAP turned a transient link fault into a permanent session fault. A counter more than 128 ahead was rejected, and because nothing commits until the CCM tag verifies, last_seen never advanced. The client re-encrypts every retransmission with a fresh, higher counter and never resends the original ciphertext, so each subsequent frame was rejected at a greater distance than the last. The session could not recover without re-authenticating; the transfer stalled until the 15-minute watchdog released the panel. It is reachable with supported settings. With W=32 and blocks_per_ack=1, 16 queued gap-ACKs at PIPE_RETX_ACK_SPACING=2 burn 128 counters on repairs alone, and the gap accumulates across aborted attempts because the client deliberately does not re-authenticate mid-transfer. max_retx = max(3*W, n/2) is order thousands for a full-panel upload, so the budget is nowhere near spent when the cap is crossed. The cap bought nothing. All 8 counter bytes sit inside the CCM nonce, so a tampered counter fails the tag; commit 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, with or without a cap -- and the session id is cleartext in every frame, so anyone who could flood CCM with a capped window could flood it without one. So: no forward bound. A counter ahead of last_seen is accepted at any distance and gated by the tag, exactly as RFC 4303 Appendix A2 does. Comparison also moves from modular to numeric. Modular arithmetic made a counter far behind indistinguishable from one far ahead, which is what let an ancient counter present as an enormous forward jump -- the "sharp edge" the 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. For the same reason, do not reintroduce a bound as cap=UINT64_MAX or as "not-backward implies forward": either restores the overlap. Consequences: - Counters no longer wrap. Reaching UINT64_MAX requires re-authentication, per RFC 4303 3.3.3; wrapping would reuse a (key, nonce) pair, which is the exact failure this file prevents. 2^64 counters in one session is unreachable. - NONCE_OUT_OF_WINDOW now means only "too far behind", so the rejection log computes direction from the counters instead of inferring it from the reason, which would have printed an underflowed 20-digit distance. - OD_NONCE_BACKWARD_BITS keeps its value but not its old justification, which was stated in terms of the 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. Tests: the oracle no longer prunes its seen set -- that pruning encoded the implementation's forgetting, so it could only ever agree with it. It now keeps every counter ever committed and states the property directly: a consumed counter is never returned NONCE_OK, at any distance, by any route. Added the cliff as a sequence (not a point, which cap=UINT64_MAX would also pass), the escalating-retransmit pattern, and far-behind-is-never-forward, which pins both forbidden shortcuts. Shift edges now run through od_nonce_check, so the wholesale-clear path is exercised the way production reaches it. Verified: 47445 checks pass under -Werror with ASan+UBSan; the same suite fails 1635 checks against the pre-change implementation. nrf52840custom and esp32-c3-N16 build. No wire change: nonce format, response codes and framing are untouched, and the accept set only grows, so no peer needs updating in lockstep. --- .github/workflows/main.yaml | 3 +- src/communication.cpp | 62 ++++--- src/encryption.cpp | 16 +- src/encryption_state.h | 5 +- src/nonce_window.h | 171 +++++++++++------ tools/test_nonce_window.cpp | 361 +++++++++++++++++++++++------------- 6 files changed, 395 insertions(+), 223 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 1376122..ab2d211 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -21,8 +21,7 @@ jobs: - name: Build and run the nonce-window host test # -fsanitize=undefined is not decoration: it is what catches the `x << 64` # UB in the bitmap shift automatically rather than relying on the test - # author to predict it. See Decision D in - # docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. + # author to predict it. run: | g++ -std=c++17 -Wall -Wextra -Werror -O1 -fsanitize=undefined,address \ tools/test_nonce_window.cpp -o /tmp/test_nonce_window diff --git a/src/communication.cpp b/src/communication.cpp index 0c563cb..50aff87 100644 --- a/src/communication.cpp +++ b/src/communication.cpp @@ -771,14 +771,18 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin // §5.2 already reserves NACKs for unrecoverable conditions, "not // ordinary packet loss", and §5.1 makes a 0x81 NACK unconditionally // fatal, so answering one here violates the spec as written. Send - // NOTHING: silence is a first-class signal on the pipe path. For a - // rejection INSIDE the window the seq is absent from the next SACK - // mask, the client retransmits it, and the transfer continues. A gap - // beyond OD_NONCE_FORWARD_CAP is NOT repaired this way -- see the - // CMD_PIPE_WRITE_DATA note below. Answering with the 3-byte NACK - // instead makes the client raise IntegrityCheckError, which its pipe - // send loop does not catch, killing the whole upload on the first - // rejected frame. + // NOTHING: silence is a first-class signal on the pipe path. The seq + // is absent from the next SACK mask, the client retransmits it under a + // FRESH, higher counter, and that counter is accepted unconditionally + // (nonce_window.h has no forward bound), so the transfer continues. + // Answering with the 3-byte NACK instead makes the client raise + // IntegrityCheckError, which its pipe send loop does not catch, + // killing the whole upload on the first rejected frame. + // + // Rare by construction now: the only surviving nonce rejections are a + // duplicate delivery and a counter more than OD_NONCE_BACKWARD_BITS + // behind, neither of which this transport produces. It stays because + // being wrong here costs the whole upload. // // Deliberately narrow: // - TAG failures keep the NACK. They are tamper evidence, not loss. @@ -876,28 +880,26 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin // that authenticates — including ones this handler then queues or // discards — so drops/dupes never desync it. // - // What bounds the forward gap is NOT a firmware constant. The client - // burns a nonce counter per *transmission*, landed or not, and its - // three transmit sites are new sends (window-credit-limited), PTO - // probes, and selective repair — and selective repair spends no - // window credit at all. The only ceiling is the client's retransmit - // budget max_retx = max(3*W, n/2), scaled by blocks_per_ack, which - // is a user-facing Home Assistant option (1..32) in another repo. - // OD_NONCE_FORWARD_CAP (src/nonce_window.h) is therefore a HEURISTIC - // with headroom over the realistic worst case, not an invariant this - // firmware can prove; a client-side blocks_per_ack change would - // silently falsify any specific number written here. The cap's job is - // to put overflow out of REACH, not to make it RECOVERABLE: once a - // frame is rejected for fwd > cap nothing commits, so last_seen never - // advances, and every retransmission -- which re-encrypts with a - // fresh, HIGHER counter (py-opendisplay _write_pipe_frame), never - // resending the original ciphertext -- is rejected at a greater - // distance still. The session cannot recover without - // re-authenticating. Dropping silently (Step 4b above) only avoids the - // immediate fatal teardown a 0x81 NACK would cause; the transfer then - // stalls until the stuck-transfer timeout (pipe-write-protocol.md - // §5.3) releases the panel. See Decision A / [C1] in - // docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. + // The forward gap is deliberately UNBOUNDED, and it has to be. The + // client burns a nonce counter per *transmission*, landed or not, and + // its three transmit sites are new sends (window-credit-limited), PTO + // probes, and selective repair — and selective repair spends no window + // credit at all. The ceiling is the client's retransmit budget + // max_retx = max(3*W, n/2), scaled by blocks_per_ack, a user-facing + // Home Assistant option (1..32) in another repo: order thousands for a + // full-panel upload, and it accumulates ACROSS aborted attempts + // because the client never re-authenticates mid-transfer. Any firmware + // constant placed here would be a number this repo cannot prove and a + // client-side setting could silently falsify. + // + // So there is no such constant. A counter ahead of last_seen is + // accepted at any distance and gated by the CCM tag instead + // (src/nonce_window.h). A forward cap would not bound an attacker — + // checking commits nothing — but it would strand the session, because + // once a gap exceeded it nothing would commit, last_seen would never + // advance, and each retransmission's still-higher counter would be + // rejected further out than the last, until re-authentication. That is + // a transient link fault promoted to a permanent session fault. handlePipeWriteData(data + 2, len - 2); break; case CMD_PIPE_WRITE_END: // 0x0082 diff --git a/src/encryption.cpp b/src/encryption.cpp index 1f2b197..63f7372 100644 --- a/src/encryption.cpp +++ b/src/encryption.cpp @@ -119,7 +119,7 @@ void deriveSessionId(const uint8_t* session_key, const uint8_t* client_nonce, // populated). In particular nonce_counter — the device's OWN outbound counter — // must keep being zeroed here: a device that carried it across a re-auth while // the client restarts at 0 would reproduce the [H2] keystream reuse against -// itself. See docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md Step 1. +// itself. static void resetNonceState(void) { encryptionSession.nonce_counter = 0; /* device's OWN outbound counter */ encryptionSession.last_seen_counter = 0; @@ -743,11 +743,15 @@ bool decryptCommand(uint8_t* ciphertext, uint16_t ciphertext_len, uint8_t* plain if (nr != NONCE_OK) { if (reason_out != nullptr) *reason_out = nr; if (nr != NONCE_BAD_SESSION && nonceLogAllowed(&nonce_log_window_ms)) { - // A replayed counter is normally BEHIND last_seen, where the wrapping - // fwd delta prints as a 20-digit number. Report the distance in the - // direction that is actually small, so the line is readable in the - // case it fires in. - const bool behind = (nr == NONCE_REPLAY); + // Direction comes from the COUNTERS, not from `nr`. Under numeric + // ordering every rejection is at or behind last_seen: anything ahead + // is accepted at any distance, so NONCE_OUT_OF_WINDOW now means only + // "more than OD_NONCE_BACKWARD_BITS behind". Inferring the direction + // from the reason would pick the wrong subtraction, underflow, and + // print a 20-digit number -- the exact unreadability this guards + // against. Computed rather than assumed so the line survives a future + // rule change. + const bool behind = (nonce_counter <= encryptionSession.last_seen_counter); od_log_warn("Nonce %s (counter=%llu last_seen=%llu %s=%llu) - frame dropped, session kept", behind ? "replay" : "out-of-window", (unsigned long long)nonce_counter, diff --git a/src/encryption_state.h b/src/encryption_state.h index 3c972ca..4bbfa23 100644 --- a/src/encryption_state.h +++ b/src/encryption_state.h @@ -23,8 +23,9 @@ struct EncryptionSession { // Bit 0 is last_seen_counter itself. The backward window is implicitly // OD_NONCE_BACKWARD_BITS - 1; there is no separate window constant to keep // in step, and no insertion index to reset. Replaces a uint64_t[64] value - // ring (512 B -> 32 B). See src/nonce_window.h and - // docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md Step 1 / Decision B. + // ring (512 B -> 32 B). last_seen_counter only ever moves UP; see + // src/nonce_window.h for the decision rule and why that is the whole + // anti-replay argument. uint64_t replay_bitmap[OD_NONCE_BITMAP_WORDS]; uint32_t last_activity; uint8_t integrity_failures; diff --git a/src/nonce_window.h b/src/nonce_window.h index 4442edc..157f3ff 100644 --- a/src/nonce_window.h +++ b/src/nonce_window.h @@ -7,11 +7,10 @@ // firmware logging layer, and it touches no global state. Everything here // operates on plain values passed in by the caller, so the whole state machine // can be compiled and exercised on a host under UBSan/ASan by -// tools/test_nonce_window.cpp. See docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md -// Decision D. +// tools/test_nonce_window.cpp. // -// Representation (RFC 4303 / RFC 6347 "shifting" style, as opposed to the -// circular RFC 6479 / WireGuard style — see Decision B): +// Representation ("shifting" style, as opposed to the circular RFC 6479 / +// WireGuard style): // // bit i of the bitmap == "counter (last_seen - i) has been consumed". // bit 0 is last_seen itself. @@ -21,24 +20,58 @@ // reset. "Not seen" is a clear bit rather than a reserved sentinel value, so a // fresh session (last_seen = 0, all-zero bitmap) accepts counter 0 exactly once // with no has_seen_counter flag. +// +// --- relationship to RFC 4303 ----------------------------------------------- +// +// The decision rule is RFC 4303 Appendix A2's anti-replay window, and the three +// cases map one-to-one: right of the window -> authenticate, then slide (clearing +// wholesale when the jump exceeds the width); inside the window -> consult the +// bitmap; left of the window -> discard. So does the ordering that makes it safe, +// "if the MAC is valid, the window is updated" — here od_nonce_check() decides, +// CCM verifies, and only then does od_nonce_commit() run. +// +// Three deliberate departures, none of them semantic: +// +// - Counters are 0-based. RFC 4303 §3.3.3 starts sequence numbers at 1; this +// protocol's client sends counter 0 as its first command, so a 1-based rule +// would reject every client's first frame. +// - The full 64-bit counter is on the wire, so there is no ESN high-order-bit +// inference to do. That machinery would be complexity with no function. +// - The window is 256 bits rather than the RFC's 32 minimum / 64 default, +// which §3.4.3 explicitly permits. +// +// Following §3.3.3, the counter does NOT wrap: it is treated as a plain +// monotonically increasing uint64_t, and a session that reached UINT64_MAX would +// have to re-authenticate rather than cycle. Wrapping would reuse a (key, nonce) +// pair, which is the precise CCM failure this file exists to prevent. Exhausting +// 2^64 counters inside one session is not reachable in practice. #include #include #include -// Width of the backward (out-of-order tolerance) window, in bits. -// uint64_t[4] = 32 B. Kept strictly greater than OD_NONCE_FORWARD_CAP so that a -// legal forward slide can never exceed the bitmap width — that keeps the -// wholesale-clear branch in od_nonce_commit() off the normal path. +// Width of the backward (out-of-order / duplicate arrival) window, in bits. +// uint64_t[4] = 32 B. +// +// This width is ONLY reordering tolerance. Replay protection does not depend on +// it: a counter at or below last_seen is either caught by the bitmap (within the +// width) or rejected on width, and only counters above last_seen are ever +// accepted — and those, by the monotonicity argument on od_nonce_check(), were +// never consumed. Narrowing or widening it cannot create a replay hole. +// +// Nor is it a correctness cliff, because a backward rejection is self-healing: +// the client re-encrypts every retransmission with a fresh, HIGHER counter (it +// never resends the original ciphertext), and a higher counter is accepted +// unconditionally. A frame rejected here is re-sent under a counter that is not. +// +// So the exact value is not load-bearing, and there is deliberately no attempt to +// derive it from the client's pipe window, blocks_per_ack or retransmit budget — +// none of those bear on it. 256 is a generous margin over the zero reordering the +// transport actually produces (the client assigns counters synchronously +// immediately before each write, ATT preserves order, and both targets consume +// their RX rings FIFO), at a cost of 32 bytes per session. #define OD_NONCE_BACKWARD_BITS 256 -// Largest forward jump that is accepted. This is a HEURISTIC, not an invariant -// firmware can prove: the real bound lives in the client's retransmit budget -// (max_retx = max(3*W, n/2)) and its blocks_per_ack setting, both of which live -// in another repo and one of which is a user-facing Home Assistant option. See -// Decision A in docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. -#define OD_NONCE_FORWARD_CAP 128 - #define OD_NONCE_BITMAP_WORDS (OD_NONCE_BACKWARD_BITS / 64) enum NonceResult { @@ -58,25 +91,46 @@ static inline void od_nonce_bit_set(uint64_t* bm, uint64_t bit) { // Pure: decides whether `counter` may be accepted. Writes nothing. // -// 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 require fwd + back <= 384 == 0 (mod 2^64), -// true only when both are zero — the case the first test already consumed. A -// counter far from the window in either direction leaves both huge and falls -// through to NONCE_OUT_OF_WINDOW. Do not reorder. -// -// Unsigned wrapping arithmetic only: the counter is parsed off the wire BEFORE -// the CCM tag is verified, so an unauthenticated attacker controls both -// operands. Converting a uint64_t >= 2^63 to int64_t is implementation-defined -// before C++20, the signed subtraction can overflow, and negating INT64_MIN is -// UB — three ways to be undefined on attacker-chosen input. Unsigned overflow -// is defined as modular arithmetic, making this total over all 2^64 inputs. +// There is NO forward bound, and its absence is the point. A counter above +// last_seen is accepted at any distance, exactly as RFC 4303 does, because the +// sender may legitimately have burned counters this receiver never saw — every +// PIPE retransmission spends a fresh one. A forward cap here would not bound an +// attacker (see the commit contract below: passing this check mutates nothing, +// and only a verified CCM tag commits), but it WOULD strand the session: once a +// gap exceeded the cap, nothing would commit, last_seen would never advance, and +// every subsequent frame — each carrying a still higher counter — would be +// rejected further out than the last, until re-authentication. +// +// The security invariant is "a consumed counter is never returned NONCE_OK +// again", and it rests on last_seen being monotonically non-decreasing: only the +// forward branch of od_nonce_commit() assigns it, and only upward. Every consumed +// counter is therefore <= last_seen forever, so the `counter > last_seen` arm +// cannot re-accept one. Below that, a consumed counter is bitmap-caught while it +// is represented and rejected on width once it is not — the region below the +// window is closed, never waved through. +// +// Comparison is plain numeric, NOT modular. Modular arithmetic makes a counter +// far behind indistinguishable from one far ahead (the two differences are +// complements mod 2^64), which is what allowed an ancient counter to present as +// an enormous forward jump. Numeric ordering removes that overlap structurally +// rather than relying on the caller to check before committing. For the same +// reason, do not reintroduce a bound by setting a cap to UINT64_MAX or by +// treating "not within the backward window" as forward — either restores the +// overlap this ordering exists to eliminate. +// +// Total over all 2^64 inputs and never UB: the counter is parsed off the wire +// BEFORE the CCM tag is verified, so an unauthenticated attacker controls both +// operands. Only unsigned comparison and one unsigned subtraction are used, and +// that subtraction is evaluated solely on the branch where counter < last_seen, +// so it cannot wrap. Converting a uint64_t >= 2^63 to int64_t is +// implementation-defined before C++20, the signed subtraction can overflow, and +// negating INT64_MIN is UB — three ways to be undefined on attacker-chosen input, +// all avoided. static inline NonceResult od_nonce_check(const uint64_t* bm, uint64_t last_seen, uint64_t counter) { - 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 (counter == last_seen) return od_nonce_bit_test(bm, 0) ? NONCE_REPLAY : NONCE_OK; + if (counter > last_seen) return NONCE_OK; /* ahead: never consumed, see above */ - if (fwd == 0u) return od_nonce_bit_test(bm, 0) ? NONCE_REPLAY : NONCE_OK; - if (fwd <= OD_NONCE_FORWARD_CAP) return NONCE_OK; /* ahead: cannot have been seen */ + const uint64_t back = last_seen - counter; /* counter < last_seen, so no wrap */ if (back < OD_NONCE_BACKWARD_BITS) return od_nonce_bit_test(bm, back) ? NONCE_REPLAY : NONCE_OK; return NONCE_OUT_OF_WINDOW; } @@ -104,42 +158,41 @@ static inline void od_nonce_bitmap_shift_left(uint64_t* bm, uint64_t shift) { } } -// Records `counter` as consumed. MUST only be called after the frame carrying -// it has been authenticated (CCM tag verified) — that is the D2 fix. +// Records `counter` as consumed. MUST only be called after the frame carrying it +// has been authenticated (CCM tag verified) — that is the D2 fix, and it is what +// makes the unbounded forward arm of od_nonce_check() safe. All 8 counter bytes +// sit inside the CCM nonce, so a tampered counter changes the keystream and fails +// the tag: a committed counter is always exactly the one the key holder emitted. +// +// last_seen moves only upward, which is the whole security argument above. The +// wholesale-clear inside the shift is now an ORDINARY path, not an off-normal +// one: it fires whenever an authenticated frame lands more than +// OD_NONCE_BACKWARD_BITS ahead, which is the case this design exists to accept. +// It stays correct when it does, because every counter it discards is then at +// least OD_NONCE_BACKWARD_BITS behind the new last_seen and is rejected on width +// — never mis-reported as unseen. // // Defined (never UB) for every input, including inputs od_nonce_check() would -// have rejected: the host test calls it directly, and a future -// OD_NONCE_FORWARD_CAP increase must not be able to produce an over-wide shift. -// The wholesale-clear path is unreachable through od_nonce_check() today (cap -// 128 < 256 bits) but is still correct when it fires on a FORWARD jump: every -// counter it discards is then >= 256 behind and is rejected on width, never -// mis-reported as unseen. -// -// Sharp edge, stated rather than hidden: a counter more than -// OD_NONCE_BACKWARD_BITS *behind* last_seen also lands in the forward branch -// (fwd and back are complements, so its fwd is enormous), clearing the bitmap -// and RE-WINDING last_seen to that counter — un-seeing everything. That is -// unreachable through od_nonce_check(), which returns NONCE_OUT_OF_WINDOW for -// such a counter so it is never committed, and the contract above is that -// commit runs only for frames that both checked OK and authenticated. It is the -// edge to watch if a future caller ever commits without checking first. +// have rejected, because the host test calls it directly. A counter further than +// OD_NONCE_BACKWARD_BITS behind is a deliberate no-op rather than a shift: under +// numeric ordering it can no longer masquerade as a forward jump, so the old +// hazard of such a counter rewinding last_seen and un-seeing the bitmap cannot +// arise — by construction, not by contract. static inline void od_nonce_commit(uint64_t* bm, uint64_t* last_seen, uint64_t counter) { - const uint64_t fwd = counter - *last_seen; - const uint64_t back = *last_seen - counter; - - if (fwd == 0u) { + if (counter == *last_seen) { od_nonce_bit_set(bm, 0); return; } - if (back < OD_NONCE_BACKWARD_BITS) { - /* backward, inside the window: last_seen does not move. - Unambiguous: fwd and back are complements mod 2^64, so back < 256 - forces fwd >= 2^64 - 255 — they can never both be small. */ - od_nonce_bit_set(bm, back); + if (counter < *last_seen) { + /* backward: last_seen does not move. Outside the width there is nowhere + to record it, and nothing needs recording — od_nonce_check() already + rejects everything that far behind. */ + const uint64_t back = *last_seen - counter; + if (back < OD_NONCE_BACKWARD_BITS) od_nonce_bit_set(bm, back); return; } /* forward */ - od_nonce_bitmap_shift_left(bm, fwd); + od_nonce_bitmap_shift_left(bm, counter - *last_seen); *last_seen = counter; od_nonce_bit_set(bm, 0); } diff --git a/tools/test_nonce_window.cpp b/tools/test_nonce_window.cpp index c73862c..552acac 100644 --- a/tools/test_nonce_window.cpp +++ b/tools/test_nonce_window.cpp @@ -7,14 +7,13 @@ // /tmp/test_nonce_window // // This file is as much a written-down statement of the intended semantics of the -// anti-replay window as it is a test. Each block names the coverage item from -// docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md, Decision D, that it discharges. +// anti-replay window as it is a test. // // The representation under test (see the header): // bit i of the bitmap == "counter (last_seen - i) has been consumed" // bit 0 == last_seen itself -// so the backward window is OD_NONCE_BACKWARD_BITS wide (indices 0..255) and the -// forward acceptance window is OD_NONCE_FORWARD_CAP wide. +// so the backward window is OD_NONCE_BACKWARD_BITS wide (indices 0..255). There +// is no forward window: a counter ahead of last_seen is accepted at any distance. #include "../src/nonce_window.h" @@ -173,13 +172,30 @@ static void test_fresh_session(void) { // Counter 1 is one ahead and cannot have been seen. CHECK_RES(check(&s, 1), NONCE_OK); - // Counter UINT64_MAX is one *behind* 0 under modular arithmetic (back == 1), - // inside the backward window, and its bit is clear. + // The no-wrap contract, stated deliberately rather than inherited. + // + // Ordering is numeric, so with last_seen == 0 there is nothing "behind": every + // other counter is ahead and accepted at any distance. These three were + // previously read as modular distances behind zero (back == 1, 255, 256) and + // answered OK / OK / OUT_OF_WINDOW. They are all OK now, and for the opposite + // reason. CHECK_RES(check(&s, UINT64_MAX), NONCE_OK); - - // ...and 256 behind is off the end of the window. - CHECK_RES(check(&s, 0u - (uint64_t)OD_NONCE_BACKWARD_BITS), NONCE_OUT_OF_WINDOW); + CHECK_RES(check(&s, 0u - (uint64_t)OD_NONCE_BACKWARD_BITS), NONCE_OK); CHECK_RES(check(&s, 0u - (uint64_t)(OD_NONCE_BACKWARD_BITS - 1)), NONCE_OK); + + // The state effect is what actually differs, and it is why wrap is not + // supported: accepting UINT64_MAX drives last_seen to the top of the range, + // after which nothing below it can ever be accepted again and the session must + // re-authenticate. Only a key holder can reach this (commit is post-CCM), so + // it is a contract, not a vulnerability. + { + NonceState t; + state_reset(&t, 0); + commit(&t, UINT64_MAX); + CHECK(t.last_seen == UINT64_MAX); + CHECK_RES(check(&t, UINT64_MAX), NONCE_REPLAY); + CHECK_RES(check(&t, 0), NONCE_OUT_OF_WINDOW); + } } // --------------------------------------------------------------------------- @@ -260,15 +276,21 @@ static void test_check_is_pure(void) { const char* what; }; const Case cases[] = { - {base, NONCE_REPLAY, "fwd == 0, bit 0 set -> REPLAY"}, + {base, NONCE_REPLAY, "equal, bit 0 set -> REPLAY"}, {base + 1, NONCE_OK, "forward by 1 -> OK"}, - {base + OD_NONCE_FORWARD_CAP, NONCE_OK, "forward at the cap -> OK"}, {base - 3, NONCE_OK, "backward, unseen -> OK"}, {base - 64, NONCE_REPLAY, "backward, seen -> REPLAY"}, - {base + OD_NONCE_FORWARD_CAP + 1, NONCE_OUT_OF_WINDOW, "past the forward cap"}, {base - OD_NONCE_BACKWARD_BITS, NONCE_OUT_OF_WINDOW, "past the backward window"}, - {base + (1ull << 62), NONCE_OUT_OF_WINDOW, "far ahead"}, - {base - (1ull << 62), NONCE_OUT_OF_WINDOW, "far behind"}, + // Forward is unbounded: these are the distances a forward cap would have + // rejected, and every one of them must be OK. + {base + 129, NONCE_OK, "just past where the old cap sat"}, + {base + 5000, NONCE_OK, "far ahead"}, + {base + (1ull << 32), NONCE_OK, "very far ahead"}, + {base + (1ull << 62), NONCE_OK, "absurdly far ahead"}, + // Genuinely behind: the distance must not exceed `base`, or the + // subtraction underflows and the counter is numerically AHEAD -- which is + // exactly the modular confusion this ordering removes. + {base - 100000, NONCE_OUT_OF_WINDOW, "far behind"}, }; for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { set_context("purity case: %s", cases[i].what); @@ -306,10 +328,11 @@ static void test_check_is_pure(void) { // - every seeded counter whose new distance is >= OD_NONCE_BACKWARD_BITS is // reported OUT_OF_WINDOW, never REPLAY. // -// d = 0, 1, 63, 64, 65, 127, 128 are reachable through od_nonce_check (capped by -// OD_NONCE_FORWARD_CAP). d = 129, 191, 192, 255, 256, 257 are driven directly -// against od_nonce_commit to cover the word boundaries and the wholesale-clear -// guard, which no reachable input can hit while the cap stays below the width. +// EVERY delta is driven through od_nonce_check, including the ones at and past +// the bitmap width. Forward acceptance is unbounded, so the wholesale-clear path +// is now reached the way production reaches it rather than by calling commit +// directly — which matters, because that path stopped being an unreachable guard +// and became the case this design exists to accept. // --------------------------------------------------------------------------- static const uint64_t kSeedOffsets[] = {0, 1, 2, 63, 64, 65, 127, 128, 129, 191, 192, 254, 255}; @@ -327,22 +350,17 @@ static void seed_window(NonceState* s, uint64_t base) { CHECK(s->last_seen == base); } -static void run_shift_edge(uint64_t d, bool reachable_through_check) { +static void run_shift_edge(uint64_t d) { const uint64_t base = 1000000u; NonceState s; seed_window(&s, base); set_context("shift edge d=%" PRIu64, d); - if (reachable_through_check) { - // d == 0 lands on the fwd == 0 branch, whose bit is already set by the seed. - CHECK_RES(check(&s, base + d), d == 0 ? NONCE_REPLAY : NONCE_OK); - commit(&s, base + d); - } else { - // Beyond the forward cap: od_nonce_check rejects it, so drive commit directly. - CHECK_RES(check(&s, base + d), NONCE_OUT_OF_WINDOW); - commit(&s, base + d); - } + // d == 0 lands on the equality branch, whose bit is already set by the seed. + // Every other d is forward and accepted at any distance. + CHECK_RES(check(&s, base + d), d == 0 ? NONCE_REPLAY : NONCE_OK); + commit(&s, base + d); CHECK(s.last_seen == base + d); // The newly committed counter is always recorded. @@ -385,13 +403,13 @@ static void run_shift_edge(uint64_t d, bool reachable_through_check) { } static void test_shift_edges(void) { - const uint64_t reachable[] = {0, 1, 63, 64, 65, 127, 128}; - for (size_t i = 0; i < sizeof(reachable) / sizeof(reachable[0]); ++i) { - run_shift_edge(reachable[i], true); - } - const uint64_t direct[] = {129, 191, 192, 255, 256, 257}; - for (size_t i = 0; i < sizeof(direct) / sizeof(direct[0]); ++i) { - run_shift_edge(direct[i], false); + // Word boundaries, the exact width boundary (255 / 256, the shift-vs-clear + // pair), and jumps far past it — all through od_nonce_check. + const uint64_t deltas[] = {0, 1, 63, 64, 65, 127, 128, 129, + 191, 192, 255, 256, 257, 300, + 4096, (1ull << 32)}; + for (size_t i = 0; i < sizeof(deltas) / sizeof(deltas[0]); ++i) { + run_shift_edge(deltas[i]); } } @@ -478,13 +496,13 @@ static void test_bit_indices_after_shift(void) { } // --------------------------------------------------------------------------- -// Decision D coverage: counter arithmetic [M1] +// Counter arithmetic at the extremes [M1] +// +// Expectations are derived from the numeric definition in the header, not from +// intuition: counters are plain monotonically increasing uint64_t values that do +// NOT wrap, and the ordered tests are counter == last_seen, counter > last_seen, +// back < BITS, else OUT_OF_WINDOW. // -// Expectations here are derived from the modular definition in the header, not -// from intuition about "before" and "after": -// fwd = counter - last_seen (mod 2^64) -// back = last_seen - counter (mod 2^64) -// and the ordered tests fwd == 0, fwd <= CAP, back < BITS, else OUT_OF_WINDOW. // UBSan makes the whole file self-checking against the signed formulation, in // which these same inputs are undefined (int64_t conversion of values >= 2^63, // signed overflow, negating INT64_MIN). @@ -493,131 +511,131 @@ static void test_bit_indices_after_shift(void) { static void test_counter_arithmetic_extremes(void) { const uint64_t two63 = 1ull << 63; - // counter = 2^63, last_seen = 1. The input that makes the signed form UB. - // fwd = 2^63 - 1 -> > CAP - // back = 1 - 2^63 = 2^63 + 1 -> >= BITS - // so it is out of the window in both directions, which is the only sane - // answer for a counter half the space away. + // counter = 2^63, last_seen = 1. The input that makes the signed form UB, and + // the one whose answer INVERTED when ordering stopped being modular: it used + // to be "out of window in both directions", because the modular fwd and back + // were both enormous. Numerically it is simply ahead, so it is accepted -- + // and gated by the CCM tag, not by distance. { NonceState s; state_reset(&s, 1); - CHECK(two63 - 1u > (uint64_t)OD_NONCE_FORWARD_CAP); - CHECK(1u - two63 == two63 + 1u); - CHECK_RES(check(&s, two63), NONCE_OUT_OF_WINDOW); + CHECK(two63 > 1u); + CHECK_RES(check(&s, two63), NONCE_OK); } - // The mirror image: last_seen = 2^63, counter = 1. + // The mirror image is unchanged: last_seen = 2^63, counter = 1 is behind, far + // past the width. { NonceState s; state_reset(&s, two63); CHECK_RES(check(&s, 1), NONCE_OUT_OF_WINDOW); + CHECK_RES(check(&s, 0), NONCE_OUT_OF_WINDOW); } - // And 2^63 apart in the other direction, from a high last_seen. + // From the very top of the range, everything is behind. { NonceState s; state_reset(&s, UINT64_MAX); - CHECK_RES(check(&s, UINT64_MAX + two63), NONCE_OUT_OF_WINDOW); CHECK_RES(check(&s, UINT64_MAX - two63), NONCE_OUT_OF_WINDOW); + CHECK_RES(check(&s, UINT64_MAX - (uint64_t)OD_NONCE_BACKWARD_BITS), NONCE_OUT_OF_WINDOW); + CHECK_RES(check(&s, UINT64_MAX - (uint64_t)(OD_NONCE_BACKWARD_BITS - 1)), NONCE_OK); + CHECK_RES(check(&s, UINT64_MAX), NONCE_OK); // equal, bit 0 clear } - // Near UINT64_MAX, including the wrap past it. last_seen = UINT64_MAX - 2. + // Near UINT64_MAX. last_seen = UINT64_MAX - 2. + // + // This block used to assert that the window WRAPS -- that counter 0 is three + // ahead of UINT64_MAX - 2 and acceptable. It no longer does, and that is the + // deliberate contract: wrapping would reuse a (key, nonce) pair. Counters at + // the low end of the range are simply behind, and the session must + // re-authenticate rather than cycle. { const uint64_t L = UINT64_MAX - 2u; NonceState s; state_reset(&s, L); - CHECK_RES(check(&s, L), NONCE_OK); // fwd == 0, bit 0 clear + CHECK_RES(check(&s, L), NONCE_OK); // equal, bit 0 clear commit(&s, L); CHECK_RES(check(&s, L), NONCE_REPLAY); - // Forward, no wrap yet. - CHECK_RES(check(&s, UINT64_MAX - 1u), NONCE_OK); // fwd == 1 - CHECK_RES(check(&s, UINT64_MAX), NONCE_OK); // fwd == 2 - // Forward, wrapping past UINT64_MAX. fwd = 0 - (2^64 - 3) = 3. - CHECK(0u - L == 3u); - CHECK_RES(check(&s, 0), NONCE_OK); - CHECK(1u - L == 4u); - CHECK_RES(check(&s, 1), NONCE_OK); - // Still forward at the cap, wrapped. - CHECK_RES(check(&s, L + (uint64_t)OD_NONCE_FORWARD_CAP), NONCE_OK); - CHECK_RES(check(&s, L + (uint64_t)OD_NONCE_FORWARD_CAP + 1u), NONCE_OUT_OF_WINDOW); - // Backward across the low end of the space: L - 1 == UINT64_MAX - 3. + // Forward, no wrap available: only two counters remain in the range. + CHECK_RES(check(&s, UINT64_MAX - 1u), NONCE_OK); + CHECK_RES(check(&s, UINT64_MAX), NONCE_OK); + + // Past the top there is nothing. 0 and 1 are far BEHIND, not ahead. + CHECK_RES(check(&s, 0), NONCE_OUT_OF_WINDOW); + CHECK_RES(check(&s, 1), NONCE_OUT_OF_WINDOW); + + // Backward across the top of the space behaves normally. CHECK_RES(check(&s, L - 1u), NONCE_OK); CHECK_RES(check(&s, L - (uint64_t)(OD_NONCE_BACKWARD_BITS - 1)), NONCE_OK); CHECK_RES(check(&s, L - (uint64_t)OD_NONCE_BACKWARD_BITS), NONCE_OUT_OF_WINDOW); - // Accept a wrapped counter and slide the window across the wrap point. - CHECK_RES(check_and_commit(&s, 1), NONCE_OK); - CHECK(s.last_seen == 1u); - - // The pre-wrap counter L is now back = 1 - L = 4 behind, and is recorded. - CHECK(1u - L == 4u); - CHECK_RES(check(&s, L), NONCE_REPLAY); - // Its never-committed neighbours across the wrap are unseen. - CHECK_RES(check(&s, UINT64_MAX - 1u), NONCE_OK); // back == 3 - CHECK_RES(check(&s, UINT64_MAX), NONCE_OK); // back == 2 - CHECK_RES(check(&s, 0), NONCE_OK); // back == 1 - CHECK_RES(check(&s, 1), NONCE_REPLAY); // fwd == 0, committed - - // The backward window reaches BITS-1 below zero and stops there. - CHECK_RES(check(&s, 1u - (uint64_t)(OD_NONCE_BACKWARD_BITS - 1)), NONCE_OK); - CHECK_RES(check(&s, 1u - (uint64_t)OD_NONCE_BACKWARD_BITS), NONCE_OUT_OF_WINDOW); - - // Commit a backward, wrapped counter and read it back. + // Consume the last counter in the range; the session is now exhausted. CHECK_RES(check_and_commit(&s, UINT64_MAX), NONCE_OK); - CHECK(s.last_seen == 1u); // backward commits do not move last_seen + CHECK(s.last_seen == UINT64_MAX); CHECK_RES(check(&s, UINT64_MAX), NONCE_REPLAY); - CHECK_RES(check(&s, UINT64_MAX - 1u), NONCE_OK); + CHECK_RES(check(&s, 0), NONCE_OUT_OF_WINDOW); + // Everything below is either recorded or off the end -- never OK again. + CHECK_RES(check(&s, L), NONCE_REPLAY); + CHECK_RES(check(&s, UINT64_MAX - 1u), NONCE_OK); // never committed, in window + + // Backward commits still do not move last_seen. + CHECK_RES(check_and_commit(&s, UINT64_MAX - 1u), NONCE_OK); + CHECK(s.last_seen == UINT64_MAX); + CHECK_RES(check(&s, UINT64_MAX - 1u), NONCE_REPLAY); } } // --------------------------------------------------------------------------- -// Decision D coverage: differential / property test against a naive oracle +// Differential / property test against a naive oracle +// +// The oracle keeps the UNPRUNED set of every counter it has ever committed. That +// is the point: the previous version pruned counters as they fell out of the +// backward window, which meant it encoded the implementation's forgetting and +// could only ever agree with it. An unpruned set lets the oracle be written from +// the specification instead -- // -// The oracle is the obvious-but-unshippable implementation: the exact set of -// counters consumed, plus last_seen. It reproduces od_nonce_check's decision -// directly from the definition, and prunes counters that have fallen out of the -// backward window when last_seen advances — that pruning is what makes it agree -// on REPLAY versus OUT_OF_WINDOW rather than only on accept versus reject. +// a counter is acceptable iff it has never been committed AND it is either +// ahead of last_seen or within OD_NONCE_BACKWARD_BITS of it // -// Counters stay far from 0 and from 2^64 so the oracle needs no wrap handling; -// the wrap cases are covered exhaustively by test_counter_arithmetic_extremes(). +// -- and it is what makes od_nonce_never_accepts_consumed() below possible, which +// is the one assertion in this file that does not restate the code. +// +// Counters stay clear of 0 and 2^64 so the walk never runs out of range; the +// extremes are covered by test_counter_arithmetic_extremes(). // --------------------------------------------------------------------------- struct Oracle { - std::set seen; + std::set ever_committed; // never pruned uint64_t last_seen; }; static NonceResult oracle_check(const Oracle& o, uint64_t counter) { - const uint64_t fwd = counter - o.last_seen; + const bool consumed = o.ever_committed.count(counter) != 0; + if (counter > o.last_seen) return NONCE_OK; // ahead: cannot have been consumed + if (counter == o.last_seen) return consumed ? NONCE_REPLAY : NONCE_OK; const uint64_t back = o.last_seen - counter; - if (fwd == 0u) return o.seen.count(counter) ? NONCE_REPLAY : NONCE_OK; - if (fwd <= (uint64_t)OD_NONCE_FORWARD_CAP) return NONCE_OK; - if (back < (uint64_t)OD_NONCE_BACKWARD_BITS) { - return o.seen.count(counter) ? NONCE_REPLAY : NONCE_OK; - } - return NONCE_OUT_OF_WINDOW; + if (back >= (uint64_t)OD_NONCE_BACKWARD_BITS) return NONCE_OUT_OF_WINDOW; + return consumed ? NONCE_REPLAY : NONCE_OK; } static void oracle_commit(Oracle* o, uint64_t counter) { - const uint64_t fwd = counter - o->last_seen; - const uint64_t back = o->last_seen - counter; - if (fwd == 0u) { - o->seen.insert(counter); - return; - } - if (back < (uint64_t)OD_NONCE_BACKWARD_BITS) { - o->seen.insert(counter); - return; - } - // Forward: the window slides, and everything now at or past the far edge is - // forgotten — exactly the bits the shift pushes off the end of the bitmap. - o->last_seen = counter; - o->seen.insert(counter); - while (!o->seen.empty() && - (o->last_seen - *o->seen.begin()) >= (uint64_t)OD_NONCE_BACKWARD_BITS) { - o->seen.erase(o->seen.begin()); + o->ever_committed.insert(counter); + if (counter > o->last_seen) o->last_seen = counter; // only ever upward +} + +// THE security property, asserted directly and independently of how the window is +// represented: no counter that was ever consumed may be accepted again, at any +// distance, by any route. A regression that reintroduced modular overlap -- or a +// commit that rewound last_seen -- shows up here as an OK on a member of the set, +// whatever the bitmap happens to contain. +static void od_nonce_never_accepts_consumed(const NonceState* s, const Oracle& o) { + for (std::set::const_iterator it = o.ever_committed.begin(); + it != o.ever_committed.end(); ++it) { + set_context("consumed counter %" PRIu64 " must never be OK (last_seen=%" PRIu64 ")", + *it, s->last_seen); + CHECK(check(s, *it) != NONCE_OK); } + clear_context(); } static void test_differential_against_oracle(void) { @@ -638,12 +656,13 @@ static void test_differential_against_oracle(void) { const unsigned bucket = (unsigned)(rng() % 100u); uint64_t counter; if (bucket < 40u) { - // Forward inside the cap, including fwd == 0 (a replay probe). - counter = s.last_seen + (rng() % (uint64_t)(OD_NONCE_FORWARD_CAP + 1)); + // Forward by a small step, including 0 (a replay probe). + counter = s.last_seen + (rng() % 129u); } else if (bucket < 60u) { - // Forward past the cap: a gap too large to accept. - counter = s.last_seen + (uint64_t)OD_NONCE_FORWARD_CAP + 1u + - (rng() % 1000u); + // Forward by a large jump -- the case a forward cap used to reject + // and which must now be accepted, including jumps wide enough to + // clear the bitmap wholesale. + counter = s.last_seen + 129u + (rng() % 100000u); } else if (bucket < 90u) { // Backward inside or just outside the window. counter = s.last_seen - (1u + rng() % (uint64_t)(OD_NONCE_BACKWARD_BITS + 64)); @@ -677,6 +696,98 @@ static void test_differential_against_oracle(void) { set_context("differential sweep seq=%d back=%" PRIu64, seq, back); CHECK_RES(check(&s, counter), oracle_check(o, counter)); } + + // ...and the security property over every counter this sequence consumed, + // not just the ones still inside the window. + od_nonce_never_accepts_consumed(&s, o); + } + clear_context(); +} + +// --------------------------------------------------------------------------- +// The cliff, as a regression test. +// +// A forward cap made this sequence unrecoverable: nothing commits, so last_seen +// never advances, and each retransmission's higher counter is rejected further +// out than the last. Asserted as a SEQUENCE rather than a point, because a point +// test at last_seen+129 would also pass under "cap = UINT64_MAX" -- one of the two +// shortcuts the header warns against. +// --------------------------------------------------------------------------- + +static void test_forward_gap_is_not_a_cliff(void) { + const uint64_t L = 1000u; + + // Distances that a cap would have rejected, each from a clean state. + const uint64_t jumps[] = {129u, 300u, 5000u, 100000u, (1ull << 32), (1ull << 63)}; + for (size_t i = 0; i < sizeof(jumps) / sizeof(jumps[0]); ++i) { + NonceState s; + state_reset(&s, L); + commit(&s, L); + set_context("forward jump %" PRIu64, jumps[i]); + + CHECK_RES(check(&s, L + jumps[i]), NONCE_OK); + commit(&s, L + jumps[i]); + CHECK(s.last_seen == L + jumps[i]); + CHECK_RES(check(&s, L + jumps[i]), NONCE_REPLAY); + + // The stream must KEEP flowing afterwards -- this is what a cap broke. + for (uint64_t k = 1; k <= 8u; ++k) { + CHECK_RES(check_and_commit(&s, L + jumps[i] + k), NONCE_OK); + } + CHECK(s.last_seen == L + jumps[i] + 8u); + } + clear_context(); + + // The client's actual failure mode: escalating retransmissions. Nothing is + // committed until the frame authenticates, so a run of checks at ever greater + // distance must leave the state untouched AND stay acceptable. + { + NonceState s; + state_reset(&s, L); + commit(&s, L); + unsigned char snapshot[sizeof(NonceState)]; + std::memcpy(snapshot, &s, sizeof(snapshot)); + + uint64_t c = L + 129u; + for (int i = 0; i < 32; ++i) { + set_context("escalating retransmit %d counter=%" PRIu64, i, c); + CHECK_RES(check(&s, c), NONCE_OK); + CHECK(std::memcmp(snapshot, &s, sizeof(snapshot)) == 0); + c += 1000u; + } + // The one that finally authenticates advances the window normally. + CHECK_RES(check_and_commit(&s, c), NONCE_OK); + CHECK(s.last_seen == c); + clear_context(); + } +} + +// --------------------------------------------------------------------------- +// The two shortcuts the header forbids. +// +// Setting a cap to UINT64_MAX, or treating "not within the backward window" as +// forward, both keep modular overlap: an ancient counter presents as an enormous +// forward jump and is accepted. Under numeric ordering it must be OUT_OF_WINDOW. +// This is the test that distinguishes the real fix from either shortcut. +// --------------------------------------------------------------------------- + +static void test_far_behind_is_never_forward(void) { + const uint64_t L = 1ull << 40; + NonceState s; + state_reset(&s, L); + commit(&s, L); + + const uint64_t behind[] = {300u, 4096u, 100000u, (1ull << 32), (1ull << 39)}; + for (size_t i = 0; i < sizeof(behind) / sizeof(behind[0]); ++i) { + set_context("far behind by %" PRIu64, behind[i]); + CHECK_RES(check(&s, L - behind[i]), NONCE_OUT_OF_WINDOW); + + // And committing one anyway (the primitive is callable directly) must not + // rewind last_seen or clear the bitmap -- the old sharp edge. + NonceState t = s; + commit(&t, L - behind[i]); + CHECK(t.last_seen == L); + CHECK(std::memcmp(&t, &s, sizeof(NonceState)) == 0); } clear_context(); } @@ -691,6 +802,8 @@ int main(void) { test_wholesale_slide(); test_bit_indices_after_shift(); test_counter_arithmetic_extremes(); + test_forward_gap_is_not_a_cliff(); + test_far_behind_is_never_forward(); test_differential_against_oracle(); if (g_failures != 0u) { From 15eb4b18c9e17ee2d96377d1c95e115972d53b76 Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:48:44 -0400 Subject: [PATCH 09/11] fix(pipe): log the fatal NACK that ends a transfer Every 0x81 NACK is terminal: it sets pipeState.error, releases the panel, and makes the client raise. None of that left a trace in the device log, so a failed upload could not be told apart from a stall or a link drop without a sniffer or the client's traceback. Logged before the send, at ERROR, with the state a diagnosis needs: the error code, expected_seq, highest_seen, reorder-queue occupancy and the negotiated window. It cannot flood -- pipeState.error is set immediately after and makes every later 0x0081 frame discard, so at most one fires per session. The comment singles out err 0x04. A conforming client cannot produce it: it only transmits seq within W of its own window_base (all four transmit sites in py-opendisplay's _pipe_stream are bounded by that rule), and the device's expected_seq is provably within W of that same base, so fwd < W or back <= W always holds and the "out of window on both sides" arm is unreachable. Client and device agree on W because the device advertises PIPE_MAX_W in the START ACK and both apply the same min-rule -- including on the PIPE_SMALL_DRAM_WINDOW envs where PIPE_MAX_W is 16 rather than 32. That proof rests on a window rule enforced in another repo and on nothing in this firmware, which is exactly why the line is worth having: if the assumption ever drifts, this is the only thing that would say so. nrf52840custom, esp32-c3-N16 and esp32-N4 build. --- src/display_service.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/display_service.cpp b/src/display_service.cpp index 04a9c8a..23b637f 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -2661,6 +2661,24 @@ static void sendPipeAck(void) { static void sendPipeNack(uint8_t err) { uint8_t r[8] = {RESP_NACK, 0x81, err, 0, 0, 0, 0, 0}; pipeBuildAckPayload(r + 3); + // The only record that this session died and why. Everything below is + // observable from the client or a sniffer but nothing was observable from the + // device, so a failed upload could not be told apart from a stall or a link + // drop without one. Logged BEFORE the send so the line survives whatever the + // response path does, and at ERROR because this is always terminal. + // + // Cannot flood: pipeState.error is set immediately below and makes every + // later 0x0081 frame discard, so at most one of these per session. + // + // err 0x04 (out of window on both sides) deserves particular suspicion. A + // conforming client cannot produce it -- it only transmits seq within W of + // its own base, and the device's expected_seq is provably within W of that + // base too -- so seeing 0x04 means either a non-conforming peer or that the + // client's window rule has drifted from what this firmware assumes. + od_log_error("ERROR: PIPE NACK err=0x%02X (expected=%u highest_seen=%u queued=%u W=%u%s) - session fatal", + (unsigned)err, (unsigned)pipeState.expected_seq, (unsigned)r[3], + (unsigned)pipeState.queued_count, (unsigned)pipeState.window, + pipeState.partial ? " partial" : ""); sendResponse(r, sizeof(r)); pipeState.error = true; // Partial transfers own partialCtx, not the full-frame direct-write session: From eeaef4bee32abfb4b5242fe3165c018a7e4d62da Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:49:45 -0400 Subject: [PATCH 10/11] docs: pull in the freeze-fix Phase 1-3 planning docs Findings, plans, and the IT8951 integration/timer-watchdog inventory docs from debug/freeze-fix-phase2, including PLAN_PHASE1_NONCE_REPLAY, directly relevant to this branch's nonce work. PLAN_PHASE2_BOUND_WAITS carries an OBSOLETE banner, superseded by PLAN_PHASE2_REFRESH_BOUNDS; kept as that branch retained it. --- ..._FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md | 706 ++++++++ ...OOP_ARCHITECTURE_CONVERGENCE_2026-07-26.md | 158 ++ .../FINDINGS_PHASE1_PLAN_REVIEW_2026-07-26.md | 431 +++++ .../FINDINGS_PHASE3_PLAN_REVIEW_2026-07-26.md | 232 +++ docs/IT8951_BBEPAPER_INTEGRATION_PLAN.md | 471 ++++++ docs/PLAN_FREEZE_PROOFING_2026-07-26.md | 296 ++++ docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md | 977 +++++++++++ docs/PLAN_PHASE2_BOUND_WAITS_2026-07-26.md | 1465 ++++++++++++++++ docs/PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md | 278 ++++ docs/PLAN_PHASE3_SESSION_GUARD_2026-07-26.md | 1469 +++++++++++++++++ ...TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md | 1182 +++++++++++++ 11 files changed, 7665 insertions(+) create mode 100644 docs/FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md create mode 100644 docs/FINDINGS_LOOP_ARCHITECTURE_CONVERGENCE_2026-07-26.md create mode 100644 docs/FINDINGS_PHASE1_PLAN_REVIEW_2026-07-26.md create mode 100644 docs/FINDINGS_PHASE3_PLAN_REVIEW_2026-07-26.md create mode 100644 docs/IT8951_BBEPAPER_INTEGRATION_PLAN.md create mode 100644 docs/PLAN_FREEZE_PROOFING_2026-07-26.md create mode 100644 docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md create mode 100644 docs/PLAN_PHASE2_BOUND_WAITS_2026-07-26.md create mode 100644 docs/PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md create mode 100644 docs/PLAN_PHASE3_SESSION_GUARD_2026-07-26.md create mode 100644 docs/TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md diff --git a/docs/FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md b/docs/FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md new file mode 100644 index 0000000..b3b85a5 --- /dev/null +++ b/docs/FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md @@ -0,0 +1,706 @@ +# Adversarial Review — PLAN_FREEZE_PROOFING_2026-07-26 + +**Date:** 2026-07-26 +**Reviewing:** `docs/PLAN_FREEZE_PROOFING_2026-07-26.md` (branch `debug/ble-hardening`) +**Scope:** `Firmware` repo — nRF52840 (Bluefruit) + ESP32-S3/C6/C3/classic (NimBLE-Arduino 2.5.0) + +> All Critical/High/Medium corrections below have been folded back into +> `PLAN_FREEZE_PROOFING_2026-07-26.md` (revised 2026-07-26), tagged `[C1]`…`[X7]`. +> This document is retained as the rationale record — read it before re-litigating any +> plan decision, especially the phase ordering and the progress-stamp definition. + +--- + +## Verdict + +**Sound in shape, wrong in several load-bearing details — ship only with the Critical +corrections below.** The diagnosis (§Context items 1–5) is accurate and I verified four of +the five wedge mechanisms against source. The phase decomposition is sensible and most of +the platform facts the author claims to have "verified this session" hold up. + +But the plan has **four Critical defects**, two of which mean the headline deliverable +does not work: + +- **The 10-minute supervisor cannot fire in the exact wedge it was designed for**, because + `markSessionProgress()` is stamped on *command dispatch* and *successful notify* — both + of which keep ticking while the device answers every command `RESP_AUTH_REQUIRED`. +- **Phase 4's second-central refusal is a no-op on the wire**: `BLE_ERR_CONN_LIMIT` (0x09) + is not a legal `HCI_Disconnect` reason, so `ble_gap_terminate` is rejected by the + controller. +- **The `pwrmgmLock` 10 s steal is shorter than a legitimate lock hold** and destroys the + lock's mutual exclusion when it fires. +- **Phase 4's refusal path re-enters the shared disconnect-cleanup flag**, which on the two + non-WiFi envs has no owner guard at all. + +None of these require restructuring the plan. All four are surgical. + +--- + +## CRITICAL + +### C1 — Progress stamps on "command dispatch" and "successful notify" make the supervisor blind to the primary wedge + +**Plan element:** Phase 6 — *"Progress stamps (`markSessionProgress()`): command dispatch, +pipe frame accept, successful notify, LAN frame dispatch, refresh completion."* + +**Why it breaks:** The wedge the plan opens with (§Context item 1) is: `integrity_failures +>= 3` → `clearEncryptionSession()` mid-transfer, after which +`src/communication.cpp:664-670` answers **every** command with +`{RESP_ACK, cmd, RESP_AUTH_REQUIRED}` via `sendResponseUnencrypted()`. That is a *command +dispatch* and a *successful notify* on every single client retry. + +**Concrete sequence** (ESP32, W=16 pipe transfer, HA push): + +| t | event | state | +|---|---|---| +| 0 s | `0x0080` START accepted | `directWriteActive=true`, `pipeState.active=true` | +| 41 s | window loss burst; 3 nonce rejections | `encryption.cpp:691-696` → `clearEncryptionSession()` | +| 41 s+ | client retransmits `0x0081` frames | each reaches `imageDataWritten` → **stamp**; each is answered `RESP_AUTH_REQUIRED` → notify → **stamp** | +| 56 s | py-opendisplay hits `MAX_PTO=3 × TIMEOUT_PIPE_DATA_COMPRESSED=5.0 s` (`device.py:465`, `commands.py:93`) and raises `ProtocolError` | client stops sending; **but does not necessarily drop the link** | +| 56 s → ∞ | HA keeps the `Device` object; any subsequent poll/telemetry command re-stamps | `now - g_lastProgressMs` never exceeds 600 000 | + +The wedge predicate `(transferActive() || chunkedWriteState.active || directWriteActive)` +is **true** the whole time (`directWriteActive` is only cleared by +`cleanupDirectWriteState`, `display_service.cpp:2011`). The supervisor therefore *sees* the +wedge and *never times it out*. Phase 6 delivers nothing for the case it was written for. + +The same defect hits Phase 5: `g_lastLinkRxMs` is stamped in `onWrite` +(`esp32_ble_callbacks.h:81`), so a client that keeps writing doomed commands defeats the +5-minute idle disconnect too. With Phase 6 subsuming the 15-min watchdogs (below), the +device ends up with **strictly less** recovery than today. + +**Correction:** `markSessionProgress()` must mean *the state machine advanced*, not *bytes +moved*. Stamp only at: +- `pipeState.expected_seq` actually advancing (inside the in-order accept in + `handlePipeWriteData`, not at frame receipt), +- `directWriteBytesWritten` increasing (`display_service.cpp:2005`), +- `chunkedWriteState.receivedChunks` incrementing (`communication.cpp:565`), +- `partialCtx` byte counter advancing, +- refresh completion (`display_service.cpp:2436`). + +Do **not** stamp on command dispatch, on notify, or on LAN frame dispatch. Add one extra +stamp on `handleAuthenticate` success so a legitimate re-auth resets the clock. + +--- + +### C2 — `pwrmgmLock` 10 s steal is shorter than legitimate holds, and stealing corrupts the lock + +**Plan element:** Phase 0 — *"`pwrmgmLockTake`: 10 s deadline +(`OD_PWRMGM_LOCK_TIMEOUT_MS = 10000`); on expiry log ERROR and force-take (steal)."* + +**Legitimate holds already exceed 10 s.** The lock is held across panel I/O in three +places (`display_service.cpp:439`, `:496`, `:513`), and the bb_epaper busy wait inside +those paths is bounded at **30 000 ms for 3-/4-/7-colour panels**: + +``` +.pio/libdeps/esp32-s3-N16R8/bb_epaper/src/bb_ep.inl:3959-3975 + int iMaxTime = 5000; // B/W + if (pBBEP->iFlags & (BBEP_3COLOR|BBEP_4COLOR|BBEP_7COLOR)) iMaxTime = 30000; +``` + +- `epdSessionForceOffLocked()` (`display_service.cpp:417-433`) calls `bbepSleep(&bbep,1)`, + which for UC81xx 3-/4-colour panels calls `bbepWaitBusy()` (`bb_ep.inl:4122`, `:4129`) → + **up to 30 s under the lock**, plus `delay(50)`. +- `epdSessionAcquire()` (`:437-487`) holds the lock across `bbepWakeUp` + + `bbepSendCMDSequence` + `epdAlignCustomPartialRamMode`; `bbepSetAddrWindow` alone ends in + a `bbepWaitBusy` (`bb_ep.inl:4104`). + +So on any Spectra/ACeP tag, a **normal** `cleanupDirectWriteState(true)` → `epdSessionForceOff()` +can hold `pwrmgmLock` for ~30 s. On nRF the transfer runs on the Bluefruit Callback task +while `epdSessionTick()` runs on loop — the plan's own rationale for the lock existing. +A 10 s steal fires on healthy hardware. + +**What the steal does when it fires** (`display_service.cpp:401-413`): + +```c +static void pwrmgmLockTake(void) { while (__atomic_exchange_n(&pwrmgmLock,1,ACQUIRE)) delay(1); } +static void pwrmgmLockGive(void) { __atomic_store_n(&pwrmgmLock, 0, RELEASE); } +``` + +`pwrmgmLock` is a plain 0/1 flag with no owner field. After a steal there are two threads +that each believe they hold it: + +1. Stealer runs `epdSessionForceOffLocked()` → `bbepSleep` + rail cut while the true holder + is mid-`bbepSendCMDSequence` → **two tasks driving the same SPI bus and CS line**. +2. True holder finishes and calls `pwrmgmLockGive()` → flag = 0 **while the stealer still + holds it**. Mutual exclusion is now gone for every subsequent operation, permanently. + `epdSessionTick()`'s `pwrmgmLockTryTake()` (`:520`) will now succeed mid-transfer and + rail-cut a live push — the precise failure the lock was introduced to prevent. +3. `pwrmgmState` ends up whatever the *loser* wrote last: the holder's `pwrmgmState = + PWR_ACTIVE` (`:467`) lands after the stealer's `PWR_OFF`, leaving the firmware convinced + the panel is powered when the rail is down. `epdSessionTick()` then never re-arms + (`:520` early-returns unless `PWR_WARM`), so the panel stays permanently mis-tracked. + +**Correction:** do not steal. Make `pwrmgmLockTake` return `bool` with a **60 s** deadline +(≥ 2× the worst-case `bbepWaitBusy`), and on expiry **fail the operation** — log ERROR, +return false, let the caller skip its panel work and set a "panel state unknown" flag that +`abortToKnownState` reports. If a forced recovery is genuinely required, add an owner field +(`volatile TaskHandle_t pwrmgmOwner`) and have the stealer set `pwrmgmOwner = self` so the +original holder's `Give` becomes a detectable no-op instead of a silent unlock. + +--- + +### C3 — `BLE_ERR_CONN_LIMIT` is not a legal `HCI_Disconnect` reason; the exclusivity refusal silently no-ops + +**Plan element:** §Key platform facts and Phase 4 — *"refuse via `pServer->disconnect(connInfo, +BLE_ERR_CONN_LIMIT)`"*. + +`NimBLEServer::disconnect(connInfo, reason)` forwards straight to `ble_gap_terminate`: + +``` +.pio/libdeps/esp32-s3-N16R8/NimBLE-Arduino/src/NimBLEServer.cpp:321-332 +bool NimBLEServer::disconnect(uint16_t connHandle, uint8_t reason) const { + int rc = ble_gap_terminate(connHandle, reason); + ... NIMBLE_LOGE("ble_gap_terminate failed: rc=%d ..."); return false; +``` + +The Core Spec restricts the `HCI_Disconnect` Reason parameter to a small allowlist — +`0x05` Authentication Failure, `0x13`/`0x14`/`0x15` (remote/local user or low-resources +termination), `0x1A` Unsupported Remote Feature, `0x29` Pairing with Unit Key Not Supported, +`0x3B` Unacceptable Connection Parameters. **`0x09` (Connection Limit Exceeded) is not in +that set**; the controller answers `Invalid HCI Command Parameters (0x12)`, +`ble_gap_terminate` returns non-zero, and `disconnect()` returns `false` after logging. + +Result: the gatecrasher stays connected. Phase 4's central promise — single-owner +exclusivity, and the "completes the earlier BLE-max-connections task" claim — does not hold, +while the code *looks* like it worked. This one is easy to miss on the bench because the +second central often disconnects on its own. + +**Correction:** use `BLE_ERR_REM_USER_CONN_TERM` (0x13). Check the `bool` return and log +WARN on failure. Add the same fix to the LAN side (`incoming.stop()` needs no reason code, +so LAN is unaffected). + +--- + +### C4 — Refusing the second central re-enters the shared disconnect-cleanup flag, which has no owner guard on `esp32-N4` + +**Plan element:** Phase 4 — *"ESP32 `onConnect`: refuse second central … `disconnect(connInfo, …)`"*. + +Terminating the refused link fires `MyBLEServerCallbacks::onDisconnect` +(`esp32_ble_callbacks.h:57-70`), which is a blind flag-setter with **no conn-handle +discrimination**: + +```c +bleDisconnectCleanupPending = true; +bleRestartAdvertisingPending = true; +``` + +`serviceBleDisconnectCleanup()` (`main.cpp:321-348`) then decides whether to tear down. Its +only protection is the `ownerStillUp` guard — and that guard is inside +`#ifdef OPENDISPLAY_HAS_WIFI` (`main.cpp:328-338`). + +`OPENDISPLAY_HAS_WIFI` is `TARGET_ESP32 && OPENDISPLAY_ENABLE_WIFI` (`wifi_service.h:13-15`). +Expanding `extends` in `platformio.ini`, exactly two of the eleven envs lack it: +`nrf52840custom` (no NimBLE at all) and **`esp32-N4`** (`platformio.ini:284-299`). + +**Concrete failure on `esp32-N4`:** client A is mid-pipe-transfer at chunk 90/300. A phone +running the opendisplay.org web client scans and connects. `onConnect` sees +`getConnectedCount() == 2`, refuses B. B's `onDisconnect` sets the flag. Next loop pass, +`serviceBleDisconnectCleanup` runs with the guard compiled out: + +``` +main.cpp:343 if (directWriteActive) cleanupDirectWriteState(true); // panel torn down +main.cpp:346 cleanupPartialWriteOnDisconnect(); +main.cpp:347 resetPipeWriteState(); // A's transfer destroyed +``` + +Client A's transfer is killed by a stranger walking past. **This is a new remote-DoS +introduced by the plan**, on the one env that already has the least headroom. + +**Correction:** two changes, both required. +1. Give the refusal its own path: set a `bleRefusedGatecrasherPending` flag in `onConnect` + and have `onDisconnect` skip raising `bleDisconnectCleanupPending` when the departing + handle is the refused one (capture the conn handle in `onConnect` and compare + `connInfo.getConnHandle()` in `onDisconnect`). +2. Move the `ownerStillUp` early-return **out** of `#ifdef OPENDISPLAY_HAS_WIFI` — the + `pServer->getConnectedCount() > 0` half of it is unconditionally correct and costs + nothing on a no-WiFi build. + +--- + +## HIGH + +### H1 — Phase 5 escalates a recoverable command drop into a forced disconnect, and the ring holds 32, not 33 + +**Plan element:** Phase 5 — *"Command ring full: set `commandQueueOverflowAbort` (host task); +loop services → `abortToKnownState("command queue overflow", dropLink=true)`."* + +Today a full ring drops the newest frame (`esp32_ble_callbacks.h:127-129`). The pipe +protocol is *designed* for that: the missing seq shows up as a zero bit in the next SACK +mask and the client retransmits exactly that chunk (`docs/pipe-write-protocol.md` §5.2; +`device.py:2771-2772`). Loss of one frame costs one round trip. Phase 5 turns it into a +link drop plus a full re-auth plus a restarted transfer. + +Worse, the ring's usable capacity is `COMMAND_QUEUE_SIZE - 1 = 32`, not 33 — the producer +refuses at `nextHead == tail` (`esp32_ble_callbacks.h:122`). The comment at `main.h:365-370` +("33 slots hold a full W=32 in-flight window + END") is off by one. With W=32 negotiated +(`PIPE_MAX_W = 32`, `structs.h:51`) and the loop task stalled in a Spectra SPI write, a full +window fills the ring exactly; **any** interleaved non-pipe command (a `0x0060` telemetry +poll, a CCCD write) overflows it and, under Phase 5, kills the link. + +**Correction:** keep the drop, do not drop the link. Set the flag, log one WARN with the +count, and gate the *abort* on the overflow recurring while `transferActive()` and +`g_lastProgressMs` is already stale — i.e. let the supervisor own the decision. If you want +belt-and-braces, bump `COMMAND_QUEUE_SIZE` to 34 on the envs with DRAM to spare (not +`esp32-N4`) so the documented W=32+END claim is actually true. + +### H2 — Phase 2, read literally, makes the cross-transport clobber *worse*, and it ships two phases before the fix + +**Plan element:** Phase 2 — *"Queue flush + session clear must run even when the +transfer-owner guard early-returns for the other transport."* + +`disconnectWiFiServer()` sets `bleDisconnectCleanupPending = true` (`wifi_service.cpp:812`). +If Phase 2 puts `clearEncryptionSession()` **before** the `ownerStillUp` guard at +`main.cpp:333`, then on every WiFi-lost tick (`main.cpp:452-457`) or LAN client close, a +live, authenticated, mid-transfer BLE session is destroyed — which is §Context item 4, the +bug Phase 4 exists to fix, now reachable from a *second* code path and shipping first. + +The guard as written happens to save you today: `transferSessionOrigin()` returns +`sessionOrigin`, which defaults to `0` (`ORIGIN_BLE`, `display_service.cpp:2114`) and is +only written at transfer START, so `lanOwnsSession` is false and `ownerStillUp` reads +`getConnectedCount() > 0`. Putting the clear before it throws that away. + +**Correction:** place `flushCommandQueue(); flushResponseQueue(); clearEncryptionSession();` +**after** the `ownerStillUp` early-return, and make the guard unconditional (see C4.2). +Better: land the Phase 4 owner token *first* and scope the clear to +`linkOwner() == OWNER_BLE`. The plan's own note ("until then clear when the departing +transport owned it") is the right instinct — make it a hard requirement, not a parenthetical. + +### H3 — Phase 6 deletes the only timeouts that currently work, before the supervisor is trustworthy + +**Plan element:** Phase 6 — *"Subsume the 15-min watchdogs: delete main.cpp:436-442 direct-write +block; retire `checkPartialWriteTimeout`'s 15-min path."* + +Given C1, the supervisor's `g_lastProgressMs` is refreshed by ordinary command traffic. +Deleting `main.cpp:436-442` (which keys on `directWriteStartTime`, a *wall-clock start* +stamp that nothing refreshes) replaces an unconditional 15-minute bound with a conditional +one that a chatty client defeats. Same for `checkPartialWriteTimeout` (`display_service.cpp:578-587`), +which keys on `partialCtx.start_time`. + +**Correction:** keep both wall-clock bounds as a backstop and simply raise them past the +supervisor (e.g. 20 min), or add a second supervisor arm that is *not* progress-based: +`transferActive() && now - g_transferStartMs > OD_TRANSFER_HARD_CAP_MS`. Delete the old +blocks only after hardware soak proves the progress arm fires. + +### H4 — nRF: the "single task, no race" premise has a documented hole, and `abortToKnownState` is reachable from the Bluefruit task + +**Plan element:** Phase 2 — *"nRF `disconnect_callback`: add `clearEncryptionSession()` (same +task as all crypto — no race)"*; Phase 1 — *"nRF variant … callable from Bluefruit task"*; +Phase 6 — *"nRF: `volatile bool g_commandInFlight` around `imageDataWritten`"*. + +The premise is *usually* right and I verified it: both `connect/disconnect_callback` +(`bluefruit.cpp:829`, `:849`) and the characteristic write callback +(`BLECharacteristic.cpp:538-542`) are dispatched through `ada_callback()`, i.e. serialized +on the single "Callback" task (`AdaCallback.c:45`, `:147`). + +The hole: `ada_callback_invoke()` allocates its item with `rtos_malloc` and +`VERIFY(cb_data)` returns **false** on failure — at which point `BLECharacteristic.cpp:541` +invokes `_wr_cb` **inline on the BLE event task**: + +```c +if ( !(_use_ada_cb.write && + ada_callback(request->data, request->len, _wr_cb, ...)) ) +{ + _wr_cb(conn_hdl, this, request->data, request->len); // BLE task, not Callback task +} +``` + +Heap exhaustion during a large nRF52840 transfer is exactly when this triggers. Then +`imageDataWritten` runs concurrently on two tasks, `clearEncryptionSession()`'s +`memset(session_key, 0, 16)` (`encryption.cpp:205`) can land mid-`aes_ccm_decrypt`, and +`g_commandInFlight` — a plain bool, not a counter — is cleared by whichever nests out +first, letting the supervisor abort under a live handler. + +**Correction:** make `g_commandInFlight` a `volatile uint8_t` **depth counter** +(increment/decrement, abort only at 0). On nRF, make `clearEncryptionSession()` from +`disconnect_callback` deferred: set `nrfSessionClearPending` and service it from `loop()` +when the depth counter is 0. Note the inline-fallback path explicitly in the plan so nobody +later relies on "single task" as an invariant. + +--- + +## MEDIUM + +### M1 — Asymmetric nonce window (+128 / −32): not a replay hole, but a 4× DoS amplifier + +**Plan element:** Phase 3 — *"backward stays −32; forward becomes `OD_NONCE_FORWARD_WINDOW = 4*PIPE_MAX_W = 128`"*. + +I worked the exact math on the 64-entry ring and the widening is **not** exploitable as a +replay: + +- `encryption.cpp:136` only consults `replay_window[]` when `counter <= last_seen_counter`. + A forward-accepted counter immediately becomes `last_seen_counter` (`:149-151`), so a + second copy of it takes the `<=` branch and is caught by the ring. +- Backward window (32) < ring depth (64), so every counter reachable via the backward branch + is still in the ring. Sound. + +What *is* real: the window is one-sided. An attacker who captures a single valid frame at +counter `N` and replays it can jam `last_seen_counter` forward. With `+32` today the damage +is 32 counters; with `+128` it is 128. The client's next 96 legitimate frames +(`N+1 … N+96`) then fall outside the `−32` backward window and are rejected. Under Phase 3 +those rejections no longer count as `integrity_failures` (correct), so the session survives +— and stalls, which is exactly the wedge the supervisor must catch. The plan makes the +recovery path load-bearing while widening the trigger. + +Ranking this Medium, not Critical: it requires a local attacker with a captured frame, and +the same class of attack exists today at ¼ the magnitude. + +**Correction:** widen **both** sides to `OD_NONCE_WINDOW = 128` and grow +`replay_window[]` from 64 to 256 entries (`encryption_state.h:21`; +1.5 KB `.bss`, check it +on `esp32-N4`). If the RAM is not there, keep backward at −32 but cap forward at +64 so a +single replay cannot open a gap wider than the ring can police. + +Also: the plan correctly moves `replay_window_index` out of the function-static +(`encryption.cpp:152`) into `encryptionSession`. That is a real bug today — +`clearEncryptionSession()` memsets the ring (`:217`) but leaves the index, so the new +session's first 64 accepts overwrite an arbitrary rotation. **Verified correct; keep it.** + +### M2 — Phase 4 exclusivity breaks reconnect after an abrupt client loss + +If the central's host crashes or its adapter is reset, the device does not learn the link is +gone until its **supervision timeout** expires (typically 4 s, up to 32 s; negotiated by the +central — the repo only requests PHY/DLE, `ble_init.cpp:` `ble_nrf_request_fast_link`, and +sets nothing on ESP32). During that window, `getConnectedCount()` still reads 1, so the +returning client's reconnect is refused. Today, with `CONFIG_BT_NIMBLE_MAX_CONNECTIONS = 3` +(`sdkconfig.h:613`), it just works. + +HA delivery is `async with Device(...)` per push (`device.py:655-697`), so a supervision-timeout +window that eats one delivery attempt will be retried next wake (`delivery.py:452`) — not +fatal, but a visible reliability regression on flaky links. + +**Correction:** on refusal, if the *existing* link has no transfer in flight +(`!transferActive()`) **and** its last RX is older than ~10 s, terminate the **old** link +with `BLE_ERR_REM_USER_CONN_TERM` and accept the new one. Refuse only when the incumbent is +actively transferring. + +### M3 — `touchForceResume()` and `directWriteTouchSuspended` drift out of sync + +**Plan element:** Phase 1 — *"`touchForceResume()` (zero the `s_epd_refresh_suspend` counter)"*. + +The counter has a paired bool guard: `handleDirectWriteStart` and the pipe full-frame +setup set `directWriteTouchSuspended = true` (`display_service.cpp:2137`, `:2800`), and +`cleanupDirectWriteState` consumes it (`:2035-2038`). If `abortToKnownState` zeroes the +counter *before* calling `cleanupDirectWriteState`, the subsequent +`touchResumeAfterEpdRefresh()` early-returns at `touch_input.cpp:418` — harmless — but +`directWriteTouchSuspended` is left `true` only if the abort ordering is different from the +plan's. As written (`cleanupDirectWriteState(true)` first, `touchForceResume()` later) the +ordering is fine. + +The genuine hazard the prompt flags — touch polling I2C concurrently with SPI streaming — +does **not** materialise on ESP32: `processTouchInput()` also gates on `transferActive()` +(`touch_input.cpp:584`), and `abortToKnownState` clears all three transfer flags before +`touchForceResume()`. On nRF there is no such gate at all (the block is `#ifdef TARGET_ESP32`), +but nRF also runs the transfer on a different task from `processTouchInput()`, so nothing +changes. + +**Correction:** have `touchForceResume()` also clear `directWriteTouchSuspended` (expose a +setter or move the bool next to the counter), and assert the counter is 0 afterwards. Low +effort, removes a future footgun. Keep the plan's ordering. + +### M4 — `esp32_set_ble_connectable()` has no failure path; a failed `start()` leaves the radio permanently dark + +**Plan element:** Phase 4 — *"stop → `setConnectableMode(NON/UND)` → re-push +`setAdvertisementData` → start"*. + +The manufacturer-data concern is **handled correctly** — see V4 below. The gap is failure +handling. `NimBLEAdvertising::start()` returns `bool`; `setAdvertisementData()` returns +`bool` and fails if the payload exceeds 31 bytes. If either fails after the `stop()`, the +device is not advertising, is not connected, and nothing retries: `bleRestartAdvertisingPending` +is only raised by `onDisconnect`. That is "unresponsive but not frozen" — a support ticket +that looks like a dead device. + +**Correction:** check both return values; on any failure, force +`setConnectableMode(BLE_GAP_CONN_MODE_UND)`, re-push, `start()` again, and set +`bleRestartAdvertisingPending = true` so the loop keeps retrying. + +### M5 — Drain-abort `break` placement relative to `commandQueue[tail].pending = false` + +**Plan element:** Phase 1 — *"after `imageDataWritten` returns, `if (commandDrainAbortPending) +{ clear; break; }` — break WITHOUT the tail store."* + +The index walk is correct as specified. `flushCommandQueue()` sets +`commandQueueTail := head`, the drain then breaks without executing `main.cpp:417`, so tail +stays at the flushed position and nothing is re-dispatched or leaked. **Verified sound.** + +The one hazard is placement: the check must go **between** `main.cpp:415` and `main.cpp:416`. +If it goes after `:416`, the `commandQueue[tail].pending = false` writes into a slot the +producer may already have re-filled post-flush. `pending` is write-only today (its only +readers are `main.cpp:291/309/416` — all writes), so the consequence is nil, but it is a +landmine for anyone who later gives `pending` meaning. + +**Correction:** state the exact insertion point in the plan, and delete the vestigial +`pending` field while you are in there — it has no readers in either ring. + +--- + +## LOW + +### L1 — Plan factual error: the response ring *is* flushed today + +§Context item 3 asserts *"neither ring is EVER flushed (heads/tails never reset anywhere)"*. +The response ring is drained to empty on every loop pass with no central connected: + +``` +main.cpp:307-312 +} else { + while (responseQueueTail != responseQueueHead) { + responseQueue[responseQueueTail].pending = false; + responseQueueTail = (responseQueueTail + 1) % RESPONSE_QUEUE_SIZE; + } +} +``` + +So Phase 2's `flushResponseQueue()` on BLE disconnect is redundant on ESP32 (it happens one +pass later anyway). Only the **command** ring genuinely survives a disconnect. Harmless to +add, but the plan should not claim it as a fix for a bug that does not exist — and the +finding matters because it means stale *responses* were never the wedge. + +### L2 — The 60 s pipe-error reset is safe but far outside client patience, and the doc needs a line + +The prompt's worry that Phase 2 breaks `docs/pipe-write-protocol.md` §5.1 does **not** hold. +§5.1 (lines 363-365) promises only that later `0x0081` frames are *silently discarded* until +the next `0x0080` or a disconnect. After `resetPipeWriteState()`, `handlePipeWriteData`'s +first line still discards them (`display_service.cpp:2811`: `if (!pipeState.active || +pipeState.error) return;`). Client-observable behaviour is identical. + +What is off is the plan's rationale: *"preserves the 60 s stable-ACK-position window for +client retries."* py-opendisplay treats every `0x81` NACK as fatal and raises immediately +(`device.py`, `ProtocolError` on fatal NACK); nothing re-reads the ACK position. 60 s is +~4× the client's entire `MAX_PTO` budget. The number is harmless but the justification is +fiction — pick 10 s and say it is a hardware-release deadline, not a client-retry window. + +Also: `PipeWriteState` (`structs.h:106-126`) has no timestamp field at all, so +`error_since_ms` is a genuine struct addition, not a rename. Fine on RAM (4 bytes). + +### L3 — "No hardware WDT" is true only because every long wait yields + +The plan's premise is defensible but the surrounding facts are not quite as stated: + +- The FreeRTOS Task WDT **is** enabled with panic on the pinned platform: + `CONFIG_ESP_TASK_WDT_INIT 1`, `CONFIG_ESP_TASK_WDT_PANIC 1`, + `CONFIG_ESP_TASK_WDT_TIMEOUT_S 5`, watching `IDLE_TASK_CPU0` + (`framework-arduinoespressif32-libs/esp32s3/qio_qspi/include/sdkconfig.h:906-910`). Plus + `CONFIG_ESP_INT_WDT` at 300 ms (`:903-904`). +- Every ESP env passes `-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` (e.g. `platformio.ini:189`). + That symbol does not exist in IDF 5.x (it is `CONFIG_ESP_TASK_WDT_TIMEOUT_S`), and the + precompiled `sdkconfig.h` wins anyway — the same trap the repo already documented for + `CONFIG_BT_NIMBLE_MAX_CONNECTIONS`. **The flag is inert.** Either delete it or fix the + name; leaving a dead knob that reads like a 120 s guarantee is how the next reader gets + the sizing wrong. +- On single-core C3/C6 the Arduino loop task shares CPU0 with IDLE0, so a genuinely + non-yielding `loop()` *would* panic-reboot in 5 s. It does not today because every long + wait yields (`waitforrefresh` `delay(10)` at `display_service.cpp:759`; `bbepWaitBusy` + `bbepLightSleep(20)` at `bb_ep.inl:3973`). Worth one sentence in the plan so a future + busy-spin does not get added casually. + +The plan's RTC rationale is **correct and already hardware-proven** — see V5. + +### L4 — Minor line-reference drift in the plan + +Not worth churn, but for the record: `communication.cpp:113-116` is actually `:117-121` +(the ring-full check); `sdkconfig.h:471` is `:613` on the S3 variant; `display_fastepd.cpp:222-231` +lands on `fastepd_full_update` at `:227-231`. Everything else I spot-checked +(`encryption.cpp:149-155`, `display_service.cpp:401-408`, `power_latch.cpp:87-90`, +`main.cpp:406-423`/`:321-348`/`:436-442`, `device_control.cpp:227-240`, +`esp32_ble_callbacks.h:128`, `touch_input.cpp:584`, `wifi_service.cpp:804`/`:879`) is exact. + +--- + +## What the plan MISSES — freeze vectors not addressed + +### X1 — I2C bus hang: `Wire.setTimeOut()` is never called anywhere + +`grep -n "setTimeOut" src/*.cpp` returns nothing. The firmware drives four I2C peripherals +(AXP2101 PMIC in `display_service.cpp`, GT911 touch in `touch_input.cpp`, SHT40, BQ27220) +through `Wire`, whose Arduino-ESP32 default timeout is 50 ms per transaction — survivable — +**but** `wireBeginForOpenDisplay()` (`display_service.cpp:785-800`) and +`invalidateOpenDisplayWire()` never re-assert it after a re-`begin()`. More importantly a +slave holding SDA low (classic GT911 wedge after a rail cut) is not recovered by a timeout: +it needs the nine-clock bus-recovery pulse train, which the firmware never issues. + +This is a real, common e-paper-tag freeze mode (touch controller and panel share the rail +that `epdSessionForceOff` cuts), and the supervisor cannot see it — `transferActive()` is +false while `processTouchInput()` spins. **Recommend:** add explicit `Wire.setTimeOut(25)` +after every `Wire.begin()`, and a nine-clock SDA-recovery routine invoked when +`rt->i2c_fail_streak` crosses a threshold (`touch_input.cpp:400` already tracks it). + +### X2 — The boot refresh is invisible to every `epdRefreshInProgress` gate + +`refreshBootScreenFull()` (`display_service.cpp:533-542`) calls `touchSuspendForEpdRefresh()` ++ `bbepRefresh` + `waitforrefresh(60)` and **never sets `epdRefreshInProgress`**. Neither +does the FastEPD boot path (`:1588-1594`). Consequences: `serviceBleDisconnectCleanup` +(`main.cpp:322`), `esp32_restart_ble_advertising` (`ble_init.cpp:236`), the `workInFlight` +gate (`main.cpp:478`) and the plan's new `idleLinkTick()` / supervisor "never interrupt a +refresh" rule all mis-read a boot refresh as idle. On a Spectra panel that is a 30–60 s +blind spot on every cold boot, and the retry path (`:1624-1633`) can double it. + +**Recommend:** set/clear `epdRefreshInProgress` around both boot-refresh paths as a Phase 0 +one-liner. It is free and it makes the supervisor's refresh exemption honest. + +### X3 — `fastepd_wait_refresh()` is a stub — the IT8951 path has *zero* firmware-side bound + +``` +src/display_fastepd.cpp:228-231 +bool fastepd_wait_refresh(int timeout_sec) { + (void)timeout_sec; + return !s_init_failed; +} +``` + +`waitforrefresh(60)` short-circuits to this on FastEPD builds (`display_service.cpp:749-751`), +so the "60 s cap" the plan cites does not exist on IT8951/E1004. The plan's Phase 0 mitigation +(log if `fullUpdate` exceeds 120 s) wraps `fastepd_full_update` (`:227-231`) but **not** +`fastepd_direct_refresh`, which is the path a real transfer takes +(`display_service.cpp:2422-2423`). Post-hoc logging on the wrong function is not the +"documented residual risk" the plan claims to have accepted. + +**Recommend:** wrap `fastepd_direct_refresh` too, and implement `fastepd_wait_refresh` as a +real busy poll against the IT8951 LUT-busy register with the passed `timeout_sec`. + +### X4 — Config chunked-write has no timer of its own + +`chunkedWriteState.active` is set at `communication.cpp:496` and cleared only on completion +(`:574`), on a malformed chunk (`:558`), or on an auth failure (`:550`). A client that sends +`0x0040` START and vanishes leaves it latched forever — and, unlike the transfer flags, it +is not covered by any existing watchdog. The plan adds `resetChunkedWriteState()` and puts +`chunkedWriteState.active` in the supervisor predicate, which is correct — but per C1 the +supervisor never fires. Once C1 is fixed this is covered; flagging it so the dependency is +explicit. + +### X5 — Deep-sleep entry with a live transfer + +`enterDeepSleep()` (`main.cpp:577`) is reachable from the advertising-window branch +(`main.cpp:388`) and the idle branch (`:498`). The idle branch is gated by `workInFlight`, +which includes `epdRefreshInProgress` and `getConnectedCount() > 0` — but **not** +`transferActive()`. A pipe transfer whose client link has already dropped (so +`getConnectedCount() == 0`) but whose `pipeState.active` is still latched, with +`bleDisconnectCleanupPending` deferred behind `epdRefreshInProgress`, can reach +`enterDeepSleep()` with the panel rail up. Rare, but the plan's `abortToKnownState` is the +natural place to close it. + +**Recommend:** add `transferActive()` to the `workInFlight` disjunction at `main.cpp:474-479`. +One term, no behaviour change in the normal case. + +### X6 — Buzzer and LED timers are outside every bound + +`buzzerService()` / `processLedFlash()` run unbounded from `loop()`. The plan's +`abortToKnownState` lists "buzzer/LED stop" — good — but nothing bounds a stuck +`buzzer_control` sequence in the first place. Low probability; noting for completeness. + +### X7 — Nothing detects "advertising stopped and never restarted" + +`esp32_restart_ble_advertising()` early-returns without starting when +`epdRefreshInProgress` (`ble_init.cpp:236-239`, re-arming the flag) or when +`getConnectedCount() > 0` (`:232-235`, **clearing** the flag). The second is the hole: if +the count is stale-nonzero (refused gatecrasher, C3/C4) the flag is cleared and advertising +never resumes. **Recommend:** a cheap `advertisingHealthTick()` — if no peer, not advertising +(`BLEDevice::getAdvertising()->isAdvertising()`), and no pending flag for > 30 s, force a +restart and log WARN. + +--- + +## Phase-ordering hazards + +| Phase shipped alone | Verdict | +|---|---| +| **0** | Net **regression** as written, because of the `pwrmgmLockTake` steal (C2). The `powerOff` and drain-cap bounds are fine. Ship Phase 0 only after C2's correction (fail-closed instead of steal). | +| **1** | Safe alone. The drain-trap fix (M5) and the queue flushes are self-contained and correct. `abortToKnownState` has no callers until Phase 5/6, so it is dead code — which is fine, but means Phase 1 provides no field benefit on its own. | +| **2** | **Must not ship before Phase 4** if implemented literally (H2). With the clear placed *after* an unconditional owner guard, it is safe and beneficial alone. The nRF half needs H4's deferral. | +| **3** | Safe alone and the highest value-per-risk phase — it removes the actual root cause of the field failures. Ship it **first**. | +| **4** | Blocked on C3 (wrong reason code) and C4 (cleanup re-entry). Once fixed, safe alone. | +| **5** | Net **regression** alone: the command-ring abort (H1) and the RX/TX-keyed idle timer (C1's twin) each make things worse without the corrected progress accounting from Phase 6. | +| **6** | Net **regression** alone as written: it deletes two working wall-clock watchdogs (H3) and replaces them with a predicate that command traffic defeats (C1). | + +**Recommended order:** 3 → 0(corrected) → 1 → 4(corrected) → 2 → 6(corrected, keeping the +old watchdogs) → 5(corrected). That front-loads the phase that fixes the reported field +failure and puts the owner token in place before anything depends on it. + +--- + +## Verified CORRECT — do not churn on these + +1. **`verifyNonceReplay` commits before tag verification.** `encryption.cpp:149-155` writes + `last_seen_counter` and the ring *before* `aes_ccm_decrypt` runs at `:714`. Real bug, + correctly diagnosed, and the `nonceCheck`/`nonceCommit` split is the right shape. +2. **Nonce failures should not touch `integrity_failures`.** `encryption.cpp:691-697` + currently increments on *any* `verifyNonceReplay` failure — including plain packet loss. + Confirmed as the mechanism behind §Context item 1. Fix is correct. +3. **The pipe fatal-NACK latch has no timeout.** `sendPipeNack` (`display_service.cpp:2562-2578`) + sets `pipeState.error = true` and calls `cleanupDirectWriteState(true)`, which clears + `directWriteActive` (`:2011`) — so `main.cpp:436-442`'s watchdog no longer applies, and + `checkPartialWriteTimeout` (`:578`) only covers `partialCtx`. For a non-partial pipe + transfer there is genuinely **no** bound. Diagnosis exact. +4. **`setConnectableMode(NON)` calls `setFlags(0)` and the re-push is required.** Confirmed + at `NimBLEAdvertising.cpp:82-84`; `setFlags(0)` is one-way (UND does not restore it), and + `setAdvertisementData(*advertisementData)` copies the app object which carries + `setFlags(0x06)` from `ble_init.cpp:302`. The plan's mitigation, and the + "`setAdvertisementData` must be LAST" trap at `ble_init.cpp:307-312`, are both correct. +5. **RTC memory does not survive a non-deep-sleep reset.** `main.cpp:96-100` documents it + from hardware (`FINDINGS_DEEP_SLEEP_WAKE_BOOT_SCREEN_2026-07-07.md`), and + `displayed_etag` is `RTC_DATA_ATTR` (`main.h:294`). The "reset state, never reboot" + decision is well-founded. +6. **NimBLE adds the peer before `onConnect`.** `NimBLEServer.cpp:464-471` fills + `m_connectedPeers` then calls `onConnect`, so `getConnectedCount() > 1` is a valid + gatecrasher test. (The refusal *mechanism* is still wrong — C3.) +7. **`-DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=1` is inert.** `sdkconfig.h:613` defines it to 3. + Correct to enforce in code. +8. **nRF `Bluefruit.begin(1, 0)` already caps at one link.** `ble_init.cpp:152`. Correct. +9. **Clearing the session on disconnect is client-compatible.** py-opendisplay authenticates + inside `__aenter__` on every connection (`device.py:670`) and calls `_clear_session()` in + `__aexit__` (`:697`) and on any setup failure (`:680-683`). HA's delivery path is one + `async with Device(...)` per push. No client assumes session persistence across a link + drop. **No compatibility risk.** +10. **The 10-minute supervisor timeout is not too short for legitimate work.** Worst-case + legitimate blocks: `waitforrefresh(60)` = 60 s (`display_service.cpp:2423`), + `bbepWaitBusy` 30 s, and the E1004 ~960 KB upload — but the client itself gives up far + sooner (`TIMEOUT_PIPE_DATA_COMPRESSED = 5.0` × `MAX_PTO = 3` ≈ 15 s; + `TIMEOUT_REFRESH = 90.0`, `device.py:457-473`). Provided the stamps are moved per C1, no + legitimate transfer comes near 600 s of no progress. The number is fine. +11. **The 2 s drain cap is safe.** The drain already caps at `COMMAND_QUEUE_SIZE` iterations + and flushes responses between commands (`main.cpp:419-421`); a wall-clock cap on top + changes nothing in the normal case. +12. **The queue-flush SPSC reasoning is right.** `commandQueueTail` is written only by the + consumer, and every call path the plan names (`serviceBleDisconnectCleanup`, the drain + loop, LAN dispatch, `abortToKnownState`) is on the loop task. `tail := head` snapshot is + the correct flush. `responseQueue` head/tail are both loop-task-only, so its flush is + trivially safe. +13. **The OTA exception is genuinely satisfied.** ESP32 `0x0051` is `esp_restart`; nRF DFU + jumps to the bootloader. Nothing to special-case. +14. **Phase 2's 60 s pipe-error reset does not break `pipe-write-protocol.md` §5.1** — see + L2. The doc's contract is about client-observable discard behaviour, which is unchanged. + Update §5.1 to mention the reset, but no protocol change is needed. + +--- + +## Summary of required corrections + +| # | Phase | Correction | +|---|-------|-----------| +| C1 | 6, 5 | Stamp progress only on state-machine advancement; never on command dispatch or notify. Key the idle timer the same way. | +| C2 | 0 | Replace the 10 s lock steal with a 60 s fail-closed take; add an owner field if a steal is ever genuinely needed. | +| C3 | 4 | Use `BLE_ERR_REM_USER_CONN_TERM` (0x13), not `BLE_ERR_CONN_LIMIT` (0x09). Check the return value. | +| C4 | 4 | Discriminate the refused conn handle in `onDisconnect`; move the `ownerStillUp` guard out of `#ifdef OPENDISPLAY_HAS_WIFI`. | +| H1 | 5 | Command-ring overflow logs and drops; it does not drop the link. Fix the off-by-one in the `main.h:365` comment. | +| H2 | 2 | Place `clearEncryptionSession()` after the owner guard, or land Phase 4's token first. | +| H3 | 6 | Keep the wall-clock watchdogs as a backstop until the progress arm is soak-proven. | +| H4 | 1, 2, 6 | `g_commandInFlight` becomes a depth counter; defer the nRF disconnect-time session clear to `loop()`. | +| M1 | 3 | Make the nonce window symmetric (±128 with a 256-entry ring, or ±64). | +| M2 | 4 | Prefer evicting an idle incumbent over refusing a reconnect. | +| M4 | 4 | Check `setAdvertisementData`/`start()` returns; fall back to connectable. | +| X1 | new | `Wire.setTimeOut()` + a nine-clock I2C recovery routine. | +| X2 | 0 | Set `epdRefreshInProgress` around both boot-refresh paths. | +| X3 | 0 | Wrap `fastepd_direct_refresh`, and implement `fastepd_wait_refresh` for real. | +| X5 | 6 | Add `transferActive()` to the `workInFlight` disjunction. | +| X7 | new | `advertisingHealthTick()` — detect a permanently dark radio. | + +## Build/portability check + +New `src/session_guard.cpp` compiles into **all eleven** envs. Guards needed: +`pServer` / `responseQueue` / `commandQueue` under `#ifdef TARGET_ESP32`; LAN calls under +`#ifdef OPENDISPLAY_HAS_WIFI` (**not** `TARGET_ESP32` — `esp32-N4` is ESP32 without WiFi, +`platformio.ini:284`). `lib_ignore = NimBLE-Arduino` on nRF (`platformio.ini:36`) means +`session_guard.cpp` must not include `ble_init.h`'s NimBLE surface unguarded. RAM cost is a +handful of scalars plus `PipeWriteState::error_since_ms` (+4 B) — fine even on `esp32-N4`, +which is DRAM-tight enough to need `PIPE_SMALL_DRAM_WINDOW` (`structs.h:45-48`). If M1's +256-entry `replay_window` is adopted (+1.5 KB `.bss`), verify `esp32-N4` links before +committing to it. diff --git a/docs/FINDINGS_LOOP_ARCHITECTURE_CONVERGENCE_2026-07-26.md b/docs/FINDINGS_LOOP_ARCHITECTURE_CONVERGENCE_2026-07-26.md new file mode 100644 index 0000000..70a0bbf --- /dev/null +++ b/docs/FINDINGS_LOOP_ARCHITECTURE_CONVERGENCE_2026-07-26.md @@ -0,0 +1,158 @@ +# Can the nRF52840 and ESP32 `loop()` / `idleDelay()` paths be converged? + +**Date:** 2026-07-26 · **Branch:** `debug/ble-hardening` (HEAD `2e2131b`) +**Scope:** this repo only. Three framework sources were opened for one decisive signature each — every such use is labelled **[LIB]**; nothing else was read from them. Sibling repos not consulted. + +## Recommendation + +**Verdict: partially feasible — and the feasible part is not the appealing part.** Full convergence (A) is not worth it: of the ~160-line ESP32 arm, ~130 lines are deep-sleep, WiFi/LAN, NimBLE advertising and queue-drain work with no nRF counterpart. Converging the *execution model* (C — nRF adopts the SPSC ring) is **feasible on RAM but counter-productive**: Bluefruit already copies each write payload and posts it to a dedicated FIFO "Callback" task with an *elastic* queue **[LIB]**, so an app ring adds a second copy and hop only to replace an unbounded-but-growing queue with a fixed 32-slot drop-on-full ring, and to move dispatch from a `TASK_PRIO_NORMAL` task onto the `TASK_PRIO_LOW` loop task that lives inside 100 ms `delay()` chunks. **Recommended: option B fused with D** — extract the target-agnostic supervisory work (`processLedFlash`/`epdSessionTick`/`buzzerService`, the two 15-min watchdogs, later the Phase 6 supervisor) into one `serviceSupervisoryTick()` called from both `loop()` arms *and* from `idleDelay()`. ~50 LOC in one file, closes the real defect (nRF has no transfer watchdog), gives the freeze-proofing plan one wiring point instead of two. Hard prerequisite: plan item **H4**'s `g_commandInFlight` depth counter must land first. + +## 0. Corrections to the stated facts + +Nine of twelve confirmed exactly. Confirmed: single top-level `#ifdef` ([main.cpp:352-354](../src/main.cpp) prologue, `#ifdef` at [:355](../src/main.cpp) and [:358](../src/main.cpp), `#else` [:518](../src/main.cpp), `#endif` [:530](../src/main.cpp)); 11-line nRF arm [:519-529](../src/main.cpp); `idleDelay` [:535-551](../src/main.cpp); both watchdogs inside the ESP32 arm ([:436-442](../src/main.cpp), [:443](../src/main.cpp)) while `checkPartialWriteTimeout()` itself ([display_service.cpp:578-587](../src/display_service.cpp)) is target-agnostic; no `sd_power_system_off` anywhere and `power_latch.cpp` stubs out on non-ESP32 ([power_latch.cpp:194-210](../src/power_latch.cpp)); ESP32 `onConnect` deferral ([esp32_ble_callbacks.h:52-55](../src/esp32_ble_callbacks.h)); `pwrmgmLock` comment ([display_service.cpp:397-408](../src/display_service.cpp)). + +Three refinements: + +- **R1 (load-bearing).** *"On nRF there is NO queue."* There **is** one — it belongs to the framework. `BLECharacteristic::_eventHandler` dispatches the write via `ada_callback(request->data, request->len, _wr_cb, …)` **[LIB]** (`Bluefruit52Lib/src/BLECharacteristic.cpp:537-541`), which `rtos_malloc`s a copy of the payload and posts it to a FreeRTOS queue drained by a dedicated `"Callback"` task **[LIB]** (`cores/nRF5/utility/AdaCallback.c:145-147`). The payload is already copied out of the SoftDevice buffer and the handler already runs off the BLE task. What is true: it does not run on the *loop* task, and this repo owns no queue of its own. +- **R2.** `disconnect_callback` is *also* dispatched through `ada_callback` **[LIB]** (`bluefruit.cpp:849`), as is `connect_callback` (`:829`). Connect/write/disconnect all run on the **same** task in **strict arrival order** — a serialization guarantee the ESP32 flag-deferral lacks. "Inline" is right relative to `loop()`, wrong relative to the BLE stack. +- **R3.** `idleDelay` services *before* it sleeps ([main.cpp:542-548](../src/main.cpp)), so anything added there inherits ≤100 ms service latency. + +## 1. Side-by-side responsibility table + +| # | Responsibility | ESP32 | nRF52840 | Divergence | +|---|---|---|---|---| +| 1 | `processLedFlash()` | loop, [:352](../src/main.cpp)/[:544](../src/main.cpp) | same | shared already | +| 2 | `epdSessionTick()` | loop, [:353](../src/main.cpp)/[:545](../src/main.cpp) | same | shared already | +| 3 | `buzzerService()` | loop, [:354](../src/main.cpp),[:483](../src/main.cpp),[:516](../src/main.cpp),[:546](../src/main.cpp) | loop, [:529](../src/main.cpp),[:546](../src/main.cpp) | shared already | +| 4 | buttons / touch | loop, [:481-482](../src/main.cpp),[:514-515](../src/main.cpp),[:542-543](../src/main.cpp) | loop, [:527-528](../src/main.cpp),[:542-543](../src/main.cpp) | shared already | +| 5 | BLE command dispatch | **loop task**, ring drain [:406-423](../src/main.cpp); producer on NimBLE host task [esp32_ble_callbacks.h:117-129](../src/esp32_ble_callbacks.h) | **Bluefruit Callback task**, payload copied by `ada_callback` **[LIB]**; registered [ble_init.cpp:157](../src/ble_init.cpp) | **platform** — NimBLE has no `ada_callback`; the ESP32 ring hand-rolls what Bluefruit ships | +| 6 | BLE response TX | queued → `flushResponseQueueToBle()` [:275-313](../src/main.cpp), 16/call | inline `notify()` + bounded retry [communication.cpp:334-355](../src/communication.cpp) | platform-adjacent | +| 7 | Disconnect teardown | flag → `serviceBleDisconnectCleanup()` [:321-348](../src/main.cpp),[:428](../src/main.cpp) | on Callback task [device_control.cpp:227-240](../src/device_control.cpp) | **incidental**, but nRF form is safe today via FIFO ordering (R2) | +| 8 | `updatemsdata()` on connect | flag → loop [esp32_ble_callbacks.h:55](../src/esp32_ble_callbacks.h),[main.cpp:429-432](../src/main.cpp) | **inline on Callback task** [device_control.cpp:219](../src/device_control.cpp) | **incidental — latent nRF defect (§2.1)** | +| 9 | `updatemsdata()` periodic | 60 s, idle branch only [:509-513](../src/main.cpp) | every `sleep_timeout_ms`, only if nonzero [:519-522](../src/main.cpp) | incidental | +| 10 | Advertising restart/interval | flag → `esp32_restart_ble_advertising()` [:433-435](../src/main.cpp),[ble_init.cpp:227-245](../src/ble_init.cpp) | `ble_nrf_advertising_tick()` [:526](../src/main.cpp),[:540](../src/main.cpp),[ble_init.cpp:59-76](../src/ble_init.cpp) | platform | +| 11 | Direct-write 15-min watchdog | loop [:436-442](../src/main.cpp) | **absent** | **incidental — real gap** | +| 12 | `checkPartialWriteTimeout()` | loop [:443](../src/main.cpp) | **absent** | **incidental — real gap** | +| 13 | WiFi/LAN + 10 s supervisor | loop [:444-465](../src/main.cpp) | n/a | platform | +| 14 | `pollActivity()` | loop [:218-265](../src/main.cpp), called [:356](../src/main.cpp) | n/a | platform | +| 15 | Post-wake window + `enterDeepSleep()` | [:360-398](../src/main.cpp),[:486-500](../src/main.cpp) | n/a | platform | +| 16 | `workInFlight` cadence | [:474-517](../src/main.cpp) | `idleDelay(sleep_timeout_ms)`/`(500)` [:519-525](../src/main.cpp) | platform in substance | +| 17 | Ring flush on disconnect | **absent both** | n/a | plan Phase 3 | + +**Net:** rows 13–16 plus the drain account for ~130 of the ~160 ESP32 lines. Genuinely incidental divergence (rows 8, 9, 11, 12) totals **about 15 lines**. That ratio is the answer. + +### 2.1 Concrete latent defect this exposes + +`updatemsdata()` ([display_service.cpp:1734-1820](../src/display_service.cpp)) polls I²C sensors, reads the battery ADC, then on nRF does `clearData`→`addFlags`→`addName`→`addData`→`stop()`→`start(0)` ([display_service.cpp:1767-1783](../src/display_service.cpp)) guarded by an unlocked file-static `prev_msd_payload_nrf[16]`. Called from the **Callback task** ([device_control.cpp:219](../src/device_control.cpp)) *and* the **loop task** ([main.cpp:521](../src/main.cpp)) concurrently, where the Callback task outranks loop **[LIB]** (`AdaCallback.c:147` `TASK_PRIO_NORMAL` vs `cores/nRF5/main.cpp:88` `TASK_PRIO_LOW`). This is exactly the hazard ESP32 avoided ([esp32_ble_callbacks.h:52-55](../src/esp32_ble_callbacks.h)). Two-line fix, independent of every option. + +## 3. Can nRF adopt the queue model? + +### 3.1 RAM — non-issue, measured + +A clean `pio run -e nrf52840custom` was performed (SUCCESS, 3.4 s): +`RAM: 18.0% (42772 / 237568 B)`, `Flash: 31.1% (251972 / 811008 B)`. `size -A`: `.text` 251 056, `.data` 908, `.bss` 41 864, **`.heap` 192 748**. Largest `.bss`: `pipeReorder` 8 316 B (nRF takes the full 33-slot window — `PIPE_SMALL_DRAM_WINDOW` is `esp32-N4`-only, [structs.h:44-56](../src/structs.h)), `chunkedWriteState` 4 116, `configScratch` 4 096, `Bluefruit` 1 932. + +Ring cost: `CommandQueueItem` = 260 B padded ([esp32_ble_callbacks.h:25-29](../src/esp32_ble_callbacks.h)) × 33 ([main.h:371](../src/main.h)) = **8 580 B**; `ResponseQueueItem` 260 × 10 = **2 600 B**; total **≈11.2 KB** → `.bss` ~53 KB, heap ~181 KB. **RAM does not constrain this decision.** Reproduce with `~/.platformio/penv/bin/pio run -e nrf52840custom` (bare `pio` is not on `PATH` here). + +### 3.2 The framework already does what the ring was invented for + +`ada_callback()` **[LIB]** (`AdaCallback.c:106-140`) mallocs a copy and `xQueueSend`s it (100 ms bounded, `CFG_CALLBACK_TIMEOUT`); on queue-full it **doubles the queue depth and retries** (`AdaCallback.c:76-99`). Today: *SoftDevice event → copy → elastic FIFO → dispatch on a NORMAL-priority task in arrival order, with connect/write/disconnect serialized.* Under C: *…→ second copy into a 33-slot ring → second hop → dispatch on a LOW-priority task usually inside `delay()`, with **drop-on-full** ([esp32_ble_callbacks.h:127-129](../src/esp32_ble_callbacks.h)), and disconnect no longer FIFO-ordered against in-flight writes.* Every property that changes, changes for the worse except one. + +### 3.3 Flow control + +The characteristic is `BLEWrite | BLEWriteWithoutResponse | BLENotify`; for a stack-located value the SoftDevice generates the ATT response itself, so inline processing already gives **no** ATT-level backpressure — deferring loses nothing. *(Assumption; verify on hardware by checking whether client write-with-response latency tracks firmware dispatch time.)* What the current model does give is implicit rate limiting via queue **growth**; a fixed ring converts that into `"Command queue full, dropping command"`. The pipe protocol absorbs drops (SACK zero-bit → retransmit; `docs/pipe-write-protocol.md` §5.2, plan `[H1]`), but usable ring depth is `COMMAND_QUEUE_SIZE-1 = 32` — exactly `PIPE_MAX_W`, zero headroom for the `0x0082` END (the `[H1]` off-by-one; the [main.h:365-370](../src/main.h) comment is wrong). **A queue makes drops more likely on nRF, not less.** + +### 3.4 Latency/throughput — the real cost + +The nRF steady state is `idleDelay(sleep_timeout_ms)`/`(500)` ([:519-525](../src/main.cpp)), chunking at 100 ms and servicing-then-sleeping ([:538-549](../src/main.cpp)). Draining from `loop()` alone delays each frame by the whole `idleDelay` (up to 65 s expressible). Draining from `idleDelay` bounds it at ~100 ms — a ceiling of roughly **78 KB/s** (32 × 244 B per 100 ms) plus a 100 ms floor on ACK latency the pipe SACK cadence ([display_service.cpp:2854](../src/display_service.cpp)) is not tuned for. Recovering today's throughput means rebuilding `idleDelay` into a real event loop — a change to the one function *both* targets share, risking 11 envs to fix an nRF-only problem. + +### 3.5 Response path + +nRF `sendResponse` notifies inline with a bounded 4×5 ms retry ([communication.cpp:342-349](../src/communication.cpp)) — ≤20 ms, on backpressure only. A response ring would **hurt** latency (≤100 ms), **help** nothing (ESP32 needs one because its dispatcher *is* the loop task), and raise a new question about `notify()` from two tasks. **Do not add a response ring to nRF under any option.** + +### 3.6 What breaks if dispatch leaves the Callback task + +Little — which is why C is *feasible* though unwise. Nothing reads the callback args (`(void)conn_hdl; (void)chr;`, [communication.cpp:625-627](../src/communication.cpp)). DFU uses the global accessor `Bluefruit.disconnect(Bluefruit.connHandle())` ([device_control.cpp:844-848](../src/device_control.cpp)), valid from any task; the bootloader jump ([:850-866](../src/device_control.cpp)) is arguably safer off the stack's own task. **But ordering is load-bearing:** disconnect is FIFO-ordered behind queued writes **[LIB]** (`bluefruit.cpp:849`); split them and a disconnect tears down `pipeState`/`partialCtx`/`directWriteActive` ([device_control.cpp:237-239](../src/device_control.cpp)) underneath frames still in the ring. So C must *also* defer disconnect. And the `transferActive()` touch gate ([touch_input.cpp:584-586](../src/touch_input.cpp), `#ifdef TARGET_ESP32`) becomes newly *necessary* on nRF. C is "port the entire ESP32 deferral discipline", not "add a ring". + +## 4. Options + +**A — full convergence.** ~200 LOC changed across `main.cpp/.h` + stubs in `wifi_service`/`ble_init`/`power_latch`. The ESP32 arm has three early `return`s ([:366](../src/main.cpp),[:389](../src/main.cpp),[:397](../src/main.cpp)) that skip the rest of the pass, and a `workInFlight` branch whose arms differ in cadence *and* in which services they call ([:480-517](../src/main.cpp)). Target-neutral form means either keeping the early returns (so "shared" is fiction) or restructuring deep sleep — the least-testable-without-hardware subsystem. **Risk HIGH. Reject.** + +**B — shared supervisory tick (recommended, fused with D).** New `static void serviceSupervisoryTick()` in `main.cpp`: `processLedFlash` + `epdSessionTick` + `buzzerService` + both wall-clock watchdogs (+ Phase 6 supervisor later). Called at the top of `loop()` and per chunk in `idleDelay()`. ~50 LOC net, one file, deletes ~6 duplicated lines. Closes the nRF watchdog gap *structurally*; gives Phases 6/7 **one** wiring point; makes the supervisor fire *during* a long `idleDelay` — which an nRF-arm-only addition cannot, since that arm doesn't run while blocked. **Risk LOW-MEDIUM**, entirely from H4: teardown on the loop task can race the Callback task, so gate the watchdog arm on `g_commandInFlight == 0` and take `pwrmgmLock`. Keep the tick `millis()`-only (it also runs in the ESP32 post-wake window, [:396](../src/main.cpp)). + +**C — nRF adopts the queue model.** ~150 LOC across `main.cpp/.h`, `ble_init.cpp`, `device_control.cpp`, `communication.cpp`, `touch_input.cpp`, `structs.h`. Benefit: eliminates every nRF cross-task hazard — `pwrmgmLock` could become an assert, H4 unnecessary, Phase 3 single-task on both targets, §2.1 race gone. Cost: §3.2–3.6, all throughput/timing-sensitive and **unverifiable without hardware**. **Risk HIGH. Reject.** + +**D — targeted additions only.** ~15 LOC in `main.cpp` + `device_control.cpp`. Same H4 caveat. Weakness: the additions sit in the nRF arm body, which doesn't run while `idleDelay` blocks (up to 65 s), and reintroduce the copy-paste that produced the gap. **Strictly dominated by B; fuse into B.** + +**E — shrink the ESP32 arm instead** (extract `serviceBleQueues`/`serviceWiFiLink`/`serviceSleepPolicy`/`servicePostWakeWindow`, ~120 LOC of pure motion, no behaviour delta). Readability only, and pure motion here makes the freeze-proofing diff unreviewable. **Do it after the plan ships.** + +## 5. Interaction with the freeze-proofing plan + +The plan already concedes the problem: *"nRF has NO transfer watchdog today — H3 is 'keep' on ESP32 but 'ADD' on nRF … Neither the original plan nor the adversarial review caught this."* B is the cheapest structural answer. + +| Plan item | Under B | Under C | Under D | +|---|---|---|---| +| **H4** depth counter | **Still required and becomes a hard prerequisite** (B moves teardown onto the nRF loop task) | Would become unnecessary — only after C's full deferral discipline, which is the risky part | Same as B | +| **Phase 3** `abortToKnownState` cross-task safety | Simpler: one call site, one rule ("only when depth == 0") | Simplest in principle, highest cost to reach | Two call sites; per-target reasoning persists | +| **Phase 5 H4** deferred nRF session clear | Unchanged; flag serviced from the tick, which now runs in both arms | Folds into the general deferral | Unchanged | +| **Phase 6** supervisor | **Clearly simpler** — the "must be wired into the nRF path" requirement is satisfied structurally, and coverage extends into `idleDelay` | Simpler still, at C's cost | Two bodies to keep in sync — the exact failure mode that produced the gap | +| **Phase 6 `[X5]`** | Unaffected | Unaffected | Unaffected | +| **Phase 7** BLE idle timeout | Slightly simpler (check belongs in the tick). Note the `[C1]` stamp stays in `imageDataWritten`, i.e. on the Callback task on nRF → must be `volatile uint32_t`, comparison must tolerate a concurrent write | Simpler (single task) | Two arms again | +| **Phases 1/2** | Orthogonal | Orthogonal | Orthogonal | + +**Sequencing:** (1) Phases 1 and 2 unchanged — highest value-per-risk. (2) **H4 on its own**, before anything drives teardown from the nRF loop task. (3) **Option B here**, as a pure refactor + two watchdog additions, in its own commit. (4) Phases 3/5/6/7 wire into `serviceSupervisoryTick()` instead of into two arms. Do **not** land B before H4, and do **not** fold B into a phase commit. + +## 6. Risk + +| Risk | Under B | Under C | +|---|---|---| +| Transfer throughput | Nil (dispatch untouched) | **High** — ~78 KB/s ceiling unless `idleDelay` is rebuilt | +| Command latency | Nil | ≤100 ms/frame + ≤100 ms/ACK | +| Teardown racing a live transfer | **Main risk** — mitigated by H4 gate + `pwrmgmLock` | Eliminated | +| Deep sleep | nRF has none; ESP32 exposure via `idleDelay` ([:396](../src/main.cpp)) — keep tick `millis()`-only | Same ESP32 exposure | +| DFU | Untouched | Bootloader jump moves to loop task — plausible, unproven | +| Power draw | Negligible | Unknown; more task switches | + +**Test coverage today: none.** `find . -name "*test*"` outside `.pio` yields only `tools/test_zlib_stream.c` (host-side zlib harness). CI (`.github/workflows/main.yaml:12-44`) is **build-only** across 11 envs. Green CI proves they link, nothing more. + +**Mandatory hardware validation for B:** (a) nRF stalled direct write → watchdog fires, panel rail drops; (b) same for a stalled `0x76` partial; (c) full Spectra push with 60 s refresh → tick does *not* tear down mid-refresh (`epdRefreshInProgress`/`pwrmgmLock` gating); (d) ESP32 battery unit → post-wake window and idle-hold timings unchanged. **For C additionally:** measured throughput before/after, sniffer trace for dropped `0x0081` at W=32, heap-pressure soak exercising the inline-fallback path, DFU-from-loop-task. + +**Cannot be validated without hardware:** all of the above, plus SoftDevice ATT auto-response behaviour, inline-fallback frequency under heap pressure, `notify()` from the loop task, and every panel-rail timing interaction. + +## 7. Historical evidence + +Both deliberate and drift, in sequence — **no evidence of a reverted convergence attempt**. + +The original nRF-only firmware already used *deferral*: `git show df6d088:src/main.cpp` shows `loop()` draining a `currentImage.ready` flag set by the BLE callback. The split was born with ESP32 support in `955f2d0` *("Add ESP32-C6 and ESP32-C3 support")*, whose first `#ifdef TARGET_ESP32` in `loop()` states the rationale outright: `// Process queued commands outside of callback context`. That is a platform statement — Bluedroid/NimBLE has no `ada_callback` equivalent; nRF didn't need a ring because Bluefruit already provides one. + +**Everything since is drift.** `git log -L 518,530:src/main.cpp` shows the nRF arm changed exactly **three** times ever: `7ffea91` (added `ble_nrf_advertising_tick()`), `aad0c6d` *("Drop per-iteration 'Loop end' debug log on nRF")*, `5ed21a9` *("feat: quarter-tone musical buzzer…")* (added `buzzerService()`). Over the same period the ESP32 arm absorbed deep sleep (`ee98b65`, `d974f9d`, `c377dec`), the watchdogs (`abcf95d` *"Add stuck-state watchdog for abandoned partial writes (panel rail)"*), WiFi/LAN (`2e2131b`), the NimBLE migration (`d4da951`) and the pipe drain (`a628d84`). **The nRF arm did not diverge; it stood still while the ESP32 arm grew** — which is why the missing watchdog is a gap, not a decision. + +One near-miss: the unmerged branch `feat/esp32-freertos-command-queue` (single commit `43b0799`, never merged): *"Swap the volatile-index SPSC command ring for a static FreeRTOS queue (`xQueueCreateStatic`), fixing cross-core memory-ordering races and the silent drop-on-overflow. … **ESP32-only; the processing pipeline (`imageDataWritten`) and the nRF/WiFi paths are unchanged.**"* The last time anyone touched this machinery the scope was drawn deliberately at the ESP32 boundary — corroboration for keeping the execution models separate. + +## 8. What would have to be true + +| # | Assumption | Check | If false | +|---|---|---|---| +| 1 | Bluefruit dispatches writes on a separate FIFO task with an elastic queue | `AdaCallback.c:76-99, 145-147` **[LIB]** (done); on hardware log `uxQueueMessagesWaiting`/free heap during a full-window push | C's cost/benefit flips; re-evaluate C | +| 2 | Callback task outranks loop (`NORMAL` vs `LOW`) | `AdaCallback.c:147`, `cores/nRF5/main.cpp:88` **[LIB]** (done); corroborated by [display_service.cpp:401-407](../src/display_service.cpp) | `pwrmgmLockTake`'s inversion mitigation is over-engineered but harmless | +| 3 | ~11 KB extra `.bss` affordable on nRF | Measured: 42 772/237 568 B, 192 748 B heap | Only matters for C | +| 4 | Watchdogs safe from the nRF loop task once `g_commandInFlight == 0` | Inspection says yes; **must be confirmed on hardware** with a stalled transfer + concurrent traffic | B degrades to "check and log only"; Phase 6 supervisor owns teardown | +| 5 | `millis()`-only additions to `idleDelay` don't perturb ESP32 deep sleep | Battery soak, compare wake-window/idle-hold before/after | Call the tick from `loop()` only (loses long-block coverage) | +| 6 | SoftDevice, not the app callback, generates the ATT write response | Hardware: client write-with-response latency vs firmware dispatch time | Further argument against C; no impact on B | +| 7 | No client depends on nRF's sub-ms dispatch latency | py-opendisplay timeouts; full-panel timing run | Only matters for C | + +## 9. Do not do this + +1. **Do not move nRF dispatch to the loop task without also deferring `disconnect_callback`.** They are FIFO-ordered today **[LIB]** (`bluefruit.cpp:849`); split them and a disconnect tears down `pipeState`/`partialCtx`/`directWriteActive` ([device_control.cpp:237-239](../src/device_control.cpp)) underneath queued frames — use-after-teardown on a live transfer, worse than any freeze this work targets. +2. **Do not add a response ring to nRF.** Buys nothing (§3.5), adds ≤100 ms to every pipe ACK, throttling the window. +3. **Do not copy `COMMAND_QUEUE_SIZE 33` to nRF as-is.** Usable depth is 32 ([esp32_ble_callbacks.h:122](../src/esp32_ble_callbacks.h)) = `PIPE_MAX_W`, no slot for the `0x0082` END — importing the `[H1]` off-by-one onto a target that lacks it. +4. **Do not "simplify" `idleDelay` into a plain `delay()`.** It is the only servicing of buttons/touch/LED/keep-alive/buzzer during long waits on both targets ([:538-548](../src/main.cpp)) and keeps the ESP32 post-wake window responsive ([:391-396](../src/main.cpp)). +5. **Do not put I²C, SPI or advertising work in the shared tick.** It runs every 100 ms inside `idleDelay` on both targets, including the ESP32 deep-sleep advertising window. `millis()` comparisons and flag checks only. +6. **Do not add the nRF watchdogs before H4.** `cleanupDirectWriteState(true)` from the loop task while the Callback task is inside `handlePipeWriteData` is a new freeze mechanism dressed as a fix. +7. **Do not un-`#ifdef` the `transferActive()` touch gate "for symmetry" under B** ([touch_input.cpp:584-586](../src/touch_input.cpp)). Under B the transfer still runs on the Callback task, so the gate isn't needed — enabling it would kill touch on nRF for every transfer with no benefit. +8. **Do not restructure `loop()` and change behaviour in one commit.** Zero automated tests + build-only CI = unreviewable, unbisectable. +9. **Do not treat green CI as validation.** It links; it does not run. + +## 10. Uncertainty + +Timing, throughput and power figures are inference from source plus arithmetic — no nRF hardware was exercised; the ~78 KB/s is a computed ceiling, not a measurement. RAM numbers **are** measured but reflect section partitioning only; FreeRTOS stacks and Bluefruit's dynamic allocations come out of the 192.7 KB heap and were not runtime-profiled. Bluefruit internals were inspected at exactly three points (write dispatch, callback queue, connect/disconnect dispatch); the **frequency** of the inline-fallback path that `[H4]` depends on is unknowable from source. I did not evaluate whether Bluefruit's queue growth is bounded in practice — it doubles from the heap, trading a dropped frame for heap pressure, a different failure mode deserving its own investigation if nRF ever shows heap exhaustion during large pushes. Finally, "nRF is safe today" rests on the Callback task's FIFO ordering; §2.1 documents one place that ordering does not protect. There may be others — the BLE-adjacent call graph was checked, not every shared global. diff --git a/docs/FINDINGS_PHASE1_PLAN_REVIEW_2026-07-26.md b/docs/FINDINGS_PHASE1_PLAN_REVIEW_2026-07-26.md new file mode 100644 index 0000000..0169586 --- /dev/null +++ b/docs/FINDINGS_PHASE1_PLAN_REVIEW_2026-07-26.md @@ -0,0 +1,431 @@ +# Adversarial Review — PLAN_PHASE1_NONCE_REPLAY_2026-07-26 + +**Date:** 2026-07-26 · **Reviewing:** [`PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md`](PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md) (branch `debug/ble-hardening`) +**Scope:** `src/encryption.cpp`, `src/encryption_state.h`, their callers, and the two clients that +drive them (`py-opendisplay`, the web client `ble-common.js`) + +> Companion to [`FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md`](FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md). +> Where the two disagree, this document is the later and more specific analysis — in particular +> it **retracts** part of that review's `[M1]` (see `M2` and `L1`). + +**Independently re-verified after the review was produced** (not taken on trust): `C1`'s +selective-repair transmit site and `max_retx` formula; `H1`'s `IntegrityCheckError` path and the +pipe loop's single `except`; `H2`'s shared nonce space in `encryptResponse`; `H3`'s +unconditional ring write; `L3`'s arithmetic; `L5`'s 11-leg CI matrix. All confirmed as +described. + +--- + +## Verdict + +**Phase 1 is safe to ship alone — no new freeze mode, no new remote DoS, no regression on either +target — but not as written.** Two edits are mandatory (`C1`, `M1`), one is strongly recommended +(`H1`), two need a paragraph each (`H2`, `H3`). + +D1–D4 are real (three exactly as described, one overstated), the check/commit split is the +correct shape, and the sliding-bitmap state machine is correct as specified. `−496 B`, +`+1,536 B`, `PIPE_MAX_W` 32/16, `MAX_PTO = 3`, the `protocol.h:384` citation and the "one counter +burned per transmission" premise all check out. Three things do not: `C1`, `H1`, `H2`. + +**Findings: 1 Critical, 3 High, 3 Medium, 7 Low.** + +--- + +## CRITICAL + +### C1 — `OD_NONCE_FORWARD_CAP = 64` is derived from a bound that does not hold + +**Plan element:** Decision A — *"worst-case unseen run = `PIPE_MAX_W + MAX_PTO` = 32 + 3 = 35 … +This is a **hard bound, not an estimate**"* — plus Step 6, which instructs writing that sentence +into `communication.cpp` as the invariant future changes are checked against. + +**What the derivation assumes:** *"After the window is exhausted it blocks for an ACK; on timeout +it resends exactly one chunk (`_send(window_base)`), never another window."* Accurate for the +**PTO** path ([device.py:2715-2726](../../py-opendisplay/src/opendisplay/device.py)) and for +**new** sends (`:2688-2694`, gated on `(next_to_send - window_base) < window`). It ignores the +third transmit site. + +**The site it ignores** — selective repair, which spends no window credit at all: + +```python +device.py:2789-2796 + for m in missing: # oldest first + ... + if do_retx: + await _send(m) + pending_retx[m] = 0 + retx_count += 1 +``` + +`missing` is every hole below `highest_recv` (`device.py:2771-2772`), up to `W-1` entries. Each +hole is re-sent again every `PIPE_RETX_ACK_SPACING = 2` ACKs (`commands.py:99`, +`device.py:2782-2788`). The only cap on the total is +`max_retx = max(3*window, ceil(n * 0.5))` — **96** for `W = 32`, and far larger for a +multi-thousand-chunk upload (`device.py:2672`, `commands.py:96`). + +**And the client deliberately hoards ACKs to spend on those rounds:** + +``` +device.py:781 Passes ``drain_stale=False`` so queued sliding-window ACKs are preserved. +device.py:786 await self._conn.write_command(self._encrypt_frame(data), response=response, drain_stale=False) +``` + +So `B` backlogged ACKs, processed while the device is not consuming anything (mid-`bbepWaitBusy`, +or with the 33-slot command ring full — [esp32_ble_callbacks.h:119-129](../src/esp32_ble_callbacks.h), +which drops on the NimBLE host task *before* decrypt), buy `⌈B/2⌉` repair rounds of up to `H` +holes each. Firmware supplies the backlog: it ACKs every `ack_every` accepted frames +([display_service.cpp:2854](../src/display_service.cpp)) **and** once per `ack_every` +out-of-order arrivals plus one immediately when a gap opens (`:2875-2892`). + +**The arithmetic.** With `W` in flight, `H` holes and `N = ack_every`, the device emits about +`(W−H)/N` ACKs before it stalls, and the client can spend them on `⌈(W−H)/(2N)⌉` repair rounds: + +| `W` | `N` | worst `H` | repair transmissions | + PTO probes | total gap | +|---|---|---|---|---|---| +| 32 | 8 (py default, `device.py:497`) | 16 | 16 | 2 | ~18 | +| 32 | 4 (HA default, `const.py:51`) | 20 | 40 | 2 | ~42 | +| 32 | **2** | 16 | 16 × 4 = **64** | 2 | **66** ✗ | +| 32 | **1** | 16 | 16 × 8 = 128, capped at `max_retx` = 96 | — | **~96** ✗ | +| 16 | 4 (web client, `ble-common.js:38-39`) | 8 | 8 | 2 | ~10 | + +`N = 1` and `N = 2` are not hypothetical: `blocks_per_ack` is a Home Assistant options-flow field +with `min=1, max=32` (`config_flow.py:104-113`), `py-opendisplay` documents it as "1..32" +(`device.py:525`), firmware clamps only at the *top* +([display_service.cpp:2723-2725](../src/display_service.cpp)), and +[main.cpp:269](../src/main.cpp) explicitly sizes the response-flush path for *"small negotiated +ack_every (N_eff 1-2)"*. + +**Why it matters.** Not a freeze — after the D1 fix an over-cap gap drops frames and keeps the +session. It matters because (a) the plan instructs writing "35 < 64" into the source as a +permanent invariant, and (b) it converts a *recoverable* transfer into a failed one exactly in +the lossy conditions Phase 1 exists to survive: at cap 128 the device re-syncs when it drains; at +cap 64 it rejects and the client burns its remaining `max_retx` budget rediscovering that by +timeout. + +**Correction:** (1) set `OD_NONCE_FORWARD_CAP = 128` — under the bitmap the cap costs nothing, +and the DoS argument for keeping it tight does not survive `M2`; (2) re-derive from the client's +**retransmit budget** `max_retx = max(3·W, n/2)`, stating plainly that firmware cannot bound it +from its own constants; (3) write the *mechanism* into the Step 6 comment, not a number that a +`blocks_per_ack` change in another repo silently invalidates. + +--- + +## HIGH + +### H1 — Decision E's "the client recovers on its own" is false + +`decryptCommand` returning false produces an unencrypted 3-byte NACK for **every** rejection +reason: + +```c +communication.cpp:698-703 + if (!decryptCommand(...)) { + od_log_error("ERROR: Decryption failed"); + uint8_t response[] = {RESP_ACK, (uint8_t)(command & 0xFF), RESP_NACK}; + sendResponseUnencrypted(response, sizeof(response)); +``` + +`RESP_NACK = 0xFF` ([opendisplay_protocol.h:698](../include/opendisplay_protocol.h)). The +client's `_read` intercepts that shape before any pipe-frame classification: + +``` +device.py:833-838 + if len(raw) == 3 and raw[2] == 0xFF: + raise IntegrityCheckError(...) +``` + +and the pipe send loop's only `except` is `BLETimeoutError` (`device.py:2716`). +`IntegrityCheckError` propagates straight out of `_stream_pipe_chunks`; the upload fails on the +**first** out-of-window frame. There is no retransmit machinery to recover with — the frame the +client would have repaired is the one whose NACK aborted it. + +Phase 1's field benefit is real but narrower than claimed: the device stays clean and ready for +the *next* connection instead of answering `0xFE` to everything. The in-progress transfer still +dies. + +**Correction:** on `NONCE_OUT_OF_WINDOW` / `NONCE_REPLAY` for `0x0081`, send **nothing** (let +PTO/SACK treat it as the plain frame loss it is) or send the normal pipe ACK. Keep `RESP_NACK` +for tag failures. ~5 lines in `communication.cpp:698-703` behind a reason code out of +`nonceCheck`; it converts Phase 1 from "the device survives" into "the transfer survives". +Decision E's recorded wire change remains right for non-pipe opcodes. + +### H2 — Device and client share one nonce space: CCM keystream reuse (shipping defect, unaddressed) + +```c +encryption.cpp:158-174 +void getCurrentNonce(uint8_t* nonce) { + memcpy(nonce, encryptionSession.session_id, 8); + uint64_t counter = encryptionSession.nonce_counter; // starts at 0 (:210, :655) + for (int i = 0; i < 8; i++) nonce[8 + i] = (counter >> (56 - i * 8)) & 0xFF; +} +``` + +```python +crypto.py:92-113 +def get_nonce(session_id: bytes, counter: int) -> bytes: + return session_id + counter.to_bytes(8, "big") +... + ccm_nonce = nonce_full[3:] # 13 bytes +``` + +`encryptResponse` ([encryption.cpp:740-743](../src/encryption.cpp)) and `decryptCommand` +(`:704-705`) both take `nonce_full[3:16]`. Same `session_key`, same `session_id`, **no direction +separator**, both counters reset to 0 at session start (`encryption.cpp:210`/`:655`; +`device.py:735`; `ble-common.js:1259`). Response #*k* and command #*k* therefore encrypt under an +identical (key, nonce). CCM is CTR underneath: the keystream depends only on key and nonce, not +the AD (which does differ), so `C_resp ⊕ C_cmd = P_resp ⊕ P_cmd`. Responses are short and highly +predictable (`{RESP_ACK, cmd, status}`, pipe ACKs), so a passive eavesdropper recovers the +leading plaintext bytes of the matching command. No authenticity break, but a textbook +nonce-reuse confidentiality failure. + +**Correction:** the fix is a direction bit — a wire change of the same cost class as Decision E, +and correctly out of Phase 1 scope. What *is* in scope: **record it** alongside Decision E so one +future wire revision fixes both. Doing nothing and not writing it down is the only unacceptable +outcome, because Phase 1 is the change that makes a future reader believe this layer has been +audited. + +### H3 — D3 understated: the exemption lets an attacker flush the entire replay ring + +The core claim is verified ([encryption.cpp:136](../src/encryption.cpp)). What the plan misses is +that the accept path writes the ring unconditionally, *including* on the exempted `diff == 0` +re-accept: + +```c +encryption.cpp:152-154 + static uint8_t replay_window_index = 0; + encryptionSession.replay_window[replay_window_index] = nonce_counter; + replay_window_index = (replay_window_index + 1) % 64; +``` + +Replaying the highest-seen frame **64 times** overwrites all 64 slots with that one value, +evicting every genuine entry, while `last_seen_counter` never moves (`:149-151` is +`>`-conditional) so the ±32 check at `:131` still admits `[L−32, L]`. **Every one of the last 32 +genuine commands then becomes replayable** — config writes, power-off, buzzer, LED. + +Reachable on ESP32: `CONFIG_BT_NIMBLE_MAX_CONNECTIONS` is **3** (`sdkconfig.h:613`; the `-D…=1` +override is inert), the write callback does not discriminate conn handles +([esp32_ble_callbacks.h:119-129](../src/esp32_ble_callbacks.h)), and `isAuthenticated()` +(`:195-199`) is a global flag with no peer binding — a second central can write captured frames +into a live session. On nRF `Bluefruit.begin(1, 0)` ([ble_init.cpp:152](../src/ble_init.cpp)) +caps at one link. + +**Correction:** no design change (the bitmap closes it). Restate D3's consequence and add a +Step 5 hardware test: replay the final frame 64× then replay an *older* in-window counter — must +be `NONCE_REPLAY`. + +--- + +## MEDIUM + +### M1 — Step 2 and Decision D prescribe different deltas, and Step 2's is UB on attacker-controlled input + +Step 2: `diff = (int64_t)counter - (int64_t)last_seen_counter`. Decision D: *"compute the delta as +`(int64_t)(counter - last_seen)` rather than subtracting two casted `int64_t`s."* Not the same +expression; Step 2's is wrong. The 8 counter bytes are plaintext and parsed **before** tag +verification ([encryption.cpp:119-121](../src/encryption.cpp), called from `:691` ahead of +`aes_ccm_decrypt` at `:714`), so an unauthenticated attacker controls the delta. +`(int64_t)nonce_counter` for `≥ 2^63` is an out-of-range conversion and the subtraction overflows +— UB that the plan's own `-fsanitize=undefined` gate will flag against the expression Step 2 +mandates. Today's `:130` has exactly this construct. + +Decision D's form is not sufficient either: the Step 2 table uses `-diff` twice, and `-diff` is +UB when `diff == INT64_MIN` (reachable with `counter = last_seen + 2^63`). + +**Correction:** drop signed arithmetic entirely: + +```c +const uint64_t fwd = counter - last_seen; /* wraps; 0 when equal */ +const uint64_t back = last_seen - counter; /* wraps; fwd + back == 0 mod 2^64 */ +if (fwd == 0) return bit_test(bm, 0) ? NONCE_REPLAY : NONCE_OK; +if (fwd <= OD_NONCE_FORWARD_CAP) return NONCE_OK; +if (back < OD_NONCE_BACKWARD_BITS) return bit_test(bm, back) ? NONCE_REPLAY : NONCE_OK; +return NONCE_OUT_OF_WINDOW; +``` + +Make Step 2's table match so `nonce_window.h` and the plan cannot diverge. + +### M2 — "Tight is correct here" rests on a jam-forward DoS that cannot happen + +Decision A inherits `[M1]` from the parent review. Under Phase 1's own design it does not hold: + +1. **A replay cannot push `last_seen_counter` past the client's high-water mark.** After the D2 + fix only a CCM-verified frame commits (Step 3), so the only counters an attacker can commit are + ones the client actually transmitted. No *future* client frame is ever below the window. +2. **Repairs carry fresh, higher counters.** `_encrypt_frame` increments on every transmission + including repairs (`device.py:747-760`, `:759`) — the plan cites this itself. The "stranded" + frames do not exist. + +The only frames a jam can strand are ones already in flight at lower counters, and those land in +the 127-wide **backward** window, where they are accepted as unseen. Net damage: zero. + +**Correction:** delete the "Why not more" paragraph; it is the only argument that produced 64 +instead of 128. Note that `[M1]`'s concern was an artifact of the value-ring design and does not +survive commit-after-verify + bitmap. + +### M3 — The `resetNonceState()` fold merges two blocks that are not equivalent + +| Field | `clearEncryptionSession` (`:205-217`) | `handleAuthenticate` (`:654-662`) | +|---|---|---| +| `nonce_counter` | `= 0` (`:210`) | `= 0` (`:655`) | +| `last_seen_counter` | `= 0` (`:211`) | `= 0` (`:656`) | +| `replay_window` | `memset` (`:217`) | `memset` (`:660`) | +| `integrity_failures` | `= 0` (`:212`) | `= 0` (`:657`) | +| `authenticated` | `= false` (`:209`) | `= true` (`:654`) | +| `session_start_time`/`last_activity` | `= 0` (`:213-214`) | `= currentTime` (`:658-659`) | +| keys/nonces/`ccm_ctx` | wiped (`:203-208`) | populated | +| `auth_attempts`, `server_nonce_time` | `= 0` (`:215-216`) | `server_nonce_time = 0` only (`:662`) | + +Only four fields are common; the rest are opposite. A fold worded as "bitmap + +`last_seen_counter`" silently drops `nonce_counter = 0` the first time someone tidies the +surrounding lines — and a device that keeps its outbound counter across a re-auth while the client +restarts at 0 walks into `H2`'s keystream reuse with *itself*. + +**Correction:** define `resetNonceState()` = +`{ nonce_counter = 0; last_seen_counter = 0; memset(replay_bitmap); integrity_failures = 0; }`, +naming all four fields. Leave every other field where it is. + +--- + +## LOW + +**L1 — D4 is not a live bug.** The facts are right (`replay_window_index` is a function static at +`:152`, reset by neither `:217` nor `:660`); the consequence is not. Both reset sites `memset` the +ring to **all zeros**, so writing from an arbitrary offset into a uniformly-empty 64-slot ring +with a +1 index produces exactly the same strict-FIFO eviction order as starting from 0. No +counter's accept/reject differs. The one real artifact is the `0`-as-empty sentinel: counter 0 can +never be accepted through the backward branch — masked today by D3's exemption, i.e. **D3 is +load-bearing for D4's representation**. Demote D4 to "latent"; keep the fix. + +**L2 — `nonceCommit()` placement is under-specified.** The success arm has an early return between +`:717` and `integrity_failures = 0`: + +```c +encryption.cpp:717-725 + if (success) { + uint8_t payload_length = decrypted_with_length[0]; + if (payload_length > encrypted_len - 1) { ...; return false; } // authentic frame + ... + encryptionSession.integrity_failures = 0; +``` + +If the commit lands after `:722`, an authentic-but-malformed frame is left replayable — and +today's code *does* commit it (`:149-153` is unconditional), so this is a behaviour change. Say +"first statement of the success arm, `:718`". + +**L3 — Decision B's storage table contradicts its own formula.** `2W × 8 B` = 16 B per unit ✓ for +the 32-row (512 B). Rows 2–3 are exactly 2× too large: 127 × 16 = **2,032 B** not 4,064; +255 × 16 = **4,080 B** not 8,160. Conclusion unaffected. + +**L4 — `PIPE_MAX_W + MAX_PTO` over-counts by one.** `MAX_PTO = 3` yields **two** probe sends: the +client increments then raises at the threshold before sending (`device.py:2721-2726`). 35 → 34 +(18 on `esp32-N4`). Conservative direction; moot under `C1`. + +**L5 — CI step runs 11×.** `.github/workflows/main.yaml` is one `build` job with an 11-entry +`matrix.environment` (`:10-23`). Use a separate top-level `host-tests` job. Confirmed safe: +`tools/test_nonce_window.cpp` is invisible to every firmware build — +`build_src_filter = +<*> -` (`platformio.ini:34`) is relative to the default +`src_dir` and no env adds `tools/`. + +**L6 — Line-reference drift.** The ring drop is `esp32_ble_callbacks.h:127-128` (`:126` is the +RELEASE store on the *success* path), not `:126-127`; the NACK is `communication.cpp:698-703`, not +`:700-701`. Everything else spot-checked is exact. + +**L7 — `NONCE_BAD_SESSION` silently stops being tamper evidence.** Today a `session_id` mismatch +counts (`:122-128` → `:691-696`); the plan routes it to "untouched". Probably right, but it is a +decision, not a consequence of D1. Also `:123-127` logs both full session IDs at `od_log_error` — +with counting removed an attacker can drive that line indefinitely. State the change; +demote/rate-limit the log as Step 4 already does for the out-of-window log. + +--- + +## Verified CORRECT — do not churn on these + +1. **D1 is real and is the wedge.** `:691-696` increments `integrity_failures` on *any* + `verifyNonceReplay` failure including plain packet loss, clearing at 3, after which + `communication.cpp:665-670` answers everything `RESP_AUTH_REQUIRED`. +2. **D2 is real.** `last_seen_counter` (`:149-151`) and the ring write (`:153`) both land before + `aes_ccm_decrypt` at `:714`. Step 3's "called from exactly one place" is achievable — + `decryptCommand` is the sole caller. +3. **D3 is real and exploitable** (`:136`). See `H3` — worse than the plan says. +4. **The bitmap state machine is correct as specified.** Worked by hand: fresh session + (`last_seen=0`, bitmap 0) accepts counter 0 exactly once via `fwd==0` + clear bit 0, needing no + `has_seen_counter`; forward commit by `d` moves old bit *i* (counter `L−i`) to bit `i+d`, which + under `L'=L+d` denotes `L−i` ✓ for d = 1, 63, 64, 65, 127; `d ≥ 128` clears wholesale and every + discarded counter is then ≥128 behind so it is rejected on width, never mis-reported unseen; + backward bit indices 1…127 valid with `back ≥ 128` rejected, so the backward window is exactly + `OD_NONCE_BACKWARD_BITS − 1 = 127` as stated; a forward cap < 128 means a legal slide never + needs a wholesale clear, so the two sides genuinely decouple, and the plan's claim that a cap + *wider* than the backward window would still be harmless is also true. Only the arithmetic form + (`M1`) and the cap value (`C1`) are wrong. +5. **"The ring is not buggy" — correct conclusion, off-by-one proof.** Attempts to construct a + sequence evicting an accepted counter `d` from the 64-slot ring while `d` is still inside ±32 + all fail. The tight count is `2W − 1 = 63`, not `2W`: all counters acceptable while `d` stays + in-window lie in `[L₀−32, d+32]`, and at least one member is provably already seen (the + `last_seen_counter` value preceding `d`, or — in the fresh-session corner — counter 0, blocked + by the zeroed ring's sentinel). 63 writes leave the index one short of `d`'s slot. So `D=64` is + sound **with exactly one slot of margin**, and the parent plan's 256/128 likewise. `D ≥ 2W` is + conservative and its conclusion holds; the real argument for the bitmap — non-obvious + combinatorics maintained by hand across two files — is strengthened by how tight the margin + actually is. +6. **Decision C is clean.** `verifyNonceReplay` has exactly three occurrences outside its + definition: `encryption.h:17`, `main.h:276`, and the sole call at `encryption.cpp:691`. Nothing + in `src/`, `tools/`, or the nRF build path references it. (`Firmware_NRF54` has its own private + copy at `opendisplay_pipe.c:427-455` — separate repo, unaffected, but it carries all four + defects and should be scheduled.) +7. **Decision D's factoring is sound and build-safe** — see `L5`. +8. **Sizing arithmetic.** 512 B → 16 B = **−496 B** ✓. 256-entry ring = 2,048 B → **+1,536 B** ✓. + No `platformio.ini` change; the `esp32-N4` link-headroom question is genuinely moot. +9. **Client premises.** One counter burned per transmission including repairs + (`device.py:747-760`) ✓; window blocks for an ACK and PTO resends exactly one chunk + (`:2688-2694`, `:2715-2726`) ✓; `MAX_PTO = 3` at `commands.py:93` ✓; `PIPE_MAX_W` 32 / + 16-on-`esp32-N4` (`structs.h:45-53`) ✓. Missing from the premise set: the SACK repair site + (`C1`). +10. **The `communication.cpp:772-775` invariant survives.** Commit still happens for every frame + that decrypts, including duplicates `handlePipeWriteData` discards. Only the name and ordering + clause need rewriting, as Step 6 says. +11. **Scope boundaries are right.** Leaving the 30 s auth-challenge window alone is correct (wire + contract at `include/opendisplay_protocol.h:384`, cannot wedge anything). Deferring + `session_timeout_seconds` and the link-drop guard to Phase 5 is correct, and the plan is + admirably explicit that Phase 1 closes only the nonce arm. +12. **No new failure mode on either target.** nRF has no command ring — `imageDataWritten` runs + inline on the Bluefruit callback task ([ble_init.cpp:157](../src/ble_init.cpp)), + single-threaded and in order — so its only gap source is air loss, which the link layer + repairs; a wider forward cap is inert there. On ESP32 nothing in Phase 1 touches the ring, the + loop watchdogs, or advertising. The LAN TLS path bypasses `decryptCommand` entirely + ([communication.cpp:663](../src/communication.cpp), origin-gated on `ORIGIN_LAN_TLS`) and is + untouched by every Phase 1 change; the plain-LAN path shares the one `encryptionSession` + exactly as today. `handleAuthenticate`'s re-auth path (`:583-585` → `clearEncryptionSession()` + → `:654-662`) resets both counters and the ring on both sides, unchanged in shape. + +--- + +## Summary of findings + +| # | Sev | Class | Finding | Correction | +|---|-----|-------|---------|-----------| +| C1 | Critical | plan | Forward cap 64 derived from a non-bound; SACK repair (`device.py:2792`) + preserved ACK backlog (`:781`) reach ~66 at `ack_every=2`, ~96 at `ack_every=1`, both user-selectable (`config_flow.py:104-113`) | Cap **128**; re-derive from `max_retx`; rewrite the Step 6 comment as a mechanism | +| H1 | High | plan | Decision E's "client recovers on its own" is false — 3-byte `0xFF` NACK raises `IntegrityCheckError` (`device.py:834`) uncaught by the pipe loop | Send no NACK (or a pipe ACK) for nonce rejections on `0x0081`; firmware-only | +| H2 | High | shipping | Device/client counters share one nonce space → CCM keystream reuse (`encryption.cpp:158-174` vs `crypto.py:92-113`) | Out of scope, but **record it** next to Decision E | +| H3 | High | shipping | D3 understated: exempted re-accept writes the ring (`:152-154`), so 64 replays flush it and unlock the last 32 counters | Restate D3; add the ring-flush hardware test | +| M1 | Medium | plan | Step 2 vs Decision D disagree; both, plus `-diff`, are UB on attacker-controlled counters | Two wrapping `uint64_t` deltas; no signed arithmetic | +| M2 | Medium | plan | "Tight is correct" rests on a jam-forward DoS that cannot occur post-D2 | Delete "Why not more"; it is the only argument against C1 | +| M3 | Medium | plan | `resetNonceState()` folds blocks sharing only four fields; drops `nonce_counter = 0` | Define the helper by naming all four fields | +| L1 | Low | plan | D4 is latent, not live | Demote the claim; keep the fix | +| L2 | Low | plan | `nonceCommit()` vs the `payload_length` early return at `:719-722` | "First statement of the success arm, `:718`" | +| L3 | Low | plan | Storage table rows 2–3 are 2× its own formula | 2,032 B and 4,080 B | +| L4 | Low | plan | `MAX_PTO=3` yields 2 probe sends | 34 / 18 (moot under C1) | +| L5 | Low | plan | CI step inside an 11-leg matrix | Separate `host-tests` job | +| L6 | Low | plan | Two line refs drift | Fix in place | +| L7 | Low | plan | `NONCE_BAD_SESSION` policy change unremarked; log attacker-drivable | State it; demote/rate-limit | + +## Is Phase 1 safe to implement as written? + +**Safe: yes.** No new freeze mode, no new remote DoS, no regression on either target. Every change +is confined to a path whose current behaviour is strictly worse. It is genuinely independent of +Phases 2–6 and genuinely deliverable alone. + +**As written: no.** Mandatory before implementation: `OD_NONCE_FORWARD_CAP = 128` with the +derivation replaced (`C1` + `M2`), and the unsigned delta form (`M1`) so the code and the UBSan +gate agree. One more (`H1`) decides whether Phase 1 saves the *transfer* or only the *device*; it +is five lines and should be in scope. `H2` and `H3` need a paragraph each, not code. With those, +Phase 1 is the right first change and should ship ahead of the rest of the program. diff --git a/docs/FINDINGS_PHASE3_PLAN_REVIEW_2026-07-26.md b/docs/FINDINGS_PHASE3_PLAN_REVIEW_2026-07-26.md new file mode 100644 index 0000000..901cf68 --- /dev/null +++ b/docs/FINDINGS_PHASE3_PLAN_REVIEW_2026-07-26.md @@ -0,0 +1,232 @@ +# Adversarial review — `PLAN_PHASE3_SESSION_GUARD_2026-07-26.md` + +Reviewed 2026-07-26 against `debug/ble-hardening` HEAD `ee43e19`. Every file:line below was read. + +## Verdict + +**Not safe to implement as written.** Research quality is high — citations are almost all accurate, the ESP32 single-task claim survives exhaustive checking, and D7's hazard analysis is largely sound. But six defects are load-bearing enough that following the plan literally produces a non-compiling tree or a teardown that violates the invariant D7 exists to protect. + +--- + +## Critical + +### `[C1]` `main.h` is a definitions header — every "declared in `main.h`" instruction is unbuildable ✅ **FIXED 2026-07-26** — plan §3a added (header-placement table); every call site corrected + +**Plan:** §4.1 "declared in `main.h`"; §7 "`session_guard.cpp` calls them through the `main.h` declarations" and "nRF stubs in `main.h`"; §8.4; §9.2 `nrfDisconnectCleanupPending`; D6c `linkIsUp()`. + +**Source:** `src/main.h` has no include guard and **defines** globals — `main.h:91` `BBEPDISP bbep;`, `:165` `bool directWriteActive = false;`, `:283` `chunked_write_state_t chunkedWriteState = {...};`, `:284` `globalConfig`, `:289` `EncryptionSession encryptionSession`, `:374-391` `responseQueue`/`commandQueue`/`pServer`/callback objects. + +``` +$ grep -rn '#include "main.h"' src/ +src/main.cpp:1:#include "main.h" +``` + +**Why wrong:** `communication.cpp`, `device_control.cpp` and a new `session_guard.cpp` cannot include it — second definition of every global ("multiple definition of `bbep`"), and on nRF it drags in `` plus NimBLE-aliased `BLE*` types the plan itself forbids in shared headers. Concretely: `communication.cpp` does not include `main.h` (it declares `extern chunked_write_state_t chunkedWriteState;` itself at `communication.cpp:83`), so §4.1's `resetChunkedWriteState()` would be declared where neither its own TU nor its caller can see it. §7's `static inline` nRF stubs are invisible to `session_guard.cpp`. §9.2's flag needs to be visible to both `device_control.cpp` (writer) and `main.cpp` (reader). + +**Correction:** `resetChunkedWriteState()` → `communication.h`. `flushCommandQueue`/`flushResponseQueue`/`linkIsUp`/`serviceLinkDrop`/`serviceDeferredPanelOff`/`nrfDisconnectCleanupPending` → `session_guard.h`, with ESP32 and nRF bodies both out-of-line in `main.cpp`. `session_guard.cpp` may include `structs.h`, `display_service.h`, `communication.h`, `encryption.h`, `od_log.h` — never `main.h`. + +### `[C2]` Step 4 cuts the panel rail before step 6 can defer it — the whole `epdStreamInProgress` design is bypassed + +**Plan:** §8.3 step 4 runs `cleanupDirectWriteState(true)` + `cleanupPartialWriteOnDisconnect()`; step 6 then declares *"HARD INVARIANT: … an in-flight controller stream is never cut mid-write (D7 hazard 1)"* and defers behind `!epdRefreshInProgress && !epdStreamInProgress`. + +**Source:** +```c +// display_service.cpp:2028-2031 (cleanupDirectWriteState) + if (pwrmgmState == PWR_ACTIVE) { + if (refreshDisplay) epdSessionForceOff(); + else epdSessionRelease(true); + } +``` +```c +// display_service.cpp, cleanup_partial_write_state() + bool teardown = partialCtx.active && pwrmgmState == PWR_ACTIVE; + memset(&partialCtx, 0, sizeof(partialCtx)); + if (teardown) epdSessionForceOff(); +``` +`epdSessionForceOff()` (`:512-516`) → `epdSessionForceOffLocked()` (`:418-434`) → `bbepSleep` + `delay(50)` + `pwrmgm(false)` → `SPI.end()` + rail LOW (`main.cpp:759-770`). + +**Why wrong:** During any wedged transfer the panel is `PWR_ACTIVE` by construction (`epdSessionAcquire` sets it at `:443`/`:467`). Step 4 therefore *always* cuts the rail mid-stream, several statements before step 6 evaluates `epdStreamInProgress`. Step 6 then finds `PWR_OFF`, `epdSessionForceOffLocked` early-returns at `:419`, and `epdForceOffPending` is never needed. D7's "close it structurally, at the panel" accommodation protects a path that never runs — including the exact nRF double-handler case it was written for. + +**Correction:** Move the deferral *below* `epdSessionForceOff()`: either (a) guard inside it — `if (epdRefreshInProgress || epdStreamInProgress) { epdForceOffPending = true; return; }` — one place, covers `cleanupDirectWriteState`, `cleanup_partial_write_state`, `sendPipeNack`, `epdSessionTick`; or (b) give the two cleanup functions a bookkeeping-only mode and leave all panel power to step 6. Either way §8.3's printed ordering is wrong. + +### `[C3]` "Two choke points cover ALL controller writes" is false + +**Plan:** D7 — set/clear around `pipeConsumePayload()` and "the legacy 0x0071 data handler", *"so one flag covers pipe, partial, compressed, legacy, FastEPD and E1004."* + +**Enumerated uncovered controller writes reachable from a command handler:** + +| Path | Site | Reached from | +|---|---|---| +| `partial_prepare_panel_ram()` → `bbepFill(&bbep, BBEP_WHITE, PLANE_1/PLANE_0)` — two **whole-plane** SPI writes | `display_service.cpp:3271-3272` | `handlePartialWriteStart` (0x0076) `:2252`; `handlePipeWriteStart` partial arm `:2792` | +| `partial_consume_bytes()` on the **inline initial payload** of a 0x0076 START | `:2258` | `handlePartialWriteStart` | +| `directWriteActivatePanel()` → `bbepSetAddrWindow` + `bbepStartWrite`; `e1004_begin_plane()` issues `bbepWriteData` (`:314-337`) | `:2092-2100` | `handleDirectWriteStart` (0x0070), `handlePipeWriteStart` full arm `:2807` | +| `zlib_stream_to_partial_write(nullptr,0,true)` final flush writes residual bytes | `:2339` | `handleDirectWriteEnd` (0x0072/0x0082) | +| `partial_write_stream_bytes()` → `partial_set_addr_window` + `bbepStartWrite` + `bbepWriteData` | `:3215-3223` | reachable from `:2258`, outside both choke points | + +`bbepFill` on a 1200×1600 panel is hundreds of KB of SPI. This is not a corner case — it is the entire 0x0076 family and the `PIPE_FLAG_PARTIAL` bring-up. + +**Correction:** Stop chasing call sites. One opcode-keyed pair in `imageDataWritten`'s switch covering `CMD_DIRECT_WRITE_{START,DATA,END}`, `CMD_PARTIAL_WRITE_START`, `CMD_PIPE_WRITE_{START,DATA,END}`. Provably exhaustive, no early-return exposure — which also answers the plan's unaddressed question about `pipeConsumePayload`'s `return true` at `:2601` and three `return false` sites (`:2607`, `:2612`) that a naive wrapper would leak the flag on. + +### `[C4]` The teardown powers down a WARM panel on every disconnect — D1b is not a "strict superset" + +**Plan:** §1/D1 *"a strict superset of what those sites do today"*; §9.1 *"All are either no-ops or strictly correct on a link that just went away."* + +**Source — the invariant is documented twice:** +```c +// main.cpp:339-342 +// ACTIVE-only-teardown invariant: a WARM (post-successful-refresh) panel +// SURVIVES disconnect and keeps its keep-alive window … +``` +```c +// device_control.cpp:232-236 +// … a reconnect within the window pays only a warm re-acquire. +``` +`epdSessionForceOffLocked` early-returns **only** on `PWR_OFF` (`:419`); on `PWR_WARM` it runs the full teardown. + +**Why wrong:** Both existing teardowns achieve ACTIVE-only semantics precisely *by never calling `epdSessionForceOff()` directly*. `abortToKnownState` step 6 calls it unconditionally. So the healthy path — push image, refresh succeeds (→ `PWR_WARM` with armed deadline, `:504-505`), disconnect — now powers the panel down, and the next push pays the ~900 ms cold rail bring-up (`main.cpp:721`) plus `bbepInitIO`/`bbepWakeUp`/init-sequence instead of a warm re-acquire. Fires on **every** disconnect. + +**Correction:** +```c + if (pwrmgmState == PWR_ACTIVE) { // ACTIVE-only-teardown invariant + if (!epdRefreshInProgress && !epdStreamInProgress) epdSessionForceOff(); + else epdForceOffPending = true; + } +``` +Add a §12 case: connect, push, disconnect, reconnect inside `screen_timeout_seconds` → log must show `acquire: WARM re-acquire`, not `COLD bring-up`. + +### `[C5]` The §8.3 log line does not compile + +`display_service.cpp:556` `static PartialStreamContext partialCtx = {};` and `:575` `static PipeWriteState pipeState = {};` are file-static; `display_service.h` exposes only `transferActive()` (`:70`), `epdRefreshInProgress` (`:77`) and the three cleanup entry points. The step-1 log line reads `pipeState.active` and `partialCtx.active`. + +**Correction:** add `bool pipeWriteActive(void)` / `bool partialWriteActive(void)` next to `transferActive()`. (`directWriteActive` and `chunkedWriteState` are fine via `extern`, as `communication.cpp:83` / `esp32_ble_callbacks.h:43` already do.) + +### `[C6]` `commandDrainAbortPending` latches outside a drain → one command dispatched twice ✅ **FIXED 2026-07-26** — plan §5 now clears the flag at the top of the drain block; regression test added to §12 + +**Plan:** §7 sets the flag unconditionally in `flushCommandQueue()`; §5 consumes it with `flag=false; break;` without the tail store. + +**Source:** D1b calls `abortToKnownState` from `serviceBleDisconnectCleanup()`, which runs at `main.cpp:370` (deep-sleep-wake branch, **before** the drain, then `return`s) and `:428` (**after** the drain). The plan itself says so in §12. + +**Why wrong:** With a non-empty ring at that moment, the flag is set and nothing consumes it this pass. Next pass the drain (1) dispatches `commandQueue[tail]`, (2) sees the flag, clears it, `break`s **without storing the tail**, (3) next pass dispatches the same slot again. For `CMD_CONFIG_WRITE`, `CMD_POWER_OFF`, `CMD_DEEP_SLEEP`, `CMD_REBOOT` that is not benign. A *new* bug shipped in the same commit as the `[M5]` fix. + +**Correction:** `commandDrainAbortPending = false;` at the top of the drain block before the `while`, or scope the flag to an active drain via a `commandDrainActive` flag. Secondary: when called from inside the drain, `dropped` over-counts by one (tail not yet advanced) — cosmetic, worth a comment. + +--- + +## High + +**`[H1]` The deferred scrub can zero a *fresh* session's nonce.** `handleAuthenticate` is dispatched **before** the auth gate (`communication.cpp:649-652`; gate at `:663-669`), and step 1 writes exactly what the scrub erases (`encryption.cpp:586-587`: `secure_random(pending_server_nonce,16)`). On nRF: handler A clears at depth≥1 → `sessionScrubPending`; A returns; a new `CMD_AUTHENTICATE` step 1 arrives on the callback task; `loop()` samples depth 0 and memsets `pending_server_nonce` → step 2's CMAC fails → `AUTH_STATUS_ERROR` on reconnect. D7's *"`isAuthenticated()` is already false, so no new command can use the key"* does not close this, because authenticate bypasses that gate by design. **Fix:** generation counter, or `if (sessionScrubPending && g_commandInFlight==0 && !authenticated && server_nonce_time==0)`. Also state the answer D7 only asks: `ccm_session_free` is never deferred (ESP32-only, `encryption.cpp:202`, and `sessionScrubIsSafeNow()` returns `true` there). + +**`[H2]` `touchForceResumeAll()` skips the real resume work.** `touchResumeAfterEpdRefresh()` (`touch_input.cpp:417-432+`) is not just a decrement — at zero it runs `invalidateOpenDisplayWire()`, `delay(GT911_POST_RESET_SETTLE_MS)`, and per-controller re-init + INT re-attach. Zeroing the counter re-enables polling (`:584`) against a controller never re-initialised after `pwrmgm(false)`'s `Wire.end()` (`main.cpp:764-766`). §12's criterion *"touch responds again (`s_epd_refresh_suspend == 0`)"* checks the very proxy that will read zero while touch is dead. **Fix:** `if (s_epd_refresh_suspend) { s_epd_refresh_suspend = 1; touchResumeAfterEpdRefresh(); }` — note this makes the helper blocking, which must be stated; and test a real touch event. + +**`[H3]` nRF's `loop()` cannot service the new deferrals promptly.** The nRF arm (`main.cpp:518-530`) opens with `idleDelay(globalConfig.power_option.sleep_timeout_ms)`, which blocks for the full duration in 100 ms chunks servicing only buttons/touch/LED/`epdSessionTick`/buzzer (`:535-551`). `serviceBleDisconnectCleanup()` is inside `#ifdef TARGET_ESP32` (`:191`–`:349`) and called only at `:370`/`:428` — there is no nRF analogue to sit "next to". Teardown latency regresses from immediate to `sleep_timeout_ms`, with the rail up mid-transfer, touch suspended, and key material + `epdForceOffPending` unserviced. **Fix:** put the service calls in `loop()`'s shared prologue (`:352-354`) **and** in `idleDelay()`'s body — the precedent `epdSessionTick()` already sets at `:545`. + +**`[H4]` The `g_commandInFlight == 0` caller check is a sampled TOCTOU.** §9.2 claims it *"guarantees no `imageDataWritten` is on the stack"*. On nRF the counter is written by the Bluefruit Callback task and read by `loop()` with no mutual exclusion; a write callback firing one instruction later re-enters. D7 says the right thing three sections later (*"defence in depth rather than the only defence"*) — §9.2 contradicts it, and that sentence is what justifies deleting Phase 5's `nrfSessionClearPending`. **Fix:** reword; let Phase 5 re-decide. + +**`[H5]` Phase 2 assigns `panelStateUnknown` to Phase 3; Phase 3 never wires it.** Phase 2 states: *"Consumed: → Phase 3. `abortToKnownState()` reports it and skips `epdSessionForceOff()` when set (retrying a lock that just timed out costs another 60 s inside the abort path)."* Phase 3's §8.3 log line omits it and step 6 has no test. After Phase 2, `epdSessionForceOff()` (`:513`) takes a lock with a 60 s deadline — so the recovery path can block `loop()` for 60 s on every disconnect. Also: Phase 2's **D-A and D-B are listed as blocking and unresolved**, and D-B's alternative is literally *"defer the whole flag to Phase 3"*. **Fix:** wire it into the log + step 6; settle Phase 2 D-A/D-B first. + +**`[H6]` A deferred abort can fire after a reconnect.** nRF re-advertises autonomously (`ble_nrf_advertising_tick`, `main.cpp:526` and inside `idleDelay` at `:540`); on ESP32 the deep-sleep-wake branch does cleanup at `:370` then restart at `:371-373` then `return`s. §9.1 waves `esp32-N4` through on the grounds that no LAN path raises the flag spuriously — correct, but it does not address a *genuine* disconnect serviced after a reconnect, and `esp32-N4` has no `ownerStillUp` guard at all (`main.cpp:328-338` is inside `#ifdef OPENDISPLAY_HAS_WIFI`). D1b widens the blast radius to session clear, chunked state, buzzer, LED and panel. **Fix:** connection generation, or an `!linkIsUp()` test at the service point (reuses D6c's new predicate). + +**`[H7]` Stopping buzzer/LED on every disconnect is a client-observable change.** "Beep/flash and disconnect" is the natural pattern for 0x0077/0x0073 — neither has a completion notification, and the parent plan records LED flash as deliberately unbounded and accepted. §12's own criterion says *"any behavioural difference visible to the client is a constraint violation, not a Phase 3 feature."* §2 lists this change in neither the in-bounds nor out-of-bounds table. **Fix:** restrict to `dropLink=true`, or get an explicit decision recorded in §2. + +--- + +## Medium + +**`[M1]`** "`cleanupDirectWriteState` no-ops when `!directWriteActive`" (§8.3 Idempotence) is false — `display_service.cpp:2010-2039` has no such guard; it zeroes eleven globals, force-offs on `PWR_ACTIVE`, calls `e1004_end_plane()`, resumes touch. That is why the ESP32 site guards the *call* (`main.cpp:343`) and nRF does not (`device_control.cpp:237`). The claim is what `[C2]` and `[C4]` both depend on being false. Re-derive step 4's ordering rationale: for a partial transfer the *first* call is what powers the panel down, and `cleanup_partial_write_state`'s own predicate then evaluates false — same end state, opposite reason to the one given. + +**`[M2]`** §8.3's "1. LOG FIRST" contradicts the D7 guard snippet showing the atomic test-and-set first. Guard must be first. Separately, `__atomic_store_n(&inAbort,0,RELEASE)` appears once at the end; `[H5]` adds an early return and Phase 2 makes panel calls failable — one skipped release latches `inAbort=1` and **permanently disables recovery**. Mandate single-exit `goto done` or RAII. + +**`[M3]`** §8.1's header surface omits 11 symbols used elsewhere in the plan: `odLinkDropRequest`, `g_linkDropPending`, `linkReleaseIfHeld`, `sessionScrubPending`, `sessionScrubIsSafeNow`/`sessionScrubNow`, `nrfDisconnectCleanupPending`, `serviceDeferredPanelOff`, `serviceLinkDrop`, `linkIsUp`, `epdStreamInProgress`, `panelStateUnknown`. + +**`[M4]`** Phase 1 deletes `replay_window[64]` for a bitmap (net −480 B) and rewrites `clearEncryptionSession()`; Phase 3's D7 split reproduces the pre-Phase-1 field list verbatim → guaranteed conflict in the phase's most safety-critical function. §10's *"gate for Phase 1's `replay_window[256]`"* is stale. Rebase the split; state which half the bitmap reset lands in. + +**`[M5]`** Nothing resets `sessionOrigin` (`display_service.cpp:2114`, set only at `:2129`/`:2170`/`:2638`). After aborting a LAN transfer it stays LAN, so the ESP32 guard's own input (`main.cpp:329`: `transferSessionOrigin() != 0`) is stale — a later BLE-only disconnect is classified LAN-owned and, with a LAN client up, the **BLE teardown is silently skipped**. Squarely in the "known state" remit. + +--- + +## Low + +- `[L1]` `pending` removal saves 2 B/slot (alignment: 260→258), so −86 B not −43 B; direction holds. §10 omits `nrfDisconnectCleanupPending` and counts `epdStreamInProgress` against `session_guard` when D7 puts it in `display_service.cpp`. +- `[L2]` `flushResponseQueue()` (discards) vs existing `flushResponseQueueToBle()` (`main.cpp:275`, *sends*) — one keystroke apart, opposite effects, same file. Rename. +- `[L3]` §12's `grep -n 'pending' src/` always matches `bleDisconnectCleanupPending`, `msdUpdatePending`, `bleRestartAdvertisingPending`, `buttonEventPending` and every new Phase 3 flag. Use `grep -rn '\.pending' src/`. +- `[L4]` §12's `grep -nE '^\+.*(RESP_|CMD_)[A-Z_]+ *='` looks for assignments; opcodes are `#define`s in the vendored header. The two `git diff --stat` header checks are the real guard and are correct. +- `[L5]` §9.1 cites `main.cpp:447` for the WiFi-lost tick calling `disconnectWiFiServer()` — `:447` is `handleWiFiServer()`; the call is at `:456` (and `wifi_service.cpp:873`). +- `[L6]` `od_log_*` carries `format(printf,2,3)` (`od_log.h:30`); codebase convention is explicit `(unsigned)` casts (`main.cpp:439`, `:497`). §7's `%u` with `uint8_t` should match. +- `[L7]` §8.3.1's "next to `serviceBleDisconnectCleanup()`" is an orphan — that function is ESP32-only. See `[H3]`. +- `[L8]` §4.1's note is right (`communication.cpp:574-576` clears 3 of 5 fields) but understated: `:550` and `:558` clear only `active`, so the consolidation *changes their behaviour*. That is a behaviour change, not a pure refactor. + +--- + +## Gaps missed entirely + +- `[X1]` `abortToKnownState()` returns `void` and cannot report failure. After Phase 2 every panel op is failable and the lock can time out; §12's *"panel rail down"* / *"all state flags false"* criteria assume success. Give it a `bool` or status flag for Phase 6. +- `[X2]` No zlib streamer reset. `od_zlib_stream_reset` runs only at START (`:2102`, `:2253`, `:2793`); after an abort the inflater keeps its state. Benign today (START always resets) but a "known state" gap. +- `[X3]` Partial transfers never suspend touch — `directWriteTouchSuspended` is set only on the full-frame paths (`:2136-2137`, `:2800-2801`), and the partial bring-up at `:2785-2794` deliberately skips it. `[M3]`'s analysis reasons only about full-frame; say this explicitly, because `[H2]`'s fix makes the helper do real I2C work. +- `[X4]` `epdPlanesPrepared`/`epdSessionInitWasPartial` (`:365`, `:369`) are not reset; `epdSessionForceOffLocked` clears the former at `:433`, so the deferred-force-off path leaves it stale until `serviceDeferredPanelOff()` completes. +- `[X5]` §2's flush justification via SACK retransmit (`pipe-write-protocol.md` §5.2 — verified, `:373-380`) applies only to `0x0081` DATA. A flushed `0x0082` END, `0x0041`, or `0x0050` has no retransmit path. Same shape as today's ring-full drop, so the constraint argument survives — but "no new behaviour" needs the qualifier now that the flush is deliberate. + +--- + +## Verified correct — do not re-check + +1. **ESP32 has no cross-task command-handler execution.** Exhaustive: `imageDataWritten` is reached from exactly three places — `main.cpp:415` (loop drain), `wifi_service.cpp:978` (LAN dispatch inside `handleWiFiServer()`, called from `loop()` at `main.cpp:447`), and `ble_init.cpp:157` (**nRF/Bluefruit only**). `onWrite` (`esp32_ble_callbacks.h:81-135`) only memcpys and stores the head; `onConnect`/`onDisconnect` (`:46-70`) are flag-only. No `xTaskCreate`/`esp_timer_create`/`xTimerCreate` in `src/` on ESP32 (`ble_init.cpp:104`'s `TimerHandle_t` is nRF). Both WiFi event handlers (`wifi_service.cpp:574`, `:643`) only set flags — the file carries an explicit "EVENT-CONTEXT RULE" comment at `:625` and everything is drained by `serviceWifiEventFollowUp()`. Button/touch ISRs (`device_control.cpp:679`, `:697`; `touch_input.cpp:175`) set bitmasks only. **D4's "document, no assert" and D7's "ESP32 callers need no depth check" are both correct.** +2. **The `[M5]` drain race is real and the fix placement is right.** `main.cpp:409-421`: `tail` cached at `:409`, dispatch at `:415`, `__atomic_store_n(...tail+1..., RELEASE)` at `:417` would overwrite a `tail := head` snapshot. Between `:415` and `:416`, breaking without the store, is correct. (`[C6]` is about the flag's lifetime, not the placement.) +3. **`pending` has no readers** — assignments only, at exactly the five cited sites: `esp32_ble_callbacks.h:125`, `communication.cpp:119`, `main.cpp:291`, `:309`, `:416`. +4. **`COMMAND_QUEUE_SIZE 33` is defined twice** — `main.h:371` and `esp32_ble_callbacks.h:19` (`#ifndef`); `main.h` defines at `:371` and includes the callbacks header at `:378`, so it wins. D5's Phase 7 warning is valid. +5. **The `main.h:365-370` capacity comment is wrong as `[H1]` says** — producer refuses at `nextHead == tail` (`esp32_ble_callbacks.h:121-122`), usable capacity 32. D5's replacement text is accurate. +6. **`EncryptionSession` has no origin field** (`encryption_state.h:11-30`). `g_commandOrigin` is per-dispatch (`communication.cpp:37`, set/restored at `wifi_service.cpp:976-979`); `sessionOrigin` tracks the transfer (`display_service.cpp:2114`). D6c's "genuinely unanswerable in Phase 3" is correct, and erring toward *not* clearing is the right direction. +7. **`wifiLanClientConnected()` behaves as claimed** — `wifi_service.cpp:355`. +8. **`isAuthenticated()` precedes `decryptCommand` on every decrypting path** — `communication.cpp:664` gates before `:698`. LAN-TLS bypasses the CCM envelope by design and never touches `session_key`, so clearing the session does not stop LAN-TLS execution — correct and pre-existing, worth one sentence in D6c. +9. **The invalidate/scrub field split is accurate** — `encryption.cpp:201-219`: racy half is `ccm_session_free` (`:202`) + five memsets (`:204-207`, `:217`); the eight scalar stores (`:208-216`) are coherent for a concurrent reader. The *premise* holds; `[H1]` is a defect in the *consequence*. +10. **`esp32-N4` is ESP32 without WiFi and without any LAN flag-setter** — `platformio.ini:284` defines only `TARGET_ESP32` + `PIPE_SMALL_DRAM_WINDOW`; `wifi_service.cpp:3` wraps the whole file in `#ifdef OPENDISPLAY_HAS_WIFI`. +11. **No self-deadlock on `pwrmgmLock`, no use-after-free in the teardown.** All four take/give pairs are function-local (`:439`/`:488`, `:496`/`:509`, `:513`/`:515`, `:520`/`:526`); the lock is never held across a return into handler code. `pipeState`, `pipeReorder`, `partialCtx`, `chunkedWriteState` are all static storage. D7's strongest ungating argument stands. +12. **All other spot-checked citations are accurate**: `display_service.cpp:2502-2504` (`transferActive`), `:2008`/`:2035-2038`, `communication.cpp:113`, `esp32_ble_callbacks.h:128`, `buzzer_control.cpp:147`, `device_control.cpp:341`, `structs.h:86`, `main.h:371`, `platformio.ini:284`. (`touch_input.cpp` counter is at `:64`; the plan cites `:115-119`, which is `touchSuspendForEpdRefresh` — close enough to be useful.) +13. **Wire-protocol constraint: Phase 3 as scoped does not violate it.** No canonical-header edits; D3 removes the only genuine candidate; both ring structs are firmware-local. `[H7]` is the one *client-observable* change and it is a behaviour question, not a header question. +14. **`pipe-write-protocol.md` §5.2 exists and says what §2 cites it for** (`:373-380`). + +--- + +## Phase interaction risks + +| Phase | Risk | Action | +|---|---|---| +| **1** | Phase 1 deletes `replay_window[64]` → bitmap and rewrites `clearEncryptionSession()`; Phase 3's D7 split targets the pre-Phase-1 field list. §10's "+1.5 KB `replay_window[256]`" is stale (Phase 1 is now −480 B). | `[M4]` — rebase before implementing. | +| **1** | D6c's gap argument (stale session bounded by out-of-window rejection + Phase 1's `counter_diff == 0` fix) is sound — verified at `encryption.cpp:136`. | Order Phase 1 → Phase 3 confirmed. | +| **2** | `pwrmgmLockTake` → bool + 60 s deadline makes `epdSessionForceOff()` failable **and blocking**; Phase 3 calls it from the loop task on every disconnect and ignores both. Phase 2 explicitly assigns `panelStateUnknown` to Phase 3; Phase 3 never wires it. | `[H5]`, `[X1]`. | +| **2** | Phase 2's **D-A** and **D-B** are blocking and unresolved; D-B's alternative is "defer the flag to Phase 3". | Settle both before Phase 3 starts; record in §1. | +| **2** | `[X2]` (boot-refresh `epdRefreshInProgress`) and `[X3]` (real `fastepd_wait_refresh`) are prerequisites for §8.3.1's "no timeout on this deferral". Boot suspends/resumes touch symmetrically (`:539`↔`:1640`, retry arm `:1627`) but sets no `epdRefreshInProgress`. | Keep the stated ordering. | +| **4** | §9.1's "keep `ownerStillUp` in front" is correct and verified (`main.cpp:328-338`, `wifi_service.cpp:812`, `main.cpp:456`) — but `[M5]`'s stale `sessionOrigin` corrupts the guard's own input, a bug Phase 4 inherits. | Reset `sessionOrigin` now. | +| **4** | D6c correctly defers owner-scoped `linkIsUp()` to Phase 4; `[H6]`'s reconnect race is also naturally fixed by the token, but not until then. | Add the interim `!linkIsUp()` service-point check. | +| **5** | §9.3 declares `nrfSessionClearPending` redundant on the strength of `[H4]`'s false guarantee. | Downgrade the claim; let Phase 5 re-decide. | +| **5** | D7 rightly puts Phase 5's in-function guard in the invalidate half; `[H1]` adds a generation-check requirement Phase 5's guard does not provide. | Land the generation check in Phase 3. | +| **5** | Phase 3 delivers the BLE-disconnect session clear one phase early with no backstop; §9.3 states the mitigation honestly and it is sound for Phase 3's own two callers. | Keep the "hand-check any new caller" warning. | +| **6** | D2's dead `g_lastProgressMs` is intentional and correctly documented; the six stamp sites in §8.2 match `[C1]`. | No action. | +| **6** | D7's "never gate" header comment is right; Phase 6's nRF supervisor must carry the (weakened, `[H4]`) depth term itself. | Already in D7 condition 2. | +| **7** | `[C6]`'s fix must not collide with Phase 7's `commandQueueOverflowAbort` policy; D5's two-files warning is accurate. | No action beyond `[C6]`. | + +--- + +## Required corrections + +| # | Sev | Correction | Section | +|---|---|---|---| +| ~~C1~~ | ~~Crit~~ | ✅ **DONE** — plan §3a header-placement table; all sites corrected. | §3a, §4.1, §7, §8.4, §9.2, §11, D6c | +| C2 | Crit | Move the refresh/stream deferral inside `epdSessionForceOff()`, or stop step 4 touching panel power. | §8.3 steps 4+6, D7 | +| C3 | Crit | Replace "two choke points" with one opcode-keyed pair in `imageDataWritten`. | D7, §11 step 9a | +| C4 | Crit | Gate step 6 on `pwrmgmState == PWR_ACTIVE`; add a WARM-survives-disconnect test. | §8.3 step 6, §9.1, D1 | +| C5 | Crit | Add `pipeWriteActive()`/`partialWriteActive()` accessors. | §8.3 step 1 | +| ~~C6~~ | ~~Crit~~ | ✅ **DONE** — cleared at top of drain block; alternative recorded; §12 test added. | §5, §7, §12 | +| H1 | High | Generation/handshake check on the deferred scrub; state that `ccm_session_free` is never deferred. | D7 hazard 2 | +| H2 | High | Route `touchForceResumeAll()` through `touchResumeAfterEpdRefresh()`; test a real touch event. | §4.2, §12 | +| H3 | High | Service new flags from `loop()`'s shared prologue **and** `idleDelay()`. | §8.3.1, §9.2 | +| H4 | High | Reword §9.2; re-check the `nrfSessionClearPending` deletion. | §9.2, §9.3 | +| H5 | High | Wire `panelStateUnknown`; settle Phase 2 D-A/D-B; give the abort a failure signal. | §8.3, §1 | +| H6 | High | Gate the deferred teardown on `!linkIsUp()` / a connection generation. | §9.1, §9.2 | +| H7 | High | Restrict buzzer/LED stop to `dropLink=true`, or record an explicit decision in §2. | §9.1, §12 | +| M1 | Med | Correct the `cleanupDirectWriteState` idempotence claim; re-derive step 4's rationale. | §8.3 | +| M2 | Med | Guard first, log second; single-exit so `inAbort` cannot latch. | §8.3, D7 | +| M3 | Med | Complete §8.1's header surface (11 symbols). | §8.1 | +| M4 | Med | Rebase the scrub split onto Phase 1's bitmap; fix the stale `replay_window[256]` ref. | D7, §10 | +| M5 | Med | Reset `sessionOrigin` in the teardown. | §8.3 step 4 | +| L1–L8 | Low | RAM arithmetic; rename `flushResponseQueue`; fix both §12 greps; `:447`→`:456`; `(unsigned)` casts; orphan cross-ref; note the `:550`/`:558` behaviour change. | §10, §7, §12, §9.1, §4.1 | +| X1–X5 | Gap | Abort failure signalling; zlib reset; partial-path touch note; `epdPlanesPrepared` window; qualify the flush's client-neutrality. | new | diff --git a/docs/IT8951_BBEPAPER_INTEGRATION_PLAN.md b/docs/IT8951_BBEPAPER_INTEGRATION_PLAN.md new file mode 100644 index 0000000..11a5134 --- /dev/null +++ b/docs/IT8951_BBEPAPER_INTEGRATION_PLAN.md @@ -0,0 +1,471 @@ +# IT8951 / ED103TC2 → bb_epaper Integration Plan + +Decision-support document. **Report only** — no code was modified to produce it. + +Scope: fold the Seeed_GFX-based IT8951 TCON path (10.3" **ED103TC2 1872×1404**, panel_ic +`OD_PANEL_IC_ED103TC2_1872X1404` = 3000 / `..._4GRAY` = 3001) into the primary **bb_epaper** +driver, so the vendored Seeed_GFX / TFT_eSPI fork can be dropped from the ESP32-S3 build. + +All line numbers are as of this investigation: +- bb_epaper checkout: `/.pio/libdeps/esp32-s3-N16R8/bb_epaper/` (identical across esp32-s3 envs) +- Seeed_GFX: `/home/davelee/opendisplay/Firmware/lib/Seeed_GFX/` +- Firmware glue: `/home/davelee/opendisplay/Firmware/src/` + +--- + +## A. Executive summary + +**Feasible, and a natural fit — recommended.** OpenDisplay uses Seeed_GFX as nothing more than +(a) an IT8951 SPI transport and (b) a raw framebuffer it `memcpy`s pre-dithered pixels into +(`src/display_seeed_gfx.cpp`). It touches **zero** TFT_eSPI drawing/text/sprite/font/touch code. +The IT8951 command surface OpenDisplay actually exercises is tiny: full-frame packed-pixel load + +one `DPY_AREA` GC16 refresh + init(VCOM)/sleep/wake — a subset of the ~30 `tcon*` functions in +`Tcon.cpp`. bb_epaper already carries bitbank2's own dormant IT8951 scaffolding (chip enum, +register `#define`s, SPI primitives, an init table, a commented panel row, an `#ifdef FUTURE` +dispatch stub — all from commit `631e3c0`), so completing it is a clean upstreamable PR rather than +a fork. **Rough effort:** ~250–350 lines of new/ported C in bb_epaper across ~6 functions + 2 +panel-table rows, plus deleting/collapsing the ~15 `#ifdef OPENDISPLAY_SEEED_GFX` sites in +`display_service.cpp` and retiring `display_seeed_gfx.{cpp,h}` down to a thin bb_epaper shim. +**Biggest risks:** (1) the existing bb_epaper IT8951 SPI stubs do **not** poll the **HRDY** +handshake and have **no read path** at all — both mandatory for real silicon; (2) per-unit **VCOM** +calibration (Seeed hardcodes `1400`/−1.40 V, bb_epaper's M5Paper stub uses `2300`/−2.30 V); (3) +packed-pixel **nibble/word order + X-mirroring** must be reproduced byte-for-byte. All three are +containable but demand a **physical ED103TC2 panel** to validate — there is no way to prove +correctness from source alone. + +--- + +## B. Seeed_GFX interface surface — full catalog (e-paper-relevant) + +### B.1 `EPaper` class — `lib/Seeed_GFX/Extensions/EPaper.{h,cpp}` +`EPaper : public TFT_eSprite` (owns a 1-bpp/4-bpp sprite framebuffer `_img8`). + +| Method | File:line | Purpose | +|---|---|---| +| `EPaper()` ctor | EPaper.cpp:1 | `setColorDepth(1)`, `createSprite(w,h,1)` — allocates `_img8` | +| `begin(uint8_t wake=0)` | EPaper.cpp:8 | `init()` (RST toggle + `hostTconInit`) then `EPD_WAKEUP()`; `wake!=0` → `initFromSleep()` | +| `update()` | EPaper.cpp:41 | Full-frame push+refresh: `wake→SET_WINDOW→PUSH_NEW_COLORS→UPDATE→sleep` (1bpp or gray branch) | +| `update(x,y,w,h,data)` | EPaper.cpp:159 | Sub-region push via `pushImage` — **unused by OpenDisplay** | +| `updataPartial(x,y,w,h)` | EPaper.cpp:71 | Aligned partial window (16-px), `tconDisplayArea1bpp` — **unused** | +| `initGrayMode(uint8_t)` | EPaper.cpp:186 | Switch `_img8` to 4-bpp (grayLevel 16); recreates sprite | +| `deinitGrayMode()` | EPaper.cpp:208 | Switch `_img8` back to 1-bpp | +| `sleep()` | EPaper.cpp:224 | `EPD_SLEEP()` → `tconSleep()` (guarded by `_sleep`) | +| `wake()` | EPaper.cpp:232 | `EPD_SET_TEMP` + `EPD_WAKEUP`/`_GRAY` → `tconWake()` (guarded) | +| `drawBufferPixel / setTemp / getTemp / setHumi / getHumi` | EPaper.cpp:36,250-266 | **unused by OpenDisplay** | +| `getPointer()` (inherited `TFT_eSprite`) | Sprite.cpp:118 | Returns `_img8` raw framebuffer pointer | + +### B.2 `EPD_*` macros — the EPaper→Tcon glue — `TFT_Drivers/ED103TC2_Defines.h` +These are the actual bridge that turns `EPaper` calls into `tcon*` commands: + +| Macro | File:line | Expands to | +|---|---|---| +| `EPD_SET_WINDOW(x1,y1,x2,y2)` | :134 | `setTconWindowsData(x1,y1,x2,y2)` | +| `EPD_PUSH_NEW_COLORS(w,h,c)` | :145 | `tconLoad1bppImage(c,…,w,h,false)` | +| `EPD_PUSH_NEW_GRAY_COLORS(w,h,c)` | :156 | `tconLoadImage(c,…,w,h,false)` (4bpp) | +| `EPD_PUSH_OLD_COLORS(...)` | :173 | **no-op** | +| `EPD_UPDATE()` | :70 | `tconDisplayArea1bpp(…,0x02,0x00,0xff)` — GC16, BG=0 FG=255 | +| `EPD_UPDATE_GRAY()` | :77 | `tconDisplayArea(…,0x02)` — GC16 | +| `EPD_UPDATE_PARTIAL()` | :64 | `tconDisplayArea1bpp(…,0x01,…)` — DU — **unused** | +| `EPD_WAKEUP()` | :118 | `tconWake()` + `setTconTemp` | +| `EPD_SLEEP()` | :84 | `tconSleep()` | +| `EPD_SET_TEMP(t)` | :178 | `setTconTemp(t)` | +| `EPD_INIT()` / `OD_EPD_RST_TOGGLE()` | :92-116 | RST pulse (runtime pins) | +| init body `ED103TC2_Init.h` | :46-47 | RST pulse + `hostTconInit()` | +| wake body `ED103TC2_Init_Wake.h` | :33 | `hostTconInitFast()` | + +### B.3 `Tcon` methods (added to `TFT_eSPI`) — `Extensions/Tcon.{h,cpp}` +| Method | Tcon.cpp:line | IT8951 command(s) issued | +|---|---|---| +| `tconWaitForReady()` | :22 | Poll HRDY (busy pin) until HIGH, w/ timeout | +| `tconSendWord / tconReceiveWord` | :48,53 | `spi.transfer16` (word granular) | +| `tconWriteCmdCode(cmd)` | :60 | preamble `0x6000` + cmd word (HRDY-gated) | +| `tconWirteData(d)` | :82 | preamble `0x0000` + data word | +| `tconWirteNData(buf,n)` | :100 | preamble `0x0000` + burst via `pushPixels[DMA]` (16 KB chunks) | +| `tconReadData()` | :144 | preamble `0x1000` + dummy + read word | +| `tconReadNData(buf,n)` | :164 | preamble `0x1000` + dummy + n-word burst read | +| `tconSendCmdArg(cmd,args,n)` | :188 | cmd + n data words | +| `tconReadReg / tconWriteReg` | :201,213 | `REG_RD`(0x10)/`REG_WR`(0x11) + addr(+val) | +| `tconLoadImgStart / …AreaStart / …End` | :223,245,261 | `LD_IMG`(0x20)/`LD_IMG_AREA`(0x21)/`LD_IMG_END`(0x22) | +| `tconSetImgBufBaseAddr(addr)` | :266 | write `LISAR`+2 / `LISAR` | +| `tconSetImgRotation(r)` | :237 | `LD_IMG` w/ rotation — **unused** | +| `tconHostAreaPackedPixelWrite(ld,area)` | :276 | set base addr → `LD_IMG_AREA` → mirror/pack rows → burst → `LD_IMG_END` | +| `tconDisplayArea(x,y,w,h,mode)` | :331 | `DPY_AREA`(0x34) + 5 args | +| `tconDisplayArea1bpp(...,bg,fg)` | :346 | X-mirror; set `UP1SR+2` bit2; set `BGVR`; `DPY_AREA`; wait; restore | +| `tconLoad1bppImage(buf,x,y,w,h,flip)` | :369 | X-mirror; load as 8bpp w/ width/8 → `tconHostAreaPackedPixelWrite` | +| `tconLoadImage(buf,x,y,w,h,flip)` | :393 | 4bpp → `tconHostAreaPackedPixelWrite` | +| `getTconInfo(buf)` | :416 | `GET_DEV_INFO`(0x0302) burst read → `I80TCONDevInfo` | +| `hostTconInit()` | :437 | `setTconVcom(1400)` → `getTconInfo` → enable `I80CPCR` packed mode | +| `hostTconInitFast()` | :453 | `getTconInfo` only (no VCOM, no I80CPCR) | +| `setTconWindowsData(x1,y1,x2,y2)` | :466 | store `_imgAreaInfo` (no bus traffic) | +| `getTconTemp / setTconTemp` | :474,482 | cmd `0x0040` | +| `getTconVcom / setTconVcom` | :491,498 | cmd `0x0039` (arg 0x02 = write) | +| `tconSleep / tconWake / tconStandby` | :506,511,516 | `SLEEP`(0x03)/`SYS_RUN`(0x01)/`STANDBY`(0x02) | +| `tconWaitForDisplayReady()` | :521 | poll `LUTAFSR` reg until 0 | + +Everything else in `lib/Seeed_GFX/` (`Sprite.cpp` 82 KB, `Smooth_font.cpp`, `Button.cpp`, +`Touch.cpp`, all `TFT_Drivers/*` for TFT LCDs, the whole `TFT_eSPI` core) is TFT-LCD/graphics +machinery **entirely unused** by OpenDisplay except that `EPaper` inherits `TFT_eSprite` only to +get a malloc'd framebuffer + `getPointer()`. + +--- + +## C. Portion USED by OpenDisplay Firmware ("must move to bb_epaper") + +Only `src/display_seeed_gfx.cpp` links to Seeed_GFX. Its complete dependency set and the call +chains that reach real IT8951 traffic: + +| `display_seeed_gfx.cpp` | → `EPaper` method | → `EPD_*` macro | → `tcon*` reached | IT8951 op | +|---|---|---|---|---| +| `seeed_gfx_epaper_begin` :104 | `initGrayMode(16)` / `deinitGrayMode` :110-113 | — | (sprite realloc) | switch 1/4-bpp buffer | +| ″ | `begin(0)` :115 | `init` body + `EPD_WAKEUP` | RST + `hostTconInit` + `tconWake`+`setTconTemp` | reset, VCOM, GetDevInfo, I80CPCR, wake | +| `seeed_gfx_direct_write_reset` :145 | `begin(0)` (cold/first) or `wake()` :155-157 | `EPD_WAKEUP`/`EPD_SET_TEMP` | `tconWake`,`setTconTemp` | wake / full init | +| `seeed_gfx_direct_write_chunk` :166 | `getPointer()` :168 + `memcpy` | — | — | fill framebuffer | +| `seeed_gfx_boot_write_row` :133 | `getPointer()` :134 + `memcpy` | — | — | fill one row | +| `seeed_gfx_direct_refresh` :179 | `update()` ×1–2 :180-183 | `SET_WINDOW`→`PUSH_NEW[_GRAY]_COLORS`→`UPDATE[_GRAY]` | `setTconWindowsData`, `tconLoad1bppImage`/`tconLoadImage`, `tconDisplayArea1bpp`/`tconDisplayArea`, `tconWaitForDisplayReady` | window, packed load, GC16 refresh, wait | +| `seeed_gfx_full_update` :119 | `update()` | (same as above) | (same) | (same) | +| `seeed_gfx_direct_sleep` / `_sleep_after_refresh` :186,129 | `sleep()` | `EPD_SLEEP` | `tconSleep` | sleep | + +**Distinct IT8951 primitives OpenDisplay truly needs** (the port target): +`tconWaitForReady` (HRDY), `getTconInfo` (GetDevInfo), `setTconVcom`, `hostTconInit` +(VCOM+GetDevInfo+I80CPCR), reg read/write (for 1bpp mode + `LUTAFSR` polling), +`tconHostAreaPackedPixelWrite` (full-frame, both 8bpp-as-1bpp and 4bpp), +`tconDisplayArea` (GC16 gray) and `tconDisplayArea1bpp` (GC16 1bpp w/ BGVR), +`tconWaitForDisplayReady`, `tconWake`, `tconSleep`, `setTconTemp`. Plus RST toggle. **~14 ops.** + +Waveform selection is a single mode index (`0x02` = GC16) passed to `DPY_AREA`; the waveforms +themselves live in the TCON's own flash and self-load — **no LUT bytes to port.** The `refresh_mode` +distinction in `seeed_gfx_direct_refresh` (:181) is merely "call GC16 once vs twice", not a +different waveform. + +--- + +## D. Portion NOT used by Firmware (can be dropped) + +Everything in B not in C. Confirmed unreachable from `display_seeed_gfx.cpp`: + +- **All TFT_eSPI graphics/text/sprite/font/touch:** the entire `TFT_eSPI` core, `Sprite.cpp` + (except the `malloc`+`getPointer` mechanics), `Smooth_font.cpp`, `Button.cpp`, `Touch.cpp`, + every `TFT_Drivers/*` LCD driver. OpenDisplay never calls a drawing/text primitive. +- **Partial update:** `EPaper::updataPartial` (EPaper.cpp:71), `EPD_UPDATE_PARTIAL` + (ED103TC2_Defines.h:64), `EPD_WAKEUP_PARTIAL`. OpenDisplay always does a full-frame + `setTconWindowsData(0,0,w-1,h-1)` + full push. +- **Sub-region push:** `EPaper::update(x,y,w,h,data)` (EPaper.cpp:159), `pushImage`. +- **Rotation:** `tconSetImgRotation` (Tcon.cpp:237), rotate args — always `ROTATE_0`. +- **Read-back beyond what init/refresh need:** `tconReadNData`/`tconReadData` are needed **only** + for `getTconInfo` and reg reads; the general read API and `MEM_BST_*` burst-memory commands are + unused. +- **1bpp BGVR — REQUIRED (confirmed shipping 2026-07-24):** panel_ic **3000** + (`OD_PANEL_IC_ED103TC2_1872X1404`, non-`_4GRAY`) **does use** the 1bpp BGVR path: + `seeed_gfx_panel_is_4gray()` is false → `deinitGrayMode()` → `EPaper::update()` `_grayLevel==0` + branch → `EPD_PUSH_NEW_COLORS` + `EPD_UPDATE` → `tconLoad1bppImage` + `tconDisplayArea1bpp` + (which sets `UP1SR+2` bit2 and `BGVR`). **The product ships 1bpp**, so this is NOT droppable and + NOT in section D — it is in scope (kept here only to record the resolved decision). Its + consequence propagates: the `UP1SR+2` register **read-modify-write** makes the HRDY-gated **read + path mandatory** (it is not merely a GetDevInfo convenience). See E.2, G, and H. +- **Temp/humidity helpers:** `setTemp/getTemp/setHumi/getHumi` (only `setTconTemp` on wake is used; + the callback API is unused). +- **`hostTconInitFast`** (Tcon.cpp:453 / Init_Wake.h): the OpenDisplay wake path calls + `EPaper::wake()`→`tconWake()` **not** `initFromSleep()`, and `seeed_gfx_direct_write_reset` forces + a full `begin()` after any rail cut (`seeed_gfx_mark_hw_deinitialized`, cpp:190). So the + "fast re-init after light sleep" path is effectively bypassed — port only the full `hostTconInit`. + +--- + +## E. What must move to bb_epaper + +### E.1 ED103TC2 panel definition(s) +Two `EPD_PANEL` rows (`bb_ep.inl` `panelDefs[]` @3738) + two `EP_PANEL_*` enum values +(`bb_epaper.h` @159, before `EP_PANEL_COUNT` @271; the old `EP47` enum value is **absent** — only a +commented `panelDefs` row survives @3779): + +| Field | 1bpp (ic 3000) | 4gray (ic 3001) | +|---|---|---| +| width / height | 1872 / 1404 | 1872 / 1404 | +| x_offset | 0 | 0 | +| pInitFull | `it8951_ed103_init` (RST→VCOM 1400→GetDevInfo→I80CPCR) | same | +| pInitFast/Part | NULL | NULL | +| flags | 0 (B/W) | `BBEP_16GRAY` (0x0040) → 4-bpp buffer, `bbepAllocBuffer` @bb_ep_gfx.inl:1955 | +| chip_type | `BBEP_CHIP_IT8951` | `BBEP_CHIP_IT8951` | +| pColorLookup | `u8Colors_2clr` | `u8Colors_4gray` | + +Analog/mode constants for both: **VCOM = 1400** (−1.40 V, from `hostTconInit` Tcon.cpp:440), +GC16 waveform = `DPY_AREA` mode **0x02**, pixel formats `IT8951_8BPP`(=3, used for 1bpp +transport) / `IT8951_4BPP`(=2), endian `IT8951_LDIMG_L_ENDIAN`, gray levels 2 or 16. +Note bb_epaper's `epd47_it8951_init` @1396 encodes VCOM **2300** (M5Paper) — **do not reuse**; +ED103TC2 needs 1400. + +### E.2 IT8951 operations to port (source → target) + +| New bb_epaper function | Ports from `Tcon.cpp` | Behavior | +|---|---|---| +| HRDY-gated cmd/data/read prims | `tconWriteCmdCode`:60,`tconWirteData`:82,`tconReadData`:144,`tconReadNData`:164 | Add HRDY wait around the **existing** stubs `bbepWriteIT8951Cmd/Data/CmdArgs` (arduino_io.inl:108-144) and add a **new read** primitive (stubs have none) | +| `bbepIT8951WaitHRDY` | `tconWaitForReady`:22 | Poll busy pin until HIGH w/ timeout (reuse `opnd_seeed_tcon_busy_timeout` semantics) | +| `bbepIT8951ReadReg/WriteReg` | `tconReadReg`:201,`tconWriteReg`:213 | `REG_RD`/`REG_WR` + addr(+val) | +| `bbepIT8951GetDevInfo` | `getTconInfo`:416 | `GET_DEV_INFO` burst read → panel W/H + img-buf base addr | +| `bbepIT8951SetVcom` | `setTconVcom`:498 | cmd 0x0039 arg 0x02 + vcom | +| `bbepIT8951Init` (host init) | `hostTconInit`:437 | RST → set VCOM 1400 → GetDevInfo → `I80CPCR=1` | +| `bbepIT8951LoadFull` (packed pixel) | `tconHostAreaPackedPixelWrite`:276 + `tconLoad1bppImage`:369 / `tconLoadImage`:393 | set `LISAR` base → `LD_IMG_AREA` → per-row X-mirror + word-pack → burst → `LD_IMG_END`. 1bpp: 8bpp transport, width/8, X-mirror; 4bpp: width as-is | +| `bbepIT8951Display` (GC16) | `tconDisplayArea`:331 / `tconDisplayArea1bpp`:346 | gray: `DPY_AREA` mode 2. 1bpp: X-mirror + set `UP1SR+2` bit2 + `BGVR`=(0<<8\|255) + `DPY_AREA` + wait + restore | +| `bbepIT8951WaitDisplay` | `tconWaitForDisplayReady`:521 | poll `LUTAFSR` until 0 | +| `bbepIT8951Sleep/Wake` | `tconSleep`:506/`tconWake`:511 | cmd `SLEEP`/`SYS_RUN` (+ `setTconTemp` on wake, Tcon.cpp:482) | + +All register `#define`s already exist in `bb_epaper.h` @329-409 (`IT8951_*`); command opcodes too. +`I80TCONDevInfo` struct must be added (currently only in `Tcon.h`:24) — put it near the IT8951 +`#define`s in `bb_epaper.h` or in `structs.h` (per repo rule, structs do not go in the protocol +header, but this is bb_epaper's own header, not the vendored protocol header, so it is fine there). + +--- + +## F. Detailed integration plan + +### F.1 Panel-table rows (bb_ep.inl:3738, bb_epaper.h enum) +Add `EP_ED103TC2_1872x1404` and `EP_ED103TC2_1872x1404_4GRAY` to the `EP_PANEL_*` enum +(bb_epaper.h, before `EP_PANEL_COUNT`:271), and the two `panelDefs[]` rows from E.1. Uncomment / +replace the dormant M5Paper row at bb_ep.inl:3779 or add fresh rows. Write `it8951_ed103_init` as a +byte sequence that `bbepSendCMDSequence` (bb_ep.inl:4202) can interpret — **but note** its opcode +model is single-byte SSD/UC commands; IT8951 needs 16-bit opcodes + HRDY, so the IT8951 init is +better done as a dedicated C function (`bbepIT8951Init`) invoked from the lifecycle branch rather +than shoe-horned into the `pInitFull` byte-table format. Set `pInitFull=NULL` and branch on +`chip_type==BBEP_CHIP_IT8951` in the lifecycle functions instead. + +### F.2 `BBEP_CHIP_IT8951` branches to add (by function, bb_ep.inl) + +| Function | Line | IT8951 branch behavior | +|---|---|---| +| `bbepWaitBusy` | 3957 | `busy_idle = HIGH` for IT8951 (HRDY ready = HIGH). Currently only UC81xx=HIGH else LOW (:3965) | +| `bbepWakeUp` | 3992 | RST pulse already generic; after reset, for IT8951 call `bbepIT8951Init` (VCOM+GetDevInfo+I80CPCR) — otherwise TCON unconfigured | +| `bbepSetAddrWindow` | 4006 | Complete the `#ifdef FUTURE` stub (:4016-4027): for full-frame OpenDisplay this collapses to storing the area; real `LD_IMG_AREA` happens inside the packed-write. Simplest: make it a no-op for IT8951 (window is implicit in the load) | +| `bbepWritePlane` | 5083 | Branch before the UC/SSD split (:5120): call `bbepIT8951LoadFull(pBBEP, plane)` (1bpp vs 4bpp per `BBEP_16GRAY`) and return | +| `bbepRefresh` | 4365 | Branch at top: for IT8951, `bbepIT8951Display` (GC16 mode 2, 1bpp-BGVR or gray) + `bbepIT8951WaitDisplay`; skip the UC/SSD `DISP_CTRL2`/`DRF` logic | +| `bbepSleep` | 4109 | Branch: IT8951 → `bbepIT8951Sleep` (cmd 0x03); `is_awake=0` | +| `bbepStartWrite` | 4140 | Not needed for IT8951 (load is monolithic); guard so it is a no-op | + +That is **6–7 functions** getting a small IT8951 branch, plus the ~8 new IT8951 functions from E.2, +plus HRDY/read added to the 3 existing SPI stubs. + +### F.3 Firmware-side changes (`src/`) +Replace the `EPaper`-based shim in `display_seeed_gfx.cpp` with bb_epaper calls, keeping the **same +public function names** so `display_service.cpp` needs no change beyond eventually collapsing its +`#ifdef`s: + +| `display_seeed_gfx.cpp` fn | New bb_epaper implementation | +|---|---| +| `seeed_gfx_epaper_begin` :104 | `bbepSetPanelType(&bbep, EP_ED103TC2_… )`; `bbepInitIO(...)`; `bbepAllocBuffer` (or `setBuffer` to a PSRAM buffer); `bbepWakeUp` (→ runs `bbepIT8951Init`) | +| `getPointer()` :134,160,168 | `bbep.ucScreen` / `BBEPAPER::getBuffer()` (bb_epaper.cpp:421) | +| `seeed_gfx_direct_write_reset` :145 | first-boot/rail-cut → full `bbepWakeUp`+init; else nothing; `memset(ucScreen,0xFF,fb_byte_size())` | +| `seeed_gfx_direct_write_chunk` :166 | `memcpy` into `ucScreen + offset` (unchanged) | +| `seeed_gfx_direct_refresh` :179 | `bbepWritePlane(&bbep, PLANE_0, ...)` then `bbepRefresh(&bbep, REFRESH_FULL)` (×2 if mode 0) | +| `seeed_gfx_direct_sleep` :186 | `bbepSleep(&bbep, 1)` | +| `seeed_gfx_mark_hw_deinitialized` :190 | keep the plain-RAM flag; on next reset force full init | + +Once the shim is pure bb_epaper, the ~15 `#ifdef OPENDISPLAY_SEEED_GFX` sites in +`display_service.cpp` (grep list: lines 19, 368, 418, 724, 753, 1579, 1677, 1964, 2093, 2147, 2332, +2452, 2644, 2826, 3170) **collapse**: the ED103TC2 path becomes just another +`seeed_driver_used()`-style panel_ic check that routes through the same `bbep` object the other +panels already use. Sites that today call `seeed_gfx_direct_*` can call the normal +`bbepWritePlane`/`bbepRefresh`/`bbepSleep` used by e.g. the E1004 path (already present at +:449-536). `seeed_driver_used()` (:723) can be renamed/merged into the generic panel dispatch. +`-DOPENDISPLAY_SEEED_GFX` and the `lib/Seeed_GFX/` tree are then deletable. + +### F.4 Framebuffer ownership & sizing +Today `EPaper` (via `TFT_eSprite::createSprite`) mallocs `_img8`; `getPointer()` returns it. In +bb_epaper, `ucScreen` is the framebuffer, allocated by `bbepAllocBuffer` (bb_ep_gfx.inl:1951), +which routes **>98 000 bytes to `ps_malloc` (PSRAM)** automatically (:1965-1969). Sizes for +1872×1404 = **2 628 288 px**: + +- **1bpp:** stride `(1872+7)/8 = 234` B/row × 1404 = **328 536 B ≈ 320.8 KiB (~329 KB)** → + `bbepAllocBuffer` picks PSRAM (>98 000). ✔ +- **4bpp (16-gray):** stride `1872/2 = 936` B/row × 1404 = **1 314 144 B ≈ 1.253 MiB (~1.31 MB)** + → PSRAM, and `BBEP_16GRAY` makes `bbepAllocBuffer` size it as `(w>>1)*h` (:1955-1956). ✔ + +**PSRAM confirmed** on every ED103TC2 build env: `platformio.ini` has `-DBOARD_HAS_PSRAM` + +`board_build.psram_type=qspi_opi` for `esp32-s3-N16R8` (:47), `N8R8` (:72), `N32R8` (:124). The S3 +carries 8 MB OPI PSRAM; a 1.31 MB framebuffer is comfortable. Packed-pixel transfer sizes match the +buffer sizes exactly (traced through `tconHostAreaPackedPixelWrite`: 1bpp → 117 words/row × 1404 = +328 536 B; 4bpp → 468 words/row × 1404 = 1 314 144 B). + +### F.5 Upstream-vs-fork strategy +bb_epaper is vendored **upstream** (bitbank2). The IT8951 stub (chip enum, `IT8951_*` `#define`s, +`bbepWriteIT8951*`, `epd47_it8951_init`, the commented panel row, the `#ifdef FUTURE` dispatch) is +**bitbank2's own unfinished work** in a single 2024-12-16 commit `631e3c0`. Completing it is a +**natural upstream PR**, not a fork: add the HRDY/read primitives, the lifecycle branches, the +GetDevInfo/VCOM/packed-write/DPY_AREA functions, and generic IT8951 panel rows (M5Paper 540×960 +**and** ED103TC2 1872×1404). Keep OpenDisplay-specific runtime-pin plumbing behind the existing +`OPENDISPLAY_SEEED_GFX_RUNTIME_PINS`-style guard so upstream stays board-agnostic. Until merged, +pin the vendored copy (as already done for `BBEP_T133A01` / E1004, platformio.ini:143). + +--- + +## G. Feasibility with minimal architectural change + +**It fits bb_epaper's table-driven model with a *parallel dispatch branch*, not a rewrite.** +bb_epaper's lifecycle is `init-table → bbepWritePlane (push local buffer) → bbepRefresh`. The +IT8951 maps onto this cleanly **at the seams**, because it too has (a) host-side framebuffer +(`ucScreen`), (b) a "push to controller frame memory" step (`bbepWritePlane`→packed-pixel load), +(c) a "refresh with a mode" step (`bbepRefresh`→`DPY_AREA` GC16). The difference is *inside* each +step: instead of a byte-command table + RAM window, IT8951 uses I80 command packets + HRDY + its +own frame memory. So each lifecycle function gets **one `if (chip_type==BBEP_CHIP_IT8951)` branch +that early-returns after doing the IT8951 equivalent** — the exact pattern already used for +`UC81xx` vs `SSD16xx` throughout (`bbepWaitBusy`:3965, `bbepSetAddrWindow`:4031, `bbepSleep`:4112, +`bbepStartWrite`:4145, `bbepWritePlane`:5120). + +**Quantified:** +- Functions gaining an IT8951 branch: **6–7** (`bbepWaitBusy`, `bbepWakeUp`, `bbepSetAddrWindow`, + `bbepWritePlane`, `bbepRefresh`, `bbepSleep`, +`bbepStartWrite` no-op). +- New/ported IT8951 functions: **~8** (HRDY wait, read primitive, reg rd/wr, GetDevInfo, SetVcom + + host init, packed-pixel full-frame load, DPY_AREA display incl 1bpp-BGVR, wait-display). +- New/ported lines: **~250–350** C (the `tcon*` originals total ~520 lines but a third is + unused: reads-beyond-init, rotation, partial, MEM_BST, temp/humi callbacks). +- Untouched: the entire SSD16xx/UC81xx code path, all 60+ existing panel rows, `bbepWriteImage*`, + `bbepMakeLUTs`, the graphics layer — **no regression surface** for existing panels. + +**Single biggest architectural friction: the SPI transport granularity + missing HRDY/read.** +bb_epaper's IT8951 stubs (`bbepWriteIT8951Cmd/Data/CmdArgs`, arduino_io.inl:108-144) fire +`SPI.transferBytes` **without any HRDY handshake and provide no read path**, whereas IT8951 +mandates HRDY-gating before the preamble and before data, and GetDevInfo/reg-reads/`LUTAFSR` +polling are read bursts (Seeed polls HRDY per word, Tcon.cpp:22,151-184). **Contain it** by adding a +single `bbepIT8951WaitHRDY(pBBEP)` used inside a small set of transport helpers (cmd/data/read), +mirroring `tconWaitForReady`'s timeout guard (which OpenDisplay already surfaces via +`opnd_seeed_tcon_busy_timeout_*`, display_seeed_gfx.cpp:40-48) so a dead panel degrades gracefully +instead of hanging. For the bulk pixel push, HRDY is polled **once** before the burst (as Seeed +does, Tcon.cpp:107), so throughput is unaffected. + +--- + +## H. Risk register + +| Risk | Sev | Detail | Mitigation | +|---|---|---|---| +| **VCOM correctness** | High | IT8951 VCOM is a per-unit analog calibration (often on an FPC sticker). Seeed hardcodes **1400** (−1.40 V, Tcon.cpp:440); bb_epaper's M5Paper stub uses **2300** (bb_ep.inl:1399). Wrong VCOM → washed-out/ghosted/over-driven image. | Use 1400 for ED103TC2 (matches shipping Seeed path). Make VCOM a panel-table/config field, not a literal. Validate against a physical panel; consider reading factory VCOM if the TCON stores it. | +| **HRDY timing** | High | Existing stubs omit HRDY; IT8951 will corrupt/hang without it. Read bursts poll HRDY per word. | Add `bbepIT8951WaitHRDY` with the existing timeout+flag mechanism; poll before preamble/data and per read word; poll once before pixel burst. | +| **Packed-pixel nibble/word order + X-mirror** | High | `tconHostAreaPackedPixelWrite` reverses X per row (`width-1-i`, Tcon.cpp:307) and `tconLoad1bppImage`/`tconDisplayArea1bpp` additionally X-mirror the origin (`panelW-1-usX-usW+1`, :371,348). 4bpp nibble order is left-pixel=high-nibble (display_seeed_gfx.cpp:3-5). Any mismatch → mirrored or scrambled output. | Port the mirror/pack loop **verbatim**; keep the same `IT8951_LDIMG_L_ENDIAN` + `bswap` handling as the existing `bbepWriteIT8951CmdArgs` (arduino_io.inl:141). Diff first render pixel-for-pixel against the current Seeed build. | +| **1bpp BGVR register dance** | **High** (was Med) | **1bpp confirmed shipping (2026-07-24)** → this path is on the critical path, not optional. ic 3000 needs `UP1SR+2` bit2 set/restore + `BGVR` color table around `DPY_AREA` (Tcon.cpp:350-363), which requires **reg read-modify-write** → makes the HRDY-gated **read path mandatory** (no longer just for GetDevInfo). The existing bb_epaper stubs have **no read primitive at all** — this is net-new code, and the single largest correctness dependency. | Port `tconDisplayArea1bpp` exactly; implement + bench-verify the read/HRDY primitive **first**, before any refresh path. Diff first 1bpp render pixel-for-pixel vs. the current Seeed build. | +| **Buffer sizing / PSRAM** | Low | 329 KB (1bpp) / 1.31 MB (4bpp) must land in PSRAM. | `bbepAllocBuffer` auto-routes >98 KB to `ps_malloc` (bb_ep_gfx.inl:1965); all ED103TC2 envs set `BOARD_HAS_PSRAM` (platformio.ini:47,72,124). Verify alloc success; fall back to internal-DRAM is impossible at 1.3 MB → handle NULL. | +| **Hardware-validation dependency** | High | Correctness (VCOM, HRDY, mirroring, GC16 waveform index) cannot be proven from source; needs a real **ED103TC2 1872×1404** panel on an S3. | Gate the migration behind a bench bring-up: keep the Seeed path selectable until the bb_epaper path is validated on hardware side-by-side. | +| **Upstream acceptance** | Low | PR must stay board-neutral. | Keep runtime-pin/timeout glue behind guards; contribute generic M5Paper + ED103TC2 rows; until merged, pin the vendored bb_epaper (as done for E1004). | +| **Refresh-wait semantics** | Low | Today `seeed_gfx_wait_refresh` just `delay(300)` (display_seeed_gfx.cpp:123). bb_epaper uses `LUTAFSR` polling. | Port `tconWaitForDisplayReady`; drop the blind delay for a real poll (strict improvement). | + +--- + +## I. Open questions (need a human / hardware) + +1. **VCOM per unit:** Is −1.40 V correct for the specific ED103TC2/E1004 panels OpenDisplay ships, + or does each unit carry an individual VCOM (sticker/OTP) that must be provisioned? Should VCOM + become a `DisplayConfig`/factory field rather than a compile-time literal? +2. **1bpp vs 4gray in the field:** ~~Does any shipped product use panel_ic 3000 (1bpp)?~~ + **RESOLVED 2026-07-24 — 1bpp IS shipping.** The BGVR/`UP1SR` 1bpp path and its HRDY-gated read + dependency are therefore **in scope and mandatory** — the port cannot be shrunk by dropping them. + The 4bpp/4gray (ic 3001) path may or may not also ship, but does not reduce scope either way. +3. **HRDY pin identity:** Confirm the IT8951 HRDY line is wired to the same GPIO OpenDisplay maps as + `busy_pin` / `opnd_seeed_runtime_busy` (default 13), and that ready = logic HIGH on this board. +4. **GC16-only, or is A2/DU wanted?** OpenDisplay's current path only ever issues GC16 (mode 0x02). + Is a fast (A2) mode desired for partial/low-latency updates, or is GC16-only acceptable + (matches today's behavior)? +5. **SPI clock:** Seeed drives the TCON via `TFT_eSPI` at its configured SPI freq; the bb_epaper + E1004 path uses 8 MHz (`bbepInitIO(...,8000000)`, display_service.cpp:188). Is 8 MHz safe for the + IT8951 GetDevInfo read burst on this wiring, or does it need a slower read clock? +6. **DMA:** Seeed's `tconWirteNData` optionally uses `pushPixelsDMA`. Should the bb_epaper port use + ESP32 SPI DMA for the ~1.3 MB burst, or is `SPI.transferBytes` (as the stub uses) sufficient? +7. **Dual-controller?** The E1004 path handles a dual-CS split panel (`iCS2Pin`, + display_service.cpp:187,215). Is any ED103TC2 variant dual-controller, or always single-CS? + +--- + +## Appendix: complete bb_epaper function inventory (annotated for the IT8951 port) + +bb_epaper has two layers: the **`BBEPAPER::` C++ class** (public API that +`display_service.cpp` calls) and the **`bbep*` C functions** (implementation the class +delegates to). Function counts and line refs are from the vendored checkout +(`.pio/libdeps//bb_epaper/`, upstream commit `c651b2a`). + +**Legend for the port impact column:** +- **TOUCH** — existing function gets a new `BBEP_CHIP_IT8951` branch. +- **ADD** — net-new IT8951 helper (no existing equivalent). +- **USE** — called unchanged by the OpenDisplay IT8951 path. +- **—** — irrelevant to OpenDisplay (drawing/text/loaders never invoked; firmware + `memcpy`s pre-dithered pixels into the buffer directly). + +### A. `BBEPAPER::` public class API (71 methods, `bb_epaper.cpp`) + +| Group | Methods | OpenDisplay/IT8951 | +|---|---|---| +| Lifecycle / panel | `begin` `initIO` `setPanelType` `testPanelType` `createVirtual` `getChip` `getFlags` `setFlags` `capabilities` `getLastError` | USE (`begin`,`initIO`,`setPanelType`) | +| Refresh / power | `refresh` `sleep` `wake` `wait` `isBusy` `startWrite` `setPasses` `hasFastRefresh` `hasPartialRefresh` `getRefreshTime` `dataTime` `opTime` `getCache` | USE (`refresh`,`sleep`,`wake`,`wait`) | +| Buffer / plane | `allocBuffer` `freeBuffer` `setBuffer` `getBuffer` `setPlane` `getPlane` `backupPlane` `writePlane` `writeRegion` `setAddrWindow` | USE (`allocBuffer`/`getBuffer` for the framebuffer + `writePlane`) | +| Geometry | `width` `height` `setRotation` `getRotation` `setCS` | USE (`width`,`height`,`setCS`) | +| Raw I/O | `writeCmd` `writeData` | USE (transitive) | +| Drawing (GFX) | `drawPixel` `drawLine` `drawRect`/`fillRect` `drawCircle`/`fillCircle` `drawEllipse`/`fillEllipse` `drawRoundRect`/`fillRoundRect` `fillScreen` `drawSprite` `stretchAndSmooth` | — | +| Text | `drawString` `print` `println` `write` `setCursor` `getCursorX`/`getCursorY` `setFont` `setFreeFont` `setItalic` `setTextColor` `setTextWrap` `getStringBox` | — | +| Image loaders | `loadBMP` `loadG` | — | +| Misc | `setDitherPattern` | — | + +### B. Internal `bbep*` C functions + +**B.1 Panel / lifecycle (`bb_ep.inl`)** + +| Function | Line | Port impact | Note | +|---|---|---|---| +| `bbepSetPanelType` | 3828 | TOUCH (data) | add 2 ED103TC2 rows to `panelDefs[]` + 2 enum values | +| `bbepTestPanelType` | 4233 | — | | +| `bbepCreateVirtual` | 3902 | — | | +| `bbepSetDitherPattern` | 3880 | — | firmware pre-dithers | +| `bbepLightSleep` | 3938 | — | | +| `bbepWaitBusy` | 3957 | **TOUCH** | IT8951 needs HRDY-level poll, not the UC/SSD busy-pin ternary | +| `bbepIsBusy` | 3980 | **TOUCH** | same HRDY semantics | +| `bbepWakeUp` | 3992 | **TOUCH** | IT8951 `SYS_RUN` + `setTconTemp`, vs generic RST pulse | +| `bbepSetAddrWindow` | 4006 | **TOUCH** | IT8951 `LD_IMG_AREA` branch exists but is `#ifdef FUTURE` (bb_ep.inl:4017) — enable + finish | +| `bbepSleep` | 4109 | **TOUCH** | IT8951 `SLEEP` command | +| `bbepStartWrite` | 4140 | **TOUCH** | IT8951 packed-pixel preamble differs | +| `bbepMakeLUTs` | 4170 | **TOUCH (skip)** | IT8951 has no host LUTs — waveforms in TCON flash; branch is a no-op | +| `bbepSendCMDSequence` | 4202 | **TOUCH** | chip-agnostic byte-code walker today; IT8951 init needs read-back (GetDevInfo/VCOM) the flat table can't express → **the one genuinely new dispatch point** | +| `bbepFill` | 4253 | TOUCH (opt) | only if IT8951 fast-clear wanted; firmware overwrites full frame anyway | +| `bbepRefresh` | 4365 | **TOUCH** | IT8951 `DPY_AREA` GC16 (mode 0x02) + `LUTAFSR` wait | +| `bbepSetRotation` | 4444 | — | always ROTATE_0 | + +**B.2 Plane / image write (`bb_ep.inl`)** + +| Function | Line | Port impact | +|---|---|---| +| `bbepWritePlane` | 5083 | **TOUCH** — IT8951 full-frame packed-pixel `LD_IMG` burst | +| `bbepWriteRegion` | 5061 | — (partial region; OpenDisplay is full-frame) | +| `bbepWriteImage` / `bbepWriteHalf` / `bbepWriteImage1to4bpp` / `bbepWriteImage2bpp` / `bbepWriteImage4bpp` / `bbepWriteImage4bppDual` / `bbepWriteImage4bppSpecial` | 4464–4976 | — (SSD/UC plane encoders) | + +**B.3 Low-level SPI I/O (per-platform; OpenDisplay uses `arduino_io.inl`)** + +| Function | Port impact | Note | +|---|---|---| +| `bbepInitIO` `bbepSetCS2` `bbepWriteCmd` `bbepWriteData` `bbepCMD2` | USE | one definition each per platform (`arduino_io.inl`/`mem_io.inl`/`esphome_io.inl`/`rpi_io.inl`) | +| `bbepWriteIT8951Cmd` `bbepWriteIT8951Data` `bbepWriteIT8951CmdArgs` | **TOUCH** | existing stubs (arduino_io.inl:108–144), **no HRDY, no read path** — add HRDY wait | +| *(new)* `bbepReadIT8951Data` / `bbepReadIT8951Reg` | **ADD** | net-new read primitive — mandatory for GetDevInfo **and** the 1bpp `UP1SR` RMW | + +**B.4 New IT8951 helpers to ADD (ported from Seeed `Tcon.cpp` — see §E.2)** + +`bbepIT8951WaitHRDY` · `bbepIT8951ReadReg`/`WriteReg` · `bbepIT8951GetDevInfo` · +`bbepIT8951SetVcom` · `bbepIT8951Init` · `bbepIT8951LoadFull` · `bbepIT8951Display` · +`bbepIT8951WaitDisplay` · `bbepIT8951Sleep`/`Wake` → all **ADD**. + +**B.5 Buffer (`bb_ep_gfx.inl`)** + +| Function | Line | Port impact | +|---|---|---| +| `bbepAllocBuffer` | 1951 | USE — routes 329 KB (1bpp) / 1.31 MB (4bpp) to `ps_malloc` (PSRAM) | + +**B.6 IRRELEVANT to OpenDisplay (never called — firmware writes pixels directly)** + +- **Pixel setters (`bb_ep_gfx.inl`):** `bbepSetPixel{2,3,4,16}Clr` · `bbepSetPixel4Gray` · `bbepSetPixel2ClrDither` · all `bbepSetPixelFast*` variants (`Fast2Clr/3Clr/4Clr/4ClrV2/16Clr/4Gray`). +- **Graphics primitives:** `bbepDrawLine` · `bbepEllipse` · `bbepRectangle` · `bbepRoundRect` · `bbepDrawSprite`. +- **Text / fonts:** `bbepWriteString` · `bbepWriteStringCustom` · `bbepSetCursor` · `bbepSetTextWrap` · `bbepGetStringBox` · `bbepUnicodeString` · `bbepUnicodeTo1252` · `bbepStretchAndSmooth`. +- **Image loaders:** `bbepLoadG5` · `bbepLoadG5_2Bit` · `bbepLoadBMP` · `bbepLoadBMP3`. + +### C. Tally + +| Bucket | Count | +|---|---| +| Existing `bbep*` functions that get an IT8951 branch (**TOUCH**) | 11 (`bbepWaitBusy`, `bbepIsBusy`, `bbepWakeUp`, `bbepSetAddrWindow`, `bbepSleep`, `bbepStartWrite`, `bbepMakeLUTs`(skip), `bbepSendCMDSequence`, `bbepRefresh`, `bbepWritePlane`, + IT8951 SPI stubs) | +| New IT8951 helpers (**ADD**) | ~10 (incl. the read primitive) | +| Existing functions used unchanged (**USE**) | class API + `bbepInitIO`/`bbepAllocBuffer`/SPI prims | +| Irrelevant, untouched (**—**) | ~40 (all GFX/text/pixel/loaders) + all SSD/UC plane encoders | + +Net: the IT8951 work concentrates in ~11 TOUCH + ~10 ADD functions; the ~40-function GFX/text +surface and every existing panel stay untouched — confirming §G's "minimal architectural change" +verdict, with `bbepSendCMDSequence` (init read-back) as the single new dispatch point. diff --git a/docs/PLAN_FREEZE_PROOFING_2026-07-26.md b/docs/PLAN_FREEZE_PROOFING_2026-07-26.md new file mode 100644 index 0000000..e04425c --- /dev/null +++ b/docs/PLAN_FREEZE_PROOFING_2026-07-26.md @@ -0,0 +1,296 @@ +# Freeze-Proofing the OpenDisplay Firmware (branch: debug/ble-hardening) + +> **Revised 2026-07-26** after adversarial review. The review found 4 Critical defects — two of +> which meant the headline deliverable did not work. All corrections are folded in below and +> tagged `[C1]`…`[X7]`. Full review: `docs/FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md` +> (**first action on implementation: move it there from +> `~/.claude/plans/create-a-comprehensive-plan-majestic-hamster-agent-a0d75f8717fc20eb0.md`** — +> plan mode blocked writing it into the repo). + +## Context + +Field failures: during PIPE_WRITE uploads from Home Assistant, lost ACKs / blind retransmits leave the device **frozen or unresponsive**. Investigation traced four wedge mechanisms plus several unbounded waits: + +1. **Nonce replay-window overrun** — the client burns a nonce per transmission (incl. retransmits); losing a full 32-frame window puts the next frame >32 ahead of `last_seen_counter`; rejections accumulate into `integrity_failures >= 3` → `clearEncryptionSession()` mid-transfer. The device then answers everything `0xFE` while `directWriteActive` keeps the panel powered 15 min. Bonus bug: `verifyNonceReplay` commits `last_seen_counter` **before** CCM tag verification ([encryption.cpp:149-155](../src/encryption.cpp)). + + > **Corrected 2026-07-26 after Phase 1 shipped.** Two claims in the sentence above were wrong and are struck: + > - **"3 rejections (= client's `MAX_PTO`)" is a false coincidence.** `MAX_PTO = 3` yields only *two* probe sends (the client increments and raises at the threshold before sending, `device.py:2721-2726`), and the client aborts the transfer on the **first** NACK, not the third (`device.py:834-838` raises `IntegrityCheckError`, uncaught by the pipe loop at `device.py:2714-2717`). Within one transfer `integrity_failures` plausibly reaches 1, not 3. Reaching 3 needs repeated attempts on the same session. + > - **The freeze actually reproduced on the bench was not a forward nonce gap at all.** It was a *session-identity divergence*: the client lost its session while the device still believed one was live, and py-opendisplay silently degraded to **unencrypted 230-byte `0x0071` chunks** (`device.py:1916-1929`, `:772-776`; `commands.py:70`). At 232 bytes those frames clear the firmware's short-frame gate and enter `decryptCommand`, where 8 bytes of image data are read as a session id → `NONCE_BAD_SESSION` → fatal NACK, forever. See `PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md` § "What actually happened on the bench". + > + > The nonce-gap defect class is real and Phase 1 fixed it. It is simply **not** the mechanism behind the observed field failure, and no baseline capture (Phase 1 Step 5 Test 0) has yet been taken to establish what is. +2. **Pipe fatal-NACK latch** — `sendPipeNack` leaves `pipeState.active=true` forever ([display_service.cpp:2562-2578](../src/display_service.cpp)); `transferActive()` latches → touch dead ([touch_input.cpp:584](../src/touch_input.cpp)), WiFi roam dead; the 15-min watchdog keys on `directWriteActive`, just cleared → nothing bounds it. +3. **Queue overflow** — response ring (10) drops newest on full ([communication.cpp:117-121](../src/communication.cpp)); command ring drops on full ([esp32_ble_callbacks.h:128](../src/esp32_ble_callbacks.h)). The **command** ring is never flushed on disconnect, so stale commands survive. *(The response ring IS drained whenever no central is connected — [main.cpp:307-312](../src/main.cpp) — so stale responses were never the wedge* `[L1]`*.)* +4. **Cross-transport session clobber** — LAN accept/close unconditionally `clearEncryptionSession()` ([wifi_service.cpp:879, :804](../src/wifi_service.cpp)), killing a live BLE session. BLE+LAN can be connected simultaneously today. +5. **Unbounded waits** — `pwrmgmLockTake` infinite spin ([display_service.cpp:401-408](../src/display_service.cpp)); `powerOff` stuck-button loop ([power_latch.cpp:87-90](../src/power_latch.cpp)); FastEPD refresh has no firmware-side bound. +6. **Session lifetime is unbounded and unscoped** — nothing clears `encryptionSession` on a BLE disconnect (only LAN does, [wifi_service.cpp:804](../src/wifi_service.cpp)), so the key and `last_seen_counter` survive into the next connection. The survival is unusable for resumption (a reconnecting client resets its counter to 0 → rejected as out-of-window or as a ring replay) and it enables a real attack: `verifyNonceReplay` exempts `counter_diff == 0` from the replay-ring check ([encryption.cpp:136](../src/encryption.cpp)), so a captured last-frame replayed after the owner disconnects is **accepted and re-executed**. Separately, `session_timeout_seconds` expiry is evaluated inside `isAuthenticated()` on every command ([encryption.cpp:195-199](../src/encryption.cpp)) and so can fire mid-transfer — a deterministic wedge on a long upload, since the client's proactive re-auth is deliberately skipped for the whole pipe stream ([device.py:778-786](../../py-opendisplay/src/opendisplay/device.py)). + +**User decisions (confirmed):** software-only supervisor (no hardware WDT — reset state, never reboot; a reboot wipes RTC incl. `displayed_etag` and forces a boot-screen redraw); clear encryption session on BLE disconnect; **encryption is always scoped to the life of the connection — `session_timeout_seconds` expiry is disabled**; BLE idle timeout = 5 minutes, kept firmware-local (not promoted to the canonical protocol header). + +**Platform facts (verified):** +- ESP32: commands queue via SPSC ring (NimBLE host task → loop task, [main.cpp:406-423](../src/main.cpp)); responses via a 10-slot ring. nRF: NO queues — `imageDataWritten` runs inline on the Bluefruit *Callback* task; `Bluefruit.begin(1,0)` already caps BLE at 1 link. +- `-DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=1` does NOT work: precompiled `sdkconfig.h:613` redefines it to 3 and wins (empirically verified — do not re-add). Enforce in `onConnect` (NimBLE fills `m_connectedPeers` BEFORE `onConnect`, so `getConnectedCount() > 1` is a valid gatecrasher test). +- OTA exception satisfied by design: ESP32 has no OTA (0x0051 = `esp_restart`); nRF DFU jumps to the bootloader with the app gone. Comment it for future OTA work. +- Drain-loop trap: [main.cpp:409-417](../src/main.cpp) caches `tail` before dispatch, stores `tail+1` after — a flush from handler context gets clobbered. +- `setConnectableMode(NON)` internally calls `setFlags(0)` (one-way), so re-push `setAdvertisementData(*advertisementData)` before `start()` — same trap already documented at [ble_init.cpp:307-312](../src/ble_init.cpp). + +--- + +## Hard constraint — NO wire protocol changes + +**Nothing in this plan may change the BLE/LAN wire protocol.** This is a firmware-internal robustness effort; every fix must be observably compatible with today's clients (`py-opendisplay`, the HA integration, the web configurator) and with the other three firmware repos. + +Concretely, the following are **out of bounds** for every phase: +- Editing `include/opendisplay_protocol.h` — it is a byte-for-byte vendored copy of `../opendisplay-protocol/src/opendisplay_protocol.h`. No local edit, and no change pushed through the canonical repo either. +- Adding, removing, or renumbering any `CMD_*` opcode or `RESP_*` code; changing the meaning of an existing one. +- Changing frame layout, framing, header/field sizes, nonce or CCM parameters, or the auth handshake sequence. +- Changing the config-packet layout in `include/opendisplay_structs.h` (field add/remove/resize/reorder), which is the same contract by another name. +- Changing any value the protocol header specifies — notably the **30 s auth-challenge window** (already recorded under *Deliberately NOT changed*) and the **LAN 30 s idle timeout** (`opendisplay_protocol.h:984`). +- Changing client-observable behaviour documented in `docs/pipe-write-protocol.md` (SACK semantics, discard rules, NACK meaning). + +What **is** in bounds, and why each stays inside the constraint: +- Firmware-local constants that no client reads: `OD_NONCE_WINDOW`, `OD_BLE_IDLE_DISCONNECT_MS`, the pipe error-release deadline, supervisor/backstop timeouts, `COMMAND_QUEUE_SIZE`. None appear on the wire; a client cannot observe their value, only the (already-legal) behaviour they produce. +- Widening the replay window and fixing the `counter_diff == 0` hole — accepting *more* legitimate frames and rejecting a replay are both already-permitted outcomes of the existing nonce rules. +- Disabling `session_timeout_seconds` expiry — the field's own spec already defines `0 = no timeout (persists until disconnect)` ([opendisplay_structs.h:916](../include/opendisplay_structs.h)); the firmware simply behaves as if the field is always 0. The struct field stays, unchanged in size and position, and becomes advisory-only. +- Dropping a link (idle timeout, supervisor abort, connection-exclusivity refusal) — disconnect is always a legal outcome; clients already handle it and reconnect. +- Sending an existing NACK/`RESP_*` code in a new situation, as long as the code's documented meaning is unchanged. + +If any phase appears to require a protocol change to work, **stop and escalate** — do not push a header change through `../opendisplay-protocol` as part of this work. Documentation-only additions (a note in `docs/pipe-write-protocol.md` §5.1, field notes in `tools/od-device-cli.py`) are permitted and expected, provided they describe behaviour the current spec already allows. + +Verification of the constraint itself, run before any phase is called done: +```bash +cd ../opendisplay-protocol && tools/sync_protocol_header.py --check --only Firmware # must pass, unchanged +cd ../Firmware && git diff main --stat -- include/opendisplay_protocol.h include/opendisplay_structs.h # must be empty +``` + +--- + +## Phase order + +Reordered per review: **root cause first, owner token before anything depends on it, supervisor before the escalations that rely on its accounting.** + +### Phase 1 — Nonce/replay correctness `(was Phase 3 — highest value-per-risk, ship first)` + +> ## ✅ SHIPPED 2026-07-26 — `0a60712`…`23ecaed` on `debug/freeze-fix-phase2` +> +> Ground truth, with `file:line` anchors, is the **"As-built"** section of +> [`PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md`](PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md). The bullets +> below are kept for provenance but **three of them describe a design that was superseded before +> implementation** — they are struck through and corrected inline. Read the Phase 1 plan, not this +> entry, before touching the code. +> +> **Verified:** 12/12 `pio run` environments build; host test `tools/test_nonce_window.cpp` passes +> 38,199 checks under UBSan/ASan; a separate `host-tests` CI job gates every push. +> **Not verified:** the *entire* hardware matrix, including the baseline capture (Test 0) and the +> test that decides whether an interrupted upload actually completes (Test 2b). + +[encryption.cpp](../src/encryption.cpp) / [encryption_state.h](../src/encryption_state.h) / **new** [nonce_window.h](../src/nonce_window.h) / [communication.cpp](../src/communication.cpp) +- Split `verifyNonceReplay` → pure `nonceCheck()` (OK / BAD_SESSION / OUT_OF_WINDOW / REPLAY, **no state writes**) + `nonceCommit(counter)` (advance `last_seen_counter` + seen-set). **Shipped as specified** — `verifyNonceReplay` deleted outright, `nonceCheck`/`nonceCommit` file-static, pure logic in a dependency-free `src/nonce_window.h`. +- `decryptCommand`: `nonceCheck` → nonce failures return false **without** touching `integrity_failures` (loss ≠ tampering; only a CCM tag failure is tamper evidence) → CCM decrypt → on success `nonceCommit` + reset counter; on tag failure increment (≥3 → clear session, unchanged). **Shipped as specified.** +- ~~`[M1]` Make the window **symmetric**: `OD_NONCE_WINDOW = ±128` with `replay_window[]` grown 64 → 256 (+1.5 KB `.bss`). If `esp32-N4` won't link, fall back to ±64.~~ **Superseded.** The value ring was replaced with an **RFC 4303 sliding bitmap**: `OD_NONCE_BACKWARD_BITS = 256` (`uint64_t[4]`, 32 B) and a separate `OD_NONCE_FORWARD_CAP = 128`. Net struct change is **−480 B**, not +1.5 KB, so **the `esp32-N4` link-headroom gate is moot** — it links at 81,468 B, *below* the pre-Phase-1 figure. `[M1]`'s jam-forward concern was withdrawn: it cannot occur once commit happens after CCM verification. +- ~~Move `replay_window_index` out of the function static into `encryptionSession` so `clearEncryptionSession()` resets it.~~ **Superseded — the field no longer exists.** A bitmap has no insertion index, so the bug is structurally impossible rather than fixed. +- ~~**Close the `counter_diff == 0` replay hole** … replace the special case with an explicit `has_seen_counter` bool (or a sentinel initial value).~~ **Superseded — no `has_seen_counter` was needed.** Under the bitmap, "not seen" is a clear bit rather than a reserved sentinel, so a fresh session (`last_seen = 0`, all-zero bitmap) accepts counter 0 exactly once with no exemption. The `!= 0` term is simply gone. *(Note: this hole is also wider than this bullet says — the old accept path wrote the ring unconditionally, so replaying the highest-seen frame 64× flushed every genuine entry and re-opened the whole backward window. See `[H3]` in the Phase 1 plan.)* +- Log: distinguish `nonce out-of-window` from `CCM tag failure %u/3`. **Shipped, plus more than specified:** both nonce logs demoted to WARN and given **independent** 5 s rate-limit budgets, and the session-id-mismatch line no longer dumps two full session IDs. +- **Added, not in this entry:** *Step 4b* — a nonce-rejected `CMD_PIPE_WRITE_DATA` (`0x0081`) frame is now answered with **silence** instead of a fatal `RESP_NACK`, so the client's SACK path can repair it. This is conformance to `docs/pipe-write-protocol.md` §5.2 ("NACKs are reserved for unrecoverable conditions … not ordinary packet loss"), not a wire change. **It is also the highest-value change in Phase 1 and the least verified** — see Test 2b. +- **Added after implementation, in response to a live hardware failure** (`55a2478`, `77ebdcd`, `23ecaed`): a session-id mismatch now answers `RESP_AUTH_REQUIRED` rather than a fatal NACK, and **the BLE link is dropped after 10 consecutive `0xFE` answers**. The link drop is Phase 5 work pulled forward — see the Phase 5 entry below. +- **Hard-constraint check: passes.** `git diff 02bdd5c..HEAD -- include/` is empty; no opcode or response code was added; both `RESP_AUTH_REQUIRED` and `RESP_NACK` are used in their documented meanings. + +### Phase 2 — Bound the refresh waits `(was Phase 0; scope cut 2026-07-26)` + +> **Scope cut — Phase 2 is now three items, not seven.** Current plan: +> [`PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md`](PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md). +> The earlier [`PLAN_PHASE2_BOUND_WAITS_2026-07-26.md`](PLAN_PHASE2_BOUND_WAITS_2026-07-26.md) is +> **obsolete** — kept only for the analysis behind the cut items. +> +> **In scope:** `[X2]` `epdRefreshInProgress` on both boot paths · `[X3]` a real +> `fastepd_wait_refresh` · a real wall-clock `waitforrefresh` deadline (P2-8, added by the Phase 2 +> plan and *not* in the original list below). Two files — `src/display_service.cpp`, +> `src/display_fastepd.cpp`. No new file, no `src/main.cpp` change, no `platformio.ini` change. +> +> **Dropped, with the residual each leaves open:** +> +> | Dropped | Residual now carried | +> |---|---| +> | `[C2]` `pwrmgmLockTake` deadline | The spin stays **unbounded on both targets**. A holder that never releases blocks its waiter forever. No `panelStateUnknown` flag is produced, so **Phase 3's `abortToKnownState` has nothing to report** — drop that from its remit. | +> | `powerOff` stuck-button bound | ESP32-only; needs a hardware fault; removes a recovery path rather than creating a freeze. | +> | Loop-drain 2 s cap | Withdrawn as unsound, not merely descoped — see below. | +> | `[L3]` inert TWDT flag | The dead `=120` knob stays in 9 ESP envs, still implying a watchdog that does not exist. | +> | Loop-liveness monitor (P2-9) | **A stalled `loop()` is now undetected on both targets.** ESP32's TWDT will not fire (every long wait yields, so IDLE0 is never starved) and nRF has no watchdog at all. | +> +> **Consequence for Phase 6.** Phase 2 was to be the "defensive floor"; it now delivers *bounded +> refreshes* only, not *detected stalls*. Everything in the table above lands on the supervisor — +> and on nRF, where none of the ESP32 wall-clock watchdogs run, there is nothing between a stall and +> Phase 6. Weigh that when sequencing Phase 6, which already had to be extended to nRF. +> +> **The loop-drain cap is withdrawn on the merits, not descoped.** The parent premise here — "a full +> window of commands can hold `loop()` for minutes" — does not survive checking: 32 pipe DATA frames +> cost 0.1–1 s total, the genuinely long case is a single END triggering a 30–60 s refresh (which a +> between-commands check cannot interrupt), and stacked refreshes are unreachable because a second +> `0x0072` short-circuits at [display_service.cpp:2366](../src/display_service.cpp). Do not +> re-propose it; if a saturation signal is wanted it belongs to Phase 7's `[H1]`. + +**Original item list, retained for the record:** + +- `[C2]` **`pwrmgmLockTake` — do NOT steal.** Legitimate holds already exceed 10 s: `bbepWaitBusy` caps at **30 000 ms** for 3/4/7-colour panels (`bb_ep.inl:3959-3975`) and `epdSessionForceOffLocked` holds the lock across `bbepSleep` → `bbepWaitBusy` (`bb_ep.inl:4122`). A steal on a bare 0/1 flag with no owner means two tasks drive the same SPI/CS, the true holder's later `Give` unlocks it under the stealer (mutual exclusion permanently dead), and `pwrmgmState` ends up `PWR_ACTIVE` on a dead rail. **Instead:** `pwrmgmLockTake` returns `bool` with a **60 s** deadline (≥2× worst-case busy wait); on expiry log ERROR, return false, caller skips its panel work and sets a "panel state unknown" flag that `abortToKnownState` reports. If a forced take is ever genuinely needed, add `volatile TaskHandle_t pwrmgmOwner` so the original holder's `Give` becomes a detectable no-op. +- `powerOff` button wait ([power_latch.cpp:87-90](../src/power_latch.cpp)): bound at 10 s, then drop the latch anyway. +- `[X2]` Set/clear `epdRefreshInProgress` around **both boot-refresh paths** (`refreshBootScreenFull` [display_service.cpp:533-542](../src/display_service.cpp) and the FastEPD boot path at `:1588-1594`). Today a 30–60 s Spectra boot refresh is invisible to every `epdRefreshInProgress` gate — including the supervisor's "never interrupt a refresh" rule. +- `[X3]` **FastEPD really is unbounded.** `fastepd_wait_refresh()` is a stub that ignores its timeout ([display_fastepd.cpp:228-231](../src/display_fastepd.cpp)) and `waitforrefresh(60)` short-circuits to it, so the "60 s cap" does not exist on IT8951/E1004. Implement `fastepd_wait_refresh` as a real busy poll against the IT8951 LUT-busy register honouring `timeout_sec`, and wrap **`fastepd_direct_refresh`** (the path a real transfer takes, [display_service.cpp:2422-2423](../src/display_service.cpp)) — not just `fastepd_full_update`. +- `[X1]` **I2C — DOWNGRADED after verification; the original finding was wrong.** The review claimed a wedged GT911 spins unbounded and that touch polls too rarely to notice. Neither holds: + - The driver **already gives up**: 5 consecutive read failures (`TOUCH_I2C_FAIL_DISABLE_THRESHOLD`, [touch_input.cpp:39](../src/touch_input.cpp)) trigger `touch_disable_controller(..., "too many I2C read failures")` ([:642](../src/touch_input.cpp), [:677](../src/touch_input.cpp)), and `TOUCH_I2C_FAIL_BACKOFF_MS` suppresses INT-driven re-reads while failing ([:609-611](../src/touch_input.cpp)). A wedged controller is dropped, not retried forever. + - The poll rate is fine — the floor is 100 ms (`TOUCH_PROCESS_MIN_INTERVAL_MS`, [:38](../src/touch_input.cpp)), enforced globally at [:589](../src/touch_input.cpp) **regardless of the configured `poll_interval_ms`**, whose per-controller value at [:600](../src/touch_input.cpp) can only ever slow polling further. (Note: [opendisplay_structs.h](../include/opendisplay_structs.h) documents `0 = 25 ms default`; the firmware uses 100 and cannot go below it. Header/behaviour divergence, documentation-only, not fixed here.) + - **So: no nine-clock SDA recovery, no new state machine.** The only residual is that each failing transaction blocks for the Arduino default (~50 ms on ESP32) since `Wire.setTimeOut()` is never called — worst case ~250 ms of blocked `loop()` before the controller is disabled. Bounded and acceptable. **Optional**: add `Wire.setTimeOut(25)` after each `Wire.begin()` (incl. `wireBeginForOpenDisplay`, [display_service.cpp:785-800](../src/display_service.cpp)) to halve that window. A wedged GT911 costs touch until reboot; it cannot freeze the device. +- Loop command drain: 2 s wall-clock cap alongside the count cap. +- `[L3]` Delete or rename the inert `-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` in every ESP env — the IDF 5.x symbol is `CONFIG_ESP_TASK_WDT_TIMEOUT_S` and the precompiled `sdkconfig.h` wins regardless. Leaving a dead knob that reads like a 120 s guarantee misleads the next reader. Add a comment that the real TWDT is 5 s/panic on IDLE0 and that today's long waits survive only because they all yield. + +### Phase 3 — `abortToKnownState()` + queue flushes + drain-trap fix `(was Phase 1)` +New `src/session_guard.h/.cpp` (both targets; ESP32 parts `#ifdef TARGET_ESP32`, LAN parts `#ifdef OPENDISPLAY_HAS_WIFI` — **not** `TARGET_ESP32`, since `esp32-N4` is ESP32 without WiFi). +- Flags: `commandDrainAbortPending`, `commandQueueOverflowAbort`, `responseQueueOverflowAbort`; `g_lastProgressMs` + `markSessionProgress()`. +- `flushCommandQueue()` / `flushResponseQueue()` in main.cpp; loop-task only; `tail := head` snapshot (SPSC-safe — tail has a single writer). +- `abortToKnownState(reason, dropLink)`: log first → optional client NACK (skip when dropping link) → set drain-abort flag → flush command ring → `cleanupDirectWriteState(true)` → `cleanupPartialWriteOnDisconnect()` → `resetPipeWriteState()` → **new** `resetChunkedWriteState()` → **new** `touchForceResume()` → buzzer/LED stop → `epdSessionForceOff()` **only if** `!epdRefreshInProgress` → `clearEncryptionSession()` → flush response ring → if dropLink: disconnect → release owner token → `markSessionProgress()`. +- `[M3]` `touchForceResume()` must also clear `directWriteTouchSuspended` ([display_service.cpp:2035-2038](../src/display_service.cpp)) and assert the counter reached 0. Keep the stated ordering (`cleanupDirectWriteState` first). +- `[M5]` **Drain-trap fix — exact placement**: the check goes **between** [main.cpp:415](../src/main.cpp) and `:416`, i.e. immediately after `imageDataWritten` returns and *before* `commandQueue[tail].pending = false`, breaking without the tail store. Placed after `:416` it writes into a slot the producer may have re-filled. While there, delete the vestigial `pending` field — it has no readers in either ring. +- `[H4]` `g_commandInFlight` is a `volatile uint8_t` **depth counter**, not a bool. Bluefruit's `ada_callback_invoke` falls back to invoking the write callback **inline on the BLE task** when `rtos_malloc` fails (`BLECharacteristic.cpp:538-542`), so "single task on nRF" is not an invariant — heap pressure during a large transfer is exactly when it breaks. + +### Phase 4 — Connection exclusivity `(was Phase 4, corrected)` +- Owner token: `OWNER_NONE/BLE/LAN`, `linkClaim()`/`linkRelease()`. +- `[C3]` **Refuse with `BLE_ERR_REM_USER_CONN_TERM` (0x13), NOT `BLE_ERR_CONN_LIMIT` (0x09).** `NimBLEServer::disconnect` forwards to `ble_gap_terminate` (`NimBLEServer.cpp:321-332`) and 0x09 is not in the Core Spec's legal `HCI_Disconnect` reason allowlist — the controller rejects it with 0x12 and the gatecrasher stays connected while the code looks like it worked. Check the `bool` return; log WARN on failure. +- `[C4]` **Do not let the refusal re-enter the shared cleanup.** `onDisconnect` ([esp32_ble_callbacks.h:57-70](../src/esp32_ble_callbacks.h)) is a blind flag-setter, and the `ownerStillUp` guard is inside `#ifdef OPENDISPLAY_HAS_WIFI` ([main.cpp:328-338](../src/main.cpp)) — so on **`esp32-N4`** a refused stranger's disconnect tears down the incumbent's live transfer (a new remote DoS). Two required changes: (a) capture the refused conn handle in `onConnect` and skip raising `bleDisconnectCleanupPending` for it in `onDisconnect`; (b) move the `ownerStillUp` early-return **out** of the `#ifdef` — its `getConnectedCount() > 0` half is unconditionally correct. +- `[M2]` Prefer **evicting an idle incumbent** over refusing a reconnect: after an abrupt client loss the link lingers until supervision timeout (4–32 s) and a returning client would be refused. If the incumbent has `!transferActive()` and last RX older than ~10 s, terminate the old link and accept the new one. Refuse only when the incumbent is actively transferring. +- LAN accept ([wifi_service.cpp:874-900](../src/wifi_service.cpp)): `linkClaim(OWNER_LAN)` or `incoming.stop()`; scope both `clearEncryptionSession()` sites to `OWNER_LAN`. +- Advertising while LAN owns: `esp32_set_ble_connectable(bool)` — stop → `setConnectableMode(NON/UND)` → re-push `setAdvertisementData` → start. Skip during the post-deep-sleep-wake window. `[M4]` **Check the return of both `setAdvertisementData()` and `start()`**; on failure force connectable mode, re-push, retry, and set `bleRestartAdvertisingPending` — otherwise a failed `start()` after a `stop()` leaves the radio permanently dark with nothing retrying. +- `[X7]` Add `advertisingHealthTick()`: no peer + not advertising (`getAdvertising()->isAdvertising()`) + no pending flag for >30 s → force restart, log WARN. Closes the "unresponsive but not frozen" hole at [ble_init.cpp:232-235](../src/ble_init.cpp), where a stale nonzero connection count *clears* the pending flag. + +### Phase 5 — Disconnect hardening `(was Phase 2, corrected — now lands after the owner token)` +- `[H2]` ESP32 `serviceBleDisconnectCleanup`: place `flushCommandQueue(); flushResponseQueue(); clearEncryptionSession();` **after** the (now unconditional) `ownerStillUp` early-return, scoped to `linkOwner() == OWNER_BLE`. Putting the clear before the guard would let every WiFi-lost tick ([main.cpp:452-457](../src/main.cpp)) destroy a live BLE session — the very bug Phase 4 exists to fix, from a second code path. +- `[H4]` nRF `disconnect_callback`: **defer** the session clear — set `nrfSessionClearPending`, service from `loop()` when the in-flight depth counter is 0. A `memset(session_key)` landing mid-`aes_ccm_decrypt` on the inline-fallback path is a real (if rare) race. +- Pipe NACK latch: add `error_since_ms` to `PipeWriteState` (genuine +4 B addition — the struct has no timestamp today) and a `pipeErrorTick()` in loop. `[L2]` Use **10 s**, not 60 s, and describe it honestly as a **hardware-release deadline** — py-opendisplay treats every `0x81` NACK as immediately fatal and never re-reads the ACK position, so the "client-retry window" rationale was fiction. Confirmed this does **not** break `docs/pipe-write-protocol.md` §5.1 (client-observable discard behaviour is unchanged) — add a line to §5.1 noting the reset. + +#### Session lifetime := connection lifetime + +**Disable `session_timeout_seconds` expiry.** `checkEncryptionSessionTimeout()` ([encryption.cpp:221-232](../src/encryption.cpp)) always returns true for an authenticated session; age-based expiry is removed. Encryption is scoped to the life of the connection and nothing else. + +- **No protocol change and no header edit.** The canonical field already documents `0 = no timeout (persists until disconnect)` ([opendisplay_structs.h:916](../include/opendisplay_structs.h)) — the firmware now behaves as if the field is always 0, which is an already-specified, already-supported value. The struct field stays (removing it is a cross-repo change); it simply becomes advisory-only on this firmware. Document it as ignored in `tools/od-device-cli.py`'s field notes. +- **No client breakage.** py-opendisplay's `_reauthenticate_if_needed` returns immediately when the value is 0 ([device.py:795-796](../../py-opendisplay/src/opendisplay/device.py)); with a legacy nonzero config it performs one unnecessary but harmless re-auth at 90% — that path is only reached from `_write`, never from `_write_pipe_frame`, so it cannot land mid-stream. Provision new units with 0 to skip the pointless handshake. +- **This removes a whole freeze class.** Expiry was evaluated inside `isAuthenticated()` on *every* command dispatch, so it could fire mid-transfer — deterministically wedging any upload longer than the configured timeout, since the client's proactive re-auth is skipped for the entire pipe stream. It also removes a query-with-side-effects: `isAuthenticated()` currently mutates session state as a side effect of being asked a question. +- With expiry gone, call site 1 of `clearEncryptionSession()` disappears, and site 2 (`handleAuthenticate`'s re-auth path, [encryption.cpp:583-585](../src/encryption.cpp)) simplifies to "authenticated → clear and re-challenge", which is the correct behaviour for a client-initiated re-auth. + +**Make a dead session with a live link impossible.** Add the guard inside `clearEncryptionSession()` itself ([encryption.cpp:238](../src/encryption.cpp)) rather than at each call site, so future callers cannot regress it: if a client is connected and the clear was not client-initiated, raise a flag that `loop()` services by dropping the link. A cleared session under a live link is invisible to the client — it keeps sending encrypted frames that all bounce `0xFE` and never re-authenticates mid-stream — so this is the difference between a recoverable error and a wedge. Surviving call sites and their disposition: + +> ### ⚠ Re-scoped: **Phase 1 already shipped a partial version of this guard** (`77ebdcd`, `23ecaed`) +> +> Phase 1's Scope boundaries said *"Do not add link-drop behaviour to `clearEncryptionSession()`. +> That guard is Phase 5."* A live bench failure forced the behaviour in early anyway, from the +> other end: `rejectUnauthenticated()` ([communication.cpp:114-166](../src/communication.cpp)) +> counts **consecutive `RESP_AUTH_REQUIRED` answers** and drops the BLE link at 10, serviced by +> `serviceBleAuthAbuseDisconnect()` ([:168-201](../src/communication.cpp)) from `loop()` on ESP32 +> and **inline** on nRF (where `loop()` runs at `TASK_PRIO_LOW` and is starved by the +> `TASK_PRIO_NORMAL` callback task during a flood). The counter is cleared by a successful decrypt +> ([:908](../src/communication.cpp)) and by a successful authentication +> ([encryption.cpp:691](../src/encryption.cpp)). +> +> **This does not complete Phase 5's guard — do not delete this section, and do not duplicate it +> either.** The shipped version keys on the *symptom* and only after ten wasted round trips; Phase 5 +> keys on the *event* and drops immediately. Phase 5 must therefore: +> +> 1. **Subsume, not duplicate.** Land the guard inside `clearEncryptionSession()` as specified, then +> reduce the Phase 1 counter to a backstop for the cases the clear-site guard cannot see +> (a client that never had a session at all, and the `NONCE_BAD_SESSION` desync path). Do **not** +> leave two independent disconnect requests racing each other. +> 2. **Fix the three defects the Phase 1 guard shipped with**, all recorded in +> [`PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md`](PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md) § `77ebdcd`: +> (a) the count is **not cleared on disconnect**, so a new client can inherit its predecessor's +> rejections — a one-line `resetAuthGateRejects()` in `disconnect_callback` +> ([device_control.cpp:227](../src/device_control.cpp)) and in `onDisconnect` +> ([esp32_ble_callbacks.h:57](../src/esp32_ble_callbacks.h)); (b) on ESP32 the guard identifies +> the offender as `getPeerInfo(0)` rather than the actual sender, because the command ring +> discards the conn handle — **fold this into Phase 4**, which is already widening connection +> identity; (c) the threshold of 10 is **below** py-opendisplay's default 16-frame pipe window +> (`device.py:2689-2694`), so a *legitimate* client whose session dies mid-upload trips it. That +> is probably the right outcome, but Phase 5 must decide it deliberately rather than inherit it. +> 3. **`[H4]` is discharged for the drop path only.** Verified against the Adafruit core: the +> Bluefruit disconnect callback is queued via `ada_callback` onto the *same* FreeRTOS task as the +> write callback, and `sd_ble_gap_disconnect()` is asynchronous — so an inline +> `Bluefruit.disconnect()` cannot unwind into the callback it is called from. `[H4]`'s actual +> hazard (a `memset(session_key)` landing mid-`aes_ccm_decrypt`) is untouched and still applies +> to the **session clear** this section specifies, which nRF's `disconnect_callback` does not do +> today. + +| Site | Disposition | +|---|---| +| [encryption.cpp:585](../src/encryption.cpp) re-auth challenge | **Exempt** — client-initiated, expects a new session | +| [encryption.cpp:670](../src/encryption.cpp) `aes_cmac` failure | Exempt — aborts a session being born | +| ~~[encryption.cpp:695](../src/encryption.cpp) /~~ [:794-798](../src/encryption.cpp) `integrity_failures >= 3` | Must drop the link. **Phase 1 removed the nonce trigger as planned** — there is now exactly **one** call site, the CCM-tag arm; the pre-decrypt site at old `:695` no longer exists. | +| [communication.cpp:204](../src/communication.cpp) `reloadConfigAfterSave` | Must drop the link — a config write always arrives over a live link, and security settings may have changed. *(Shifted down ~136 lines by Phase 1's auth-guard block.)* | +| [wifi_service.cpp:804](../src/wifi_service.cpp) / [:879](../src/wifi_service.cpp) LAN | Scope to `OWNER_LAN` (Phase 4) | +| [config_parser.cpp:883](../src/config_parser.cpp) boot load | Exempt — not connected | + +**Note in code that deep sleep already wipes the session.** `encryptionSession` is plain `.bss` ([main.h:289](../src/main.h)), not `RTC_DATA_ATTR`, so a deep-sleep cycle destroys it regardless. Comment this so nobody later "optimises" it into RTC memory — persisting the key and `last_seen_counter` across sleeps would reintroduce the replay vector Phase 1 closes. + +### Phase 6 — The 10-minute supervisor `(was Phase 6, corrected)` + +> ⚠️ **Phase 6 inherited work from the Phase 2 scope cut (2026-07-26).** Phase 2 no longer bounds +> `pwrmgmLockTake`, no longer bounds `powerOff`'s stuck-button wait, and — most significantly — no +> longer detects a stalled `loop()` on either target (the loop-liveness monitor was dropped). ESP32's +> TWDT will not cover the gap: every long wait yields, so IDLE0 is never starved and it does not +> fire. nRF has no watchdog at all and none of the ESP32 wall-clock watchdogs run there. +> +> So the supervisor is now the **first and only** thing that notices a stall, not the last line of a +> layered defence. Two consequences for this phase: its nRF arm moves from "must not be forgotten" +> to load-bearing, and a panel-lock holder that never releases is a fault class it must survive +> without any upstream bound or signal. + +- `[C1]` **Progress means the state machine advanced — never "a command arrived" or "a notify succeeded."** After `clearEncryptionSession()`, [communication.cpp:664-670](../src/communication.cpp) answers every retry with `RESP_AUTH_REQUIRED` — a dispatch *and* a notify per retry — so dispatch/notify stamps keep `g_lastProgressMs` fresh forever and the supervisor never fires in the exact wedge it was built for. Stamp **only** at: `pipeState.expected_seq` advancing (inside the in-order accept), `directWriteBytesWritten` increasing, `chunkedWriteState.receivedChunks` incrementing, `partialCtx` byte counter advancing, refresh completion, and `handleAuthenticate` success. **Not** on command dispatch, notify, or LAN frame dispatch. +- Wedge: `(transferActive() || chunkedWriteState.active || directWriteActive) && now - g_lastProgressMs > 600000` → if `epdRefreshInProgress`, log and retry next pass; else `abortToKnownState("supervisor", true)`. +- `[H3]` **Keep the existing wall-clock watchdogs** ([main.cpp:436-442](../src/main.cpp), `checkPartialWriteTimeout`) as a backstop, raised to 20 min. They key on *start* stamps nothing refreshes, so they bound cases a progress predicate can't. Delete them only after hardware soak proves the progress arm fires. +- **nRF has NO transfer watchdog today — H3 is "keep" on ESP32 but "ADD" on nRF.** Both 900 s bounds live inside the `#ifdef TARGET_ESP32` arm of `loop()`: the direct-write check at [main.cpp:438](../src/main.cpp) and the `checkPartialWriteTimeout()` call beside it. The nRF `loop()` body is the `#else` arm and evaluates neither, so on nRF a stalled transfer is bounded by **nothing** — not today, and not by H3's "backstop" unless it is explicitly added there. Neither the original plan nor the adversarial review caught this. The supervisor and the wall-clock backstop must both be wired into the nRF `loop()` path, and the nRF hardware soak must cover a stalled transfer explicitly rather than assuming ESP32 parity. +- `[X5]` Add `transferActive()` to the `workInFlight` disjunction ([main.cpp:474-479](../src/main.cpp)) so a latched transfer with a dropped link can't reach `enterDeepSleep()` with the panel rail up. +- `[H4]` Abort only when the in-flight depth counter is 0. If stale >10 min while in-flight, log an ERROR heartbeat — Phase 2's bounds are the recovery story. +- No hardware WDT (user decision). Comment the OTA-exception rationale. +- `[X4]` Config chunked-write has no timer of its own ([communication.cpp:496](../src/communication.cpp) set, cleared only on completion/malformed/auth-fail) — it is covered by the supervisor predicate *once C1 is fixed*. Dependency noted deliberately. + +### Phase 7 — Queue-full handling + BLE idle timeout `(was Phase 5, corrected — must land after Phase 6)` +- `[H1]` **Command-ring overflow logs and drops; it does NOT drop the link.** The pipe protocol is designed for a dropped frame (zero bit in the next SACK → client retransmits that chunk, `docs/pipe-write-protocol.md` §5.2) — one round trip vs. a link drop + re-auth + restarted transfer. Escalate to `abortToKnownState` only if overflow recurs while `transferActive()` **and** `g_lastProgressMs` is already stale, i.e. let the supervisor own the decision. Also fix the off-by-one in the [main.h:365-370](../src/main.h) comment: usable capacity is `COMMAND_QUEUE_SIZE - 1 = 32`, not 33 (producer refuses at `nextHead == tail`), so the documented "W=32 window + END" claim is false — bump `COMMAND_QUEUE_SIZE` to 34 on envs with DRAM to spare (**not** `esp32-N4`). +- Response-ring overflow: flag, serviced in loop, gated on `transferActive()`. +- **BLE idle disconnect.** `OD_BLE_IDLE_DISCONNECT_MS = 300000` (5 min; 0 disables). Gate on peer connected && `!epdRefreshInProgress`, then drop the link. + + **Definition of activity (authoritative):** *a chunk arriving in the command queue carrying a **valid command**, or a **continuation of a data upload**.* Nothing else stamps `g_lastLinkActivityMs`. + + `[C1]` "Valid" is the load-bearing word and it is what makes this timer resistant to the defect that killed the original RX-keyed design. Validity is not knowable at queue-insert time — `onWrite` runs on the NimBLE host task before decryption — so the stamp goes in `imageDataWritten` **after** the decrypt/auth gate passes ([communication.cpp:663-711](../src/communication.cpp)) **and** the opcode resolves to a known command (`commandName(command) != nullptr`, i.e. not the `default:` unknown-opcode branch). A post-`clearEncryptionSession()` retry flood therefore never stamps: every frame short-circuits to `RESP_AUTH_REQUIRED` at [communication.cpp:664-670](../src/communication.cpp) before reaching the stamp, and the link is dropped at 5 min. + + "Continuation of a data upload" means a `0x0071`/`0x0081` frame that was **accepted** (consumed in order, or queued in the reorder window) — **not** one silently discarded because `pipeState.error` is latched ([display_service.cpp:2811](../src/display_service.cpp)). Counting discarded frames would let a client retransmitting into a dead pipe hold the link forever, which is the same defect one layer down. + + **Relationship to the supervisor's `g_lastProgressMs` (Phase 6):** two distinct signals, deliberately. Idle activity answers *"is the client still talking sense?"*; supervisor progress answers *"is the state machine advancing?"* They compose: a client politely polling battery status every minute is active (valid commands) and correctly not dropped, while making no transfer progress — and the supervisor doesn't fire either, because its wedge predicate requires `transferActive()`. A client flooding undecryptable frames trips the idle timer at 5 min; a client sending valid frames into a stalled transfer trips the supervisor at 10 min. + + **LAN keeps its 30 s ([opendisplay_protocol.h:984](../include/opendisplay_protocol.h)); the asymmetry is a deliberate design choice, not an oversight.** LAN is a machine-to-machine push transport: a client connects, pushes, and closes, so 30 s of silence means it is gone or broken — drop it fast and free the socket. **Only BLE carries interactive sessions**, where a human using the web configurator or the HA UI legitimately pauses between commands (reading config, composing a change, waiting on a slow refresh). A 30 s BLE timeout would break interactive use; 5 min tolerates human latency while still bounding the hostage window. BLE reconnect is also far more expensive than a TCP reconnect — advertising, connection setup, re-auth, and possibly a deep-sleep wake. + + **The constant stays firmware-local.** Mirroring LAN by promoting it to the canonical header would be a cross-repo change through `../opendisplay-protocol` plus a `--push` to all four firmware repos; not warranted until the behaviour is proven on hardware. Revisit only if a client ever needs to read the value. + + **Interaction with deep sleep is cooperative, not adversarial.** `pollActivity()` refreshes `lastActivityMs` every pass while a client is connected ([main.cpp:243-258](../src/main.cpp): *"A live link … is activity in itself"*), so today a silent client pins the device out of deep sleep indefinitely — the hostage case. Dropping the link makes `connCount` fall to 0, `lastActivityMs` stops being refreshed, and the existing `sleep_timeout_ms` hold elapses normally. No change to the sleep gates is required. + +--- + +## Files touched +`src/session_guard.h/.cpp` (new), `src/main.cpp`/`main.h`, `src/encryption.cpp`/`encryption_state.h`, `src/display_service.cpp`, `src/wifi_service.cpp`, `src/esp32_ble_callbacks.h`, `src/ble_init.cpp`, `src/device_control.cpp`, `src/power_latch.cpp`, `src/display_fastepd.cpp`, `src/touch_input.cpp`, `src/communication.cpp`, `src/structs.h`, `platformio.ini`, `docs/pipe-write-protocol.md`. + +## Verification +- Session lifetime: verify a transfer longer than any legacy `session_timeout_seconds` completes untouched; verify a client-initiated re-auth mid-connection still works; verify a captured last-frame replayed after reconnect is now REJECTED (the `counter_diff == 0` hole). +- Per phase: `pio run -e nrf52840custom -e esp32-s3-N16R8 -e esp32-c3-N16 -e esp32-c6-N4 -e esp32-N4`. CI builds all **12** on push (the matrix grew; several places in these plans still say 11). ~~**`esp32-N4` is the gate** for Phase 1's `replay_window[256]` (+1.5 KB `.bss`) — if it won't link, drop to ±64.~~ **Moot as of Phase 1:** the sliding bitmap made the struct **480 B smaller**, and `esp32-N4` links at 81,468 B / 24.9% RAM — below where it started. A `host-tests` CI job now also compiles and runs `tools/test_nonce_window.cpp` under UBSan/ASan on every push. +- Hardware (py-opendisplay CLI): forward-gap nonce test (skip 100 counters) → session survives; true replay → rejected, session survives; kill client mid-pipe-window → reconnect, re-auth, clean push; forced pipe NACK → touch recovers ≤10 s; second BLE central → **verify on-air with a sniffer or `nRF Connect` that the refusal actually terminates** (this is the C3 trap); LAN connect during BLE session → accept-then-close; BLE connect while LAN owns → refused, telemetry still advertising; silent client 5+ min → dropped, advertising resumes, deep sleep reachable; stalled pipe with live connection **that keeps sending doomed frames** → supervisor still cleans at 10 min (this is the C1 regression test); regression: full Spectra transfer (60 s+ refresh) and an E1004 ~960 KB upload complete untouched. +- Every recovery action logs one ERROR/WARN line with reason + counters. +- Final soak: 24 h cron'd pushes alternating BLE/LAN with ~10% induced client kills. + +## Deliberately NOT changed + +Recorded so implementation does not "fix" these and review does not re-litigate them. Both were surfaced by `TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md`; both are decided. + +- **The 30 s auth-challenge validity window stays exactly as-is.** Step 2 of `CMD_AUTHENTICATE` must arrive within 30 s of the challenge — stamped at [encryption.cpp:587-588](../src/encryption.cpp), enforced at [:604-608](../src/encryption.cpp). This is a **cross-repo wire contract**, not a firmware-local knob: [opendisplay_protocol.h:384](../include/opendisplay_protocol.h) specifies *"STEP 2 must arrive within 30 s of the challenge or it is rejected"* with `@targets: Firmware | NRF54 | Silabs | NRF52811`. Changing it would require an edit in `../opendisplay-protocol` plus a `--push` to four firmware repos. It is also **not** a liveness timer and cannot wedge anything — a late step 2 gets `AUTH_STATUS_ERROR` and the client restarts the handshake. Phase 1 restructures `encryption.cpp` but **must not touch this check**, including the known boot-window quirk (`server_nonce_time = 0` from `clearEncryptionSession` means a step 2 with no preceding step 1 passes the freshness test during the first 30 s of uptime; not exploitable, since the MAC still requires the master key). Correct Phase 5's "scoped to the connection **and nothing else**" phrasing to acknowledge this second, independent encryption time bound — wording only, no code. + +- **No new bounding timer for the buzzer or the LED.** `abortToKnownState` stops both as part of teardown (Phase 3) — that is a teardown action, not a timer, and it stays. Nothing further is added. For the record, and correcting review finding `[X6]` which lumped them together: + - **Buzzer is already bounded** — `kBuzzerMaxTotalMs = 30000` ([buzzer_control.cpp:17](../src/buzzer_control.cpp), enforced at `:168`). No gap. *(The source comment there claims a "5 s cap" against a 30 s constant; comment-only defect, left alone.)* + - **LED flash is genuinely unbounded** — `processLedFlash` has no global cap equivalent. **Accepted as-is.** A stuck LED sequence wastes power but cannot freeze the device: it holds no lock, blocks no task, and is cleared by `abortToKnownState`, by disconnect teardown, and by an explicit `0x0075` LED_STOP. + +## Explicit non-goals / residual risk +- A true CPU/peripheral hard hang remains detect-and-log only — accepted with the software-only decision. **But "recoverable by power cycle" is weaker than it sounds on latching devices.** The button *press* is captured by an ISR ([device_control.cpp:679-693](../src/device_control.cpp)), but the hold-duration evaluation and the power-off action run in `processButtonEvents()`, which is called **only** from `loop()`/`idleDelay()` ([main.cpp:481](../src/main.cpp), [:514](../src/main.cpp), [:527](../src/main.cpp), [:542](../src/main.cpp)) — and the power-off hold test itself is at [device_control.cpp:78](../src/device_control.cpp). So while `loop()` is blocked (e.g. inside a 60 s `waitforrefresh`, whose `delay(10)` yields to FreeRTOS but never services buttons), a long-press does not trigger power-off. On a `DEVICE_FLAG_BATTERY_LATCH` unit with no way to interrupt the rail, the user's fallback is unavailable for the duration of the block. This does not change the software-only decision, but it means the residual risk is "wait out the block or remove the battery", not "hold the button". +- No config-schema changes (timeouts are compile-time constants in v1). +- The client-side nonce burn (py-opendisplay) is untouched; firmware-side widening makes it safe. +- ~~`[M1]` A local attacker with a captured frame can still jam `last_seen_counter` forward within the window and stall a session; the supervisor is the recovery path. Symmetric windowing keeps the stall bounded by the ring.~~ **Withdrawn — this risk does not survive Phase 1.** With commit-after-verify, only a CCM-authenticated frame advances `last_seen_counter`, so an attacker can only re-commit counters the client genuinely transmitted and can never push past the client's own high-water mark. Repairs then carry fresh, higher counters, so no future client frame falls below the window. The jam was an artifact of the value-ring design. +- **Residual risk that replaces it:** a forward gap beyond `OD_NONCE_FORWARD_CAP = 128` is still reachable on a pathological link with `blocks_per_ack = 1` and a multi-thousand-chunk upload. Step 4b makes it non-fatal (silent drop → SACK repair) rather than impossible — **and that repair path has never been observed on hardware.** diff --git a/docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md b/docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md new file mode 100644 index 0000000..cda8129 --- /dev/null +++ b/docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md @@ -0,0 +1,977 @@ +# 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** + +> **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? diff --git a/docs/PLAN_PHASE2_BOUND_WAITS_2026-07-26.md b/docs/PLAN_PHASE2_BOUND_WAITS_2026-07-26.md new file mode 100644 index 0000000..21f72ce --- /dev/null +++ b/docs/PLAN_PHASE2_BOUND_WAITS_2026-07-26.md @@ -0,0 +1,1465 @@ +# Phase 2 Implementation Plan — Bound Every Unbounded Wait + +> # ⛔ OBSOLETE — SUPERSEDED +> +> **This plan is no longer the Phase 2 specification.** It was written for a seven-item Phase 2; +> five of those items (P2-1, P2-2, P2-5, P2-6, P2-9) were subsequently cut, leaving a document that +> is mostly struck-through material. +> +> **The current plan is +> [`PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md`](PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md)** — three +> items (P2-3, P2-8, P2-4), two files, written from scratch against the current tree. +> +> **Do not implement from this document.** It is retained for one reason: the analysis behind the +> cut items is expensive to reproduce and is what stops each being re-proposed — +> `[C2]`'s do-not-steal argument for the panel lock, P2-5's arithmetic showing a drain cap is +> powerless, P2-9's rejected alternatives (nRF hardware WDT, idle-hook heartbeat), and the eight +> decisions those items carried. Read it as an appendix to the current plan, not as a plan. + + +**Branch:** `debug/freeze-fix-phase2` (branched from Phase 1 as-built) · **Date:** 2026-07-26 +**Parent plan:** [`PLAN_FREEZE_PROOFING_2026-07-26.md`](PLAN_FREEZE_PROOFING_2026-07-26.md) § "Phase 2" +**Review that shaped it:** [`FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md`](FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md) `[C2] [X1] [X2] [X3] [L3]` + +Phase 2 bounds the **refresh waits**. It adds no new subsystem, no new state machine, and after the +2026-07-26 scope cut no new file and no new task: it puts a wall-clock bound on each place where `loop()` can block indefinitely, and +it makes the one gate the supervisor depends on (`epdRefreshInProgress`) actually cover every +refresh. + +> **Scope, in one line:** three items — P2-3, P2-8, P2-4 (+ optional P2-7) — touching +> `src/display_service.cpp` and `src/display_fastepd.cpp` and nothing else. Everything cut is in the +> appendices, struck through, with its residual recorded. Nothing blocks implementation. +> +> **Adjusted 2026-07-26 for Phase 1 as-built.** Phase 1 has shipped (`02bdd5c`..`d62cb29`) and this +> branch is cut from it, so the "independent, can land before or after" framing is now moot in +> practice: Phase 2 lands *on top of* Phase 1. Three concrete consequences, folded in below. +> +> 1. **The `esp32-N4` RAM gate moved in Phase 2's favour, not against it.** Phase 1 did *not* ship +> `replay_window[256]` (+1,536 B) as this plan assumed. Decision B changed to a 32 B sliding +> bitmap replacing the 512 B ring, so Phase 1 **gave back 480 B**: `esp32-N4` measured +> 81,940 → **81,468 B**. P2-9's ~2 KB task stack has *more* headroom than budgeted here. +> 2. **P2-9's core premise is now confirmed by shipped code, not just by reading the core.** Phase 1's +> `23ecaed` was forced to drop the BLE link *inline from the nRF callback task* precisely because +> `loop()` is starved mid-transfer — the same starvation P2-9 exists to observe. The priority +> facts in the table below were independently re-verified during that work. +> 3. **Phase 1 pulled Phase 5's link-drop forward** (an auth-gate guard that disconnects after 10 +> consecutive unauthenticated commands). Phase 2 does not interact with it, but it means an +> out-of-`loop()` actor that drops the link **already exists** on nRF — relevant context for D-H +> and D-I, which assumed P2-9 would be the first such actor. Neither decision is reopened here. +> +> Line references to `src/main.cpp` and `src/communication.cpp` in this plan were taken against +> `02bdd5c`; Phase 1 modified both, so re-anchor before editing rather than trusting a line number. + +**Both targets, or it does not count.** Every bound here must be *binding* on `nrf52840custom` as +well as the ESP32 envs — executing, measured in a clock that keeps running, and observable when +violated. Most of the original findings were ESP32-shaped and four of them compile out on nRF; § +"Making the bounds binding on both targets" works through the coverage per item and adds the two +items (**P2-8**, **P2-9**) needed to close the nRF side. + +**Scope discipline.** Phase 2 must remain self-contained — `src/session_guard.*` does not exist +yet (Phase 3) and `abortToKnownState()` cannot be called from here. Where Phase 2 produces a +signal that Phase 3 will consume, it exports a flag and a getter and nothing else. Those +hand-off points are marked **→ Phase 3** below. + +--- + +## Verification done before writing this plan + +Two of the parent plan's five Phase-2 findings did not survive re-verification against the actual +sources. `[X1]` was already downgraded by the parent plan. `[X3]` is downgraded here for the same +reason: the claim was made from the shape of the firmware-side stub without reading the library +underneath it. + +| Parent claim | Verified status | +|---|---| +| `[C2]` `pwrmgmLockTake` spins forever; a steal is unsafe | **CONFIRMED.** [display_service.cpp:401-408](../src/display_service.cpp) is `while (exchange(&pwrmgmLock,1)) delay(1);` with no deadline. Legit holds really do reach 30 s: `bbepWaitBusy` uses `iMaxTime = 30000` for `BBEP_3COLOR\|4COLOR\|7COLOR` (`bb_ep.inl:3966-3968`), and `epdSessionForceOffLocked` holds the lock across that. The steal is unsafe exactly as described — `pwrmgmLock` is a bare `volatile uint8_t` ([main.h:185](../src/main.h)) with no owner field. | +| `powerOff` stuck-button loop | **CONFIRMED.** [power_latch.cpp:85-90](../src/power_latch.cpp) — `while (digitalRead(buttonPin()) == LOW) delay(20);` with no bound. ESP32-only file (`#if defined(TARGET_ESP32)`, [:3](../src/power_latch.cpp)). | +| `[X2]` boot refreshes bypass `epdRefreshInProgress` | **CONFIRMED.** The flag is set in exactly two places — [display_service.cpp:2415/2436](../src/display_service.cpp) (direct-write END refresh) and [:3284/:3294](../src/display_service.cpp) (partial refresh). Neither boot path sets it: `refreshBootScreenFull` [:533-542](../src/display_service.cpp) and the FastEPD boot path [:1586-1594](../src/display_service.cpp). | +| `[X3]` FastEPD refresh is unbounded | **WRONG — DOWNGRADED.** Every FastEPD/IT8951 wait in the vendored library is already bounded: `it8951WaitForLUTReady` breaks at **30 000 ms** (`FastEPD.inl:2027-2037`) and `it8951WaitForReady` breaks at **3 000 ms** (`:1908-1919`). `bbepFullUpdate` on a parallel panel is a fixed-trip-count DMA loop with no busy-wait at all (`:2732-3040`), and on `BB_PANEL_IT8951` it short-circuits to `it8951WriteFramebuffer*Bit` (`:2745-2754`), which waits LUT-ready at entry. **There is a real defect here, but it is the opposite one** — see D-D. | +| `[L3]` `CONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` is inert | **CONFIRMED.** The symbol appears in 9 ESP envs (`platformio.ini:53, 83, 112, 140, 189, 209, 229, 253, 295`) and in **no** IDF 5.x sdkconfig. The real setting, from the precompiled `sdkconfig.h`, is `CONFIG_ESP_TASK_WDT_TIMEOUT_S 5` with `CONFIG_ESP_TASK_WDT_PANIC 1` and `CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 1`. So the true TWDT is **5 s / panic-reboot on IDLE0 starvation**, and today's 30–60 s waits survive only because every one of them yields (`delay`/`vTaskDelay`/`bbepLightSleep`). | + +--- + +## What Phase 2 changes + +Eight independent work items (P2-5 dropped — see D-F). Each is separately revertable; none depends +on another. + +| # | Item | Files | Targets | Risk | +|---|---|---|---|---| +| ~~P2-1~~ | ~~Bound `pwrmgmLockTake` (60 s deadline, no steal)~~ — ❌ **DROPPED** (owner decision) | — | — | — | +| ~~P2-2~~ | ~~`powerOff` stuck-button wait bounded at 10 s~~ — ❌ **DROPPED** (owner decision) | — | — | — | +| **P2-3** | `epdRefreshInProgress` around both boot-refresh paths | `display_service.cpp` | both | Low | +| **P2-4** | Make `fastepd_wait_refresh` a real bounded LUT wait | `display_fastepd.cpp` | ESP32/E1004 | Medium | +| ~~P2-5~~ | ~~Wall-clock cap on the loop command drain~~ — ❌ **DROPPED** (rationale did not survive checking; see D-F) | — | — | — | +| ~~P2-6~~ | ~~Delete the inert TWDT flag; document the real one~~ — ❌ **DROPPED** (owner decision) | — | — | — | +| **P2-7** | *(optional)* `Wire.setTimeOut(25)` — **ESP32 only, the API does not exist on nRF** | `display_service.cpp` | ESP32 | Low | +| **P2-8** | `waitforrefresh` → wall-clock deadline, not an iteration count | `display_service.cpp` | **both (nRF-critical)** | Low | +| ~~P2-9~~ | ~~Loop-liveness heartbeat + monitor task~~ — ❌ **DROPPED** (owner decision) | — | — | — | + +> **Scope cut 2026-07-26 (owner decision): P2-1, P2-2, P2-6 and P2-9 are DROPPED**, joining P2-5. +> Phase 2 is now **P2-3, P2-4, P2-8**, with P2-7 still optional. Each dropped item keeps its +> full specification below, struck through, with its residual cost stated. +> +> What survives is exactly the refresh path: the real `waitforrefresh` deadline (P2-8), the real +> FastEPD wait (P2-4), and `epdRefreshInProgress` covering the boot paths (P2-3). Everything that +> added a new mechanism is gone (the lock deadline, the button bound, the monitor task), and so is +> the one documentation-only item (P2-6). **Three items, two files, one subsystem.** +> +> **Net effect on the phase's own thesis.** Phase 2 opened by defining a bound as binding only if it +> (1) executes, (2) has a live timebase, and (3) is observable by a third party. With P2-9 dropped, +> **condition 3 is satisfied by nothing on either target**, and with P2-1 dropped the panel lock +> fails condition 1 as well. Phase 2 is now a *narrower* claim than it set out to make: it bounds +> the refresh paths, and defers detection of a stalled `loop()` to Phase 6. + +Suggested landing order: **P2-3 → P2-8 → P2-4** (+ P2-7 if D-G flips). All three are low-risk +and independent. Phase 2 no longer touches `src/main.cpp` or `platformio.ini` at all, so there is no conflict with +Phase 3's `[M5]` drain-trap fix. + +--- + +## Making the bounds binding on both targets + +The first draft of this plan was implicitly ESP32-shaped: four of the seven original items compile +out on nRF, and the one enforcement mechanism it leaned on (the IDF task watchdog) does not exist +there. This section states what "binding" has to mean, checks each item against it per target, and +added the two items (**P2-8**, **P2-9**) needed to close the nRF side. + +> **After the scope cut, only P2-8 of those two survives.** P2-9 was the answer to condition 3, so +> the analysis below still stands as *diagnosis* but Phase 2 no longer *treats* the third condition. +> Read the coverage table as "what Phase 2 bounds" (P2-3, P2-4, P2-6, P2-8) plus a record of what +> was knowingly left unbounded (P2-1, P2-2) and unobserved (P2-9). + +### A bound is binding only if all three hold + +1. **It executes on that target** — the deadline code is not `#ifdef`'d away, and its consumers exist. +2. **Its timebase advances while the thing it bounds is stuck** — a deadline measured in a clock that + stops when the fault occurs is not a deadline. +3. **A violation is observable by something other than the blocked party** — otherwise the bound + only protects against faults that were already going to resolve. + +(1) is a coverage question, (2) is an nRF timebase question, (3) is the "watchdog" question proper. + +### Verified platform facts + +ESP32 facts are from the precompiled `sdkconfig.h` (see `[L3]` above). nRF facts are from +`~/.platformio/packages/framework-arduinoadafruitnrf52-seeed`, the core the `nrf52840custom` env +actually builds against (`platformio.ini:29-32`). + +**The two nRF priority rows are no longer theory.** Phase 1's `23ecaed` had to move the BLE +link-drop *inline into the callback task* because deferring it to `loop()` did not execute during a +transfer — the loop task (priority 1) is starved by the callback task (2) and the Bluefruit task +(3), and on nRF `loop()` reaches its service calls only after `idleDelay(sleep_timeout_ms)`. That is +the same starvation P2-9 exists to detect, now observed on hardware rather than inferred. It +strengthens P2-9's case and is the strongest single argument that a loop-serviced bound is **not** +binding on nRF mid-transfer — the premise behind condition 2 below. + +The same work established that `Bluefruit.disconnect()` is safe from callback context (it defers to +`sd_ble_gap_disconnect()`, and the disconnect callback is serialized behind the write callback on +the one `ada_callback` queue). Useful precedent if any Phase 2 item is ever tempted to act from +outside `loop()` — though nothing in Phase 2 currently needs to. + +| | ESP32 (Arduino / IDF 5.5.4) | nRF52840 (Adafruit/Seeed core) | +|---|---|---| +| RTOS | FreeRTOS, dual-core (S3/classic), single (C3/C6) | FreeRTOS, single core, `configMAX_PRIORITIES 5`, tick **1024 Hz** (`FreeRTOSConfig.h:55-56`) | +| `loop()` task priority | 1 | 1 — `TASK_PRIO_LOW` (`cores/nRF5/main.cpp:88`, `rtos.h:58`) ✅ re-verified in Phase 1 | +| Higher-priority tasks | NimBLE host task | Callback task = 2, **Bluefruit task = 3** (`rtos.h:59-61`) ✅ re-verified in Phase 1 | +| `delay()` | `vTaskDelay` — yields | `vTaskDelay` — yields (`cores/nRF5/delay.c:33-49`) | +| **`millis()` source** | `esp_timer_get_time()/1000` — **hardware timer** | **`tick2ms(xTaskGetTickCount())` — FreeRTOS tick** (`delay.c:29-31`, `rtos.h:65`) | +| Tickless idle | n/a for the timebase | `configUSE_TICKLESS_IDLE 1` (`FreeRTOSConfig.h:52`) | +| Task watchdog | `CONFIG_ESP_TASK_WDT_TIMEOUT_S 5`, `_PANIC 1`, `_CHECK_IDLE_TASK_CPU0 1` | **none** | +| Hardware WDT in use | no | **no** — `NRF_WDT` never started; no `wdt` symbol anywhere in `src/` | +| Idle hook | via `esp_register_freertos_idle_hook()` | `configUSE_IDLE_HOOK 1`, `vApplicationIdleHook` is a **weak alias to `__empty`** (`cores/nRF5/hooks.c:33`) — free to override | +| Reset-cause reporting | `esp_reset_reason()`, decoded at [main.cpp:26-37](../src/main.cpp) | nothing equivalent wired up | + +### Item-by-item coverage + +| Item | ESP32 | nRF | Action | +|---|---|---|---| +| ~~**P2-1**~~ lock deadline ❌ dropped | — | — | **left unbounded**; `pwrmgmLockTake` keeps its infinite spin on both targets | +| ~~**P2-2**~~ `powerOff` ❌ dropped | — | **n/a** — whole file is `#if defined(TARGET_ESP32)` ([power_latch.cpp:3](../src/power_latch.cpp)); nRF has no latch path and therefore no stuck-button loop | none | +| **P2-3** `epdRefreshInProgress` | ✅ 4 consumers | ⚠️ **flag is set but has ZERO consumers on nRF** — all four live in ESP32-only code ([ble_init.cpp:236](../src/ble_init.cpp), [main.cpp:322](../src/main.cpp), [:478](../src/main.cpp), + Phase 6) | set it anyway (correct, cheap, and Phase 6 adds the nRF consumers); **note the inertness in the commit message** | +| **P2-4** FastEPD | ✅ | **n/a** — `OPENDISPLAY_FASTEPD` is ESP32-only (`platformio.ini:54, 84, 113, 254`) | nRF's only refresh bound is `waitforrefresh` → **P2-8** | +| ~~**P2-5**~~ drain cap ❌ dropped | — | **n/a by design** — nRF has no command queue; `imageDataWritten` runs inline on the Bluefruit **callback task (prio 2)**, which *preempts* `loop()` (prio 1) | see "different failure mode" below | +| ~~**P2-6**~~ TWDT flag ❌ dropped | — | — | inert flag left in place on ESP32; nRF's watchdog absence left undocumented | +| **P2-7** `Wire.setTimeOut` | ✅ default 50 ms, settable | ❌ **API absent**, and the TWIM driver busy-spins with *no* timeout (`Wire_nRF52.cpp:166-181`) | ESP32-only; the nRF gap is **D-L** | + +Two structural observations fall out of that table: + +**The nRF `loop()` body is four function calls.** Lines [519-529](../src/main.cpp): `idleDelay`, +`ble_nrf_advertising_tick`, `processButtonEvents`, `processTouchInput`, `buzzerService`. Everything +else in `loop()` — the drain, `serviceBleDisconnectCleanup`, the 900 s direct-write watchdog at +[:436-442](../src/main.cpp), `checkPartialWriteTimeout()` at [:443](../src/main.cpp), the +`workInFlight`/deep-sleep gate — is inside the `#ifdef TARGET_ESP32` arm. This is the same finding +the parent plan records for Phase 6 ("nRF has NO transfer watchdog today"); it applies equally to +Phase 2's coverage and is why P2-9 must not be written as an ESP32 addition with an nRF port bolted on. + +**nRF's blocking failure mode is inverted, not absent.** On ESP32 a long command blocks `loop()`, +which is the only drainer, so everything stops. On nRF a long command blocks the **callback task +(prio 2)**, while `loop()` (prio 1) keeps running — so buttons, touch, buzzer and `epdSessionTick` +stay alive, but no further BLE writes are serviced. Worse, per the parent plan's `[H4]`, Bluefruit +falls back to invoking the write callback **inline on the BLE task (prio 3)** when `rtos_malloc` +fails, and *that* blocks the stack itself. nRF therefore needs no drain cap, but it does need an +observer that is not the loop task — which is exactly P2-9. + +### The nRF timebase hazard (condition 2) + +**Every deadline in this plan is `millis()`-based, and on nRF `millis()` is derived from the +FreeRTOS tick, not from hardware.** `millis()` is `tick2ms(xTaskGetTickCount())` (`delay.c:29-31`). +If the tick stops, every Phase 2 bound silently stops counting — precisely in the pathological case +it was written for. + +How much does this actually cost? Checked case by case: + +- **Cooperative blocking (what Phase 2 targets): SAFE.** Every long wait yields via `vTaskDelay`, so + the tick keeps running and `millis()` advances normally. `pwrmgmLockTake`'s `delay(1)`, + `waitforrefresh`'s `delay(10)`, and `bbepWaitBusy`'s `bbepLightSleep(20, …)` all qualify — and + note that `bbepLightSleep` is a **plain `delay()` off ESP32** (`bb_ep.inl:3947-3950`), so the + 30 s `bbepWaitBusy` cap that P2-1's 60 s is derived from is sound on nRF too. +- **Tickless idle: SAFE.** `configUSE_TICKLESS_IDLE 1` suppresses ticks during idle, but + `vPortSuppressTicksAndSleep` compensates the count on wake, so `millis()` stays monotonic. +- **Interrupts disabled / critical section / SoftDevice storm: NOT COVERED.** Ticks are lost and + never compensated; `millis()` stalls and the 60 s deadline never expires. + +The third case is the parent plan's already-accepted "true CPU/peripheral hard hang" residual, and +no software-only mechanism recovers from it. **The correct action is to document it, not to +engineer around it** — a DWT-cycle-counter timebase (`dwt_enable()` / `DWT->CYCCNT` are already +exposed in `cores/nRF5/delay.c:52-58`) would work but wraps every ~67 s at 64 MHz, needs wrap +accumulation, and buys nothing for a fault class we have already accepted. Recorded so it is not +rediscovered. + +--- + +## P2-3 — `epdRefreshInProgress` around both boot-refresh paths + +Two edits, both mechanical. The flag is `volatile bool` at +[display_service.cpp:85](../src/display_service.cpp), declared in +[display_service.h:77](../src/display_service.h). + +**bb_epaper boot path** — [display_service.cpp:533-542](../src/display_service.cpp). Wrapping it +here covers both call sites ([:1624](../src/display_service.cpp) and the retry at +[:1632](../src/display_service.cpp)): + +```c +static bool refreshBootScreenFull() { + if (!writeBootScreenWithQr()) { od_log_warn("Boot screen render failed"); return false; } + od_log_info("EPD refresh: FULL (boot)"); + touchSuspendForEpdRefresh(); + epdRefreshInProgress = true; + bbepRefresh(&bbep, REFRESH_FULL); + bool ok = waitforrefresh(60); + epdRefreshInProgress = false; + return ok; +} +``` + +**FastEPD boot path** — [display_service.cpp:1586-1594](../src/display_service.cpp). Set before +`fastepd_full_update()`, clear after `waitforrefresh(60)` and **before** `epdSessionForceOff()` +(the force-off is teardown, not refresh, and holding the flag across it would block the very +Phase-6 supervisor pass that might need to act). + +### Why this matters more than it looks + +Three consumers gate on this flag and all three are wrong today during a boot refresh: + +- [ble_init.cpp:236](../src/ble_init.cpp) — advertising restart deferral. +- [main.cpp:322](../src/main.cpp) — `serviceBleDisconnectCleanup` deferral. +- [main.cpp:478](../src/main.cpp) — the `workInFlight` deep-sleep gate. + +A 30–60 s Spectra boot refresh is currently invisible to all of them, and **→ Phase 6** adds a +fourth consumer (the supervisor's "never interrupt a refresh" rule) that would inherit the same +hole. P2-3 is a prerequisite for Phase 6 being correct, not just a tidy-up. + +--- + +## P2-8 — `waitforrefresh`: wall-clock deadline, not an iteration count + +[display_service.cpp:747-776](../src/display_service.cpp). The bb_epaper wait — **the only refresh +bound nRF has** — counts iterations, not time: + +```c +for (size_t i = 0; i < (size_t)(timeout * 100); i++){ + delay(10); + ... +} +``` + +Each iteration is `delay(10)` = *at least* 10 ms. Under preemption it is more, and on nRF the loop +task is the **lowest** non-idle priority (1), sitting under the callback task (2) and the Bluefruit +task (3). So `waitforrefresh(60)` is a bound of "60 s of loop-task scheduling", not 60 s of wall +clock — it can overrun arbitrarily while a BLE transfer keeps the higher-priority tasks busy. It +also mis-reports: the `"Refresh took %.2f seconds"` line divides `i` by 100 and is wrong by the same +factor. + +Same defect on ESP32, less visible there because the loop task is less contended. + +```c +bool waitforrefresh(int timeout){ + ... + // Wall-clock deadline, not an iteration count. delay(10) is a *minimum*: on + // nRF the loop task is the lowest non-idle priority (1) under the Bluefruit + // callback task (2) and BLE task (3), so counting iterations bounds + // loop-task scheduling rather than elapsed time and can overrun arbitrarily + // during a transfer. + const uint32_t deadline = millis() + (uint32_t)timeout * 1000u; + uint32_t polls = 0; + const uint32_t t0 = millis(); + while ((int32_t)(millis() - deadline) < 0) { + delay(10); + if (polls % 50 == 0) od_log_raw("."); + if (!bbepIsBusy(&bbep)) { + if (polls == 0) { + od_log_error("ERROR: Epaper not busy after refresh command - refresh may not have started"); + return false; + } + od_log_raw(".\n"); + od_log_info("Refresh took %u ms", (unsigned)(millis() - t0)); + return true; + } + polls++; + } + od_log_warn("Refresh timed out after %u ms", (unsigned)(millis() - t0)); + return false; +} +``` + +Preserve the `polls == 0` check exactly — the existing comment at +[:754-757](../src/display_service.cpp) explains that BUSY asserts within µs of `MASTER_ACTIVATE`, so +a first poll at 10 ms is what makes "never went busy" a valid error. Do not reorder the increment. + +This is the cheapest item in the plan with the largest nRF-side effect: it converts nRF's single +refresh bound from advisory to real. + +--- + +## P2-4 — Make `fastepd_wait_refresh` a real, bounded wait + +### Correcting `[X3]` + +The parent plan says FastEPD is unbounded and asks for a busy poll "honouring `timeout_sec`". That +is not the defect. Every wait in the library is already capped (30 s LUT, 3 s HRDY, fixed-trip DMA +loops). The actual defect is the mirror image: + +> `fastepd_wait_refresh` returns **immediately** ([display_fastepd.cpp:228-231](../src/display_fastepd.cpp)), +> and the refresh functions it is paired with return **before the panel has finished refreshing**. +> So the caller believes the refresh is complete and proceeds to cut power. + +Trace it: `fastepd_direct_refresh(1)` ([:276-284](../src/display_fastepd.cpp)) → +`it8951_fullscreen_du()` ([:132-180](../src/display_fastepd.cpp)) → issues +`it8951DisplayArea1Bit(...)` then `it8951WaitForReady(st)`, which waits **HRDY** (host interface +ready, 3 s cap) and *not* **LUTAFSR** (waveform complete). The panel is still painting when the +function returns. `waitforrefresh(60)` then routes to the stub +([display_service.cpp:749](../src/display_service.cpp)) and returns instantly, and +`cleanupDirectWriteState(false)` → `epdSessionRelease()` can call `einkPower(0)` / `deInit()` +mid-waveform. + +The library gets away with this internally because `it8951WriteFramebuffer1Bit` waits LUT-ready at +**entry** (`FastEPD.inl:2125`) — the next operation absorbs the previous one's tail. Our code path +does not have a next operation; it has a power-down. + +### The fix + +```c +// display_fastepd.cpp — replace the stub at :228-231 +// Real wait: block until the IT8951 waveform LUT is idle. The refresh entry +// points (it8951_fullscreen_du / bbepFullUpdate->it8951WriteFramebuffer*Bit) +// return once the image is LOADED and HRDY is back, NOT once the panel has +// finished painting -- the library relies on the *next* operation's entry-side +// LUT wait to absorb that. Our next operation is a power-down, so we must do +// the wait here or we cut the rail mid-waveform. +// +// The bound is the library's own: it8951WaitForLUTReady breaks at 30 000 ms +// (FastEPD.inl:2027-2037). timeout_sec is therefore advisory and is logged when +// it is tighter than the library's cap; we do not reimplement the register poll. +bool fastepd_wait_refresh(int timeout_sec) { + if (s_init_failed) return false; + FASTEPDSTATE* st = g_epd.state(); + if (!st) return false; + const uint32_t start = millis(); + it8951WaitForLUTReady(st); // hard-bounded at 30 s inside the library + const uint32_t waited = millis() - start; + if (waited >= 30000u) { + od_log_error("[FastEPD] LUT-ready timeout (%u ms) - refresh incomplete", (unsigned)waited); + return false; + } + if (timeout_sec > 0 && waited > (uint32_t)timeout_sec * 1000u) { + od_log_warn("[FastEPD] refresh took %u ms (caller budget %ds)", (unsigned)waited, timeout_sec); + } + return true; +} +``` + +`it8951WaitForLUTReady` is already declared at +[display_fastepd.cpp:15](../src/display_fastepd.cpp), so no new extern is needed. + +### Coverage + +This one change covers **every** FastEPD refresh, because they all funnel through +`waitforrefresh()` → `fastepd_wait_refresh()`: + +- `fastepd_direct_refresh` + `waitforrefresh(60)` — [display_service.cpp:2422-2423](../src/display_service.cpp) *(the path a real transfer takes)* +- `fastepd_full_update` + `waitforrefresh(60)` — [display_service.cpp:1591-1592](../src/display_service.cpp) *(boot)* +- `fastepd_partial_refresh` — [display_service.cpp:3287](../src/display_service.cpp) + +The parent plan asked for `fastepd_direct_refresh` specifically; wrapping the *wait* instead of +each *refresh* gets all three for free and cannot be bypassed by a future fourth caller. + +### Blast radius + +`OPENDISPLAY_FASTEPD` is defined in 4 envs (`platformio.ini:54, 84, 113, 254`) and the code path is +live only when `fastepd_driver_used()` — i.e. IT8951/E1004 panels. **This change makes refreshes +take measurably longer on those boards** (they were previously returning early). That is the point, +but it means the E1004 ~960 KB upload regression test is mandatory, not optional. + +--- + +## P2-7 — *(optional)* `Wire.setTimeOut(25)` — **ESP32 only** + +### What the call actually does + +`TwoWire::setTimeOut(uint16_t timeOutMillis)` sets the per-transaction I2C timeout that Arduino-ESP32 +passes down to `i2cRead()` / `i2cWrite()` (`framework-arduinoespressif32/libraries/Wire/src/Wire.cpp:458, +:518, :525`). **The default is 50 ms** (`Wire.cpp:44`, `_timeOutMillis(50)`), and the firmware never +calls the setter, so every I2C transaction that fails to complete blocks the calling task for 50 ms. + +Per the parent plan's downgraded `[X1]`: the GT911 driver already self-disables after 5 consecutive +read failures ([touch_input.cpp:39](../src/touch_input.cpp), [:642](../src/touch_input.cpp)), so the +worst case is **~5 × 50 ms = ~250 ms of blocked `loop()`** before the controller is dropped for good. +`setTimeOut(25)` halves that to ~125 ms. That is the entire benefit — a latency trim, not a new bound. + +Call sites, all after a successful `Wire.begin()`: [display_service.cpp:786](../src/display_service.cpp) +and [:794](../src/display_service.cpp) (in `wireBeginForOpenDisplay`), [:872](../src/display_service.cpp) +and [:885](../src/display_service.cpp) (in `initOrRestoreWireForOpenDisplay`), and +[:920](../src/display_service.cpp). + +### ⚠️ Correction: ESP32-only, and nRF's situation is worse + +An earlier revision of this plan listed P2-7 as applying to both targets. That was wrong twice over: + +**1. The API does not exist on nRF.** `setTimeOut` (capital O) is an Arduino-ESP32 extension. The +Adafruit/Seeed core's `TwoWire` (`libraries/Wire/Wire.h:33`) does not declare it; it only inherits +`Stream::setTimeout` (lowercase o), which is a *stream read* timeout with nothing to do with I2C +transactions. + +**2. nRF's I2C driver has no timeout at all — it spins forever.** `Wire_nRF52.cpp` waits on TWIM +events with bare, non-yielding busy-loops: + +```c +while(!_p_twim->EVENTS_RXSTARTED && !_p_twim->EVENTS_ERROR); // :166 +while(!_p_twim->EVENTS_LASTRX && !_p_twim->EVENTS_ERROR); // :169 +while(!_p_twim->EVENTS_STOPPED); // :175 <- no ERROR check +while(!_p_twim->EVENTS_SUSPENDED); // :181 <- no ERROR check +``` + +(Same shape on the write path at `:230-247`.) No bound, no yield, and the last two do not even +inspect `EVENTS_ERROR`. + +Scope of the exposure, stated honestly: a **disconnected or NACKing** device is fine — TWIM raises +`EVENTS_ERROR` and the first two loops exit. The unbounded case is a genuine **bus lockup** (a +peripheral holding SDA low, a missing or weak pull-up), where neither the completion event nor the +error event ever arrives and the calling task hangs permanently. Rarer than a NACK, but it is exactly +the GT911-wedge scenario `[X1]` was originally written about — and on nRF there is no +`TOUCH_I2C_FAIL_DISABLE_THRESHOLD` escape, because the driver never returns to be counted. + +I2C is genuinely used on nRF: `sensor_sht40.cpp` includes `` and calls +`Wire.beginTransmission()` with no target guard. + +### Recommendation + +**Still defer P2-7 itself.** 250 ms is not a freeze; the setting is global to the bus, so it also +applies to SHT40, BQ27220 and AXP2101, and a 25 ms ceiling is not obviously safe for a +clock-stretching sensor. If deferred, record it as accepted-as-is alongside `[X1]`. + +**The nRF finding is a separate, new question — see D-L.** It is a real unbounded, non-yielding +wait, which is precisely Phase 2's subject matter, and `setTimeOut` cannot fix it because the API is +absent. It deserves its own decision rather than being folded into an optional latency trim. + +--- + +## Decisions needed + +> **After the scope cut, nothing blocks implementation.** D-A, D-A2, D-B, D-C and D-E existed only +> to shape P2-1; D-H, D-I and D-J only to shape P2-9. All eight are moot and struck through below, +> retained because their analysis is the useful part if either item is ever revived. +> +> **Still live:** **D-D** (accept the `[X3]` downgrade — recommend yes), **D-G** (include P2-7 — +> recommend no, defer), **D-K** (accept the nRF tick-derived `millis()` limitation — recommend yes), +> and **D-L** (nRF I2C busy-spins with no timeout: bound it or accept it). All four have a +> recommendation that can be taken as the default, so P2-3/P2-4/P2-6/P2-8 can start immediately. + +### D-D — Accept the `[X3]` downgrade? *(recommendation: yes)* + +The parent plan's "implement a real busy poll against the LUT-busy register honouring +`timeout_sec`" is superseded by P2-4, which calls the library's existing bounded poll instead of +reimplementing it. This is a **correction to the parent plan** and should be folded back into +`PLAN_FREEZE_PROOFING_2026-07-26.md` § Phase 2 the same way `[X1]` was, so review does not +re-litigate it. It also means the parent plan's Verification line "FastEPD refresh has no +firmware-side bound" needs rewording. + +### D-F — Keep P2-5 at all? — ✅ **RESOLVED: dropped** + +*Confirmed 2026-07-26.* **P2-5 is out of Phase 2 entirely** — no cap, no constant, no saturation +WARN. The drain loop is untouched by this phase. Phase 3's `[M5]` becomes the sole edit to that +region; the ring-saturation diagnostic, if wanted, belongs to Phase 7's `[H1]`. + +Reframed from "what value for `COMMAND_DRAIN_BUDGET_MS`" — the value question was moot once the +item went. Per the P2-5 section: a 2 s cap never fires (32 DATA frames ≈ 0.1–1 s), cannot interrupt the +one genuinely long command (a 30–60 s refresh — the check is between commands), and the stacked- +refresh case it *would* catch is unreachable +([display_service.cpp:2366](../src/display_service.cpp)). The drain is already bounded by +33 × (per-command time), with per-command time bounded by P2-1 / P2-4 / P2-8. + +Positions considered: + +1. **Drop entirely** — ✅ **chosen.** Also removes the `[M5]` merge conflict with Phase 3. +2. **Replace with a 2-line saturation WARN** on `drained == COMMAND_QUEUE_SIZE` — rejected for + Phase 2; handed to Phase 7's `[H1]` queue-full work, where the ring is already the subject. +3. **Keep the 2 s cap** — rejected. Only defensible as TWDT margin against a hypothetical future + where commands get 5× slower. Today's margin is already 5×. + +This is a **correction to the parent plan**, like `[X1]` and `[X3]`, and should be folded back into +`PLAN_FREEZE_PROOFING_2026-07-26.md` § Phase 2 ("Loop command drain: 2 s wall-clock cap alongside +the count cap") so it is not re-litigated. + +### D-G — Include P2-7? *(recommendation: no, defer)* + +See P2-7. If deferred, add it to the parent plan's *Deliberately NOT changed* section with the +reasoning, so it does not get rediscovered. Note the scope correction: P2-7 is **ESP32-only** — the +`setTimeOut` API does not exist on the nRF core. + +### D-L — nRF I2C has no timeout and busy-spins forever. Bound it, or accept it? *(new)* + +Discovered while checking D-G's scope. `Wire_nRF52.cpp:166-181` / `:230-247` spin on TWIM events with +no deadline and no yield; a bus lockup hangs the calling task permanently. This is a genuine unbounded +wait on nRF — Phase 2's exact subject matter — and unreachable via `setTimeOut`, which the core does +not implement. + +| Option | Assessment | +|---|---| +| **(a) Accept and document** | Consistent with `[X1]`'s downgrade and with the accepted hard-hang residual. Cheapest. But `[X1]`'s "the driver already gives up after 5 failures" reasoning **does not hold on nRF** — the driver never returns, so nothing counts failures | +| **(b) Wrap our own call sites** with a pre-flight bus check (sample SDA/SCL; skip the transaction if SDA is held low) | Bounded, ~15 lines, no library fork. Does not help if the bus locks *mid*-transaction | +| **(c) Fork/patch the core's Wire** to add a deadline to the four loops | Correct fix, but it is a vendored-core patch that every toolchain update must re-apply. High maintenance for a rare fault | +| **(d) Bound it at the caller** — run I2C only from a context where a hang is survivable | Not achievable; `loop()` is the caller | + +**Recommend (a) for Phase 2, with (b) noted as the cheap follow-up if a lockup is ever observed in +the field.** Rationale: the failure needs a physical bus fault (stuck SDA, bad pull-up), not a +software condition — no client, protocol state, or packet loss can trigger it — so it is outside the +freeze class this effort targets. But it must be recorded honestly rather than inherited from +`[X1]`, whose "the driver gives up" argument is ESP32-specific and does not transfer. + +If (a) is chosen, add it to the parent plan's residual-risk list, **not** to *Deliberately NOT +changed* — it is an accepted gap, not a considered-and-rejected change. + +### D-K — nRF timebase: accept the tick-derived `millis()` limitation? *(recommendation: yes)* + +Per the timebase analysis: every cooperative wait yields, so ticks keep running and all Phase 2 +bounds hold on nRF. Only a scheduler-starving fault (interrupts disabled / SoftDevice storm) stalls +`millis()` — which is the parent plan's already-accepted hard-hang residual. **Recommend accepting +and documenting**, rather than adding a DWT-cycle-counter timebase with 67 s wrap handling for a +fault class we cannot recover from anyway. Add it to the parent plan's residual-risk list. + +--- + +## Files touched + +| File | Items | Targets | +|---|---|---| +| `src/display_service.cpp` | P2-3, P2-8, (P2-7) | both | +| ~~`src/display_service.h`~~ | ~~P2-1 (`panelStateUnknown` extern)~~ — dropped | — | +| ~~`src/session_monitor.cpp/.h`~~ *(new)* | ~~P2-9~~ — dropped, **no new file in Phase 2** | — | +| `src/display_fastepd.cpp` | P2-4 | ESP32 | +| ~~`src/power_latch.cpp`~~ | ~~P2-2~~ — dropped | — | +| ~~`src/main.cpp`~~ | ~~P2-6 (comment)~~ — dropped; **Phase 2 does not touch `main.cpp` at all** | — | +| ~~`platformio.ini`~~ | ~~P2-6~~ — dropped; **no build-config change in Phase 2** | — | +| ~~`docs/TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md`~~ | ~~P2-6~~ — dropped | — | +| `docs/PLAN_FREEZE_PROOFING_2026-07-26.md` | D-D (`[X3]` downgrade), **the scope cut: § Phase 2 must drop P2-1/P2-2/P2-5/P2-9 and move their residuals to Phase 6's remit**, D-K (residual) | — | + +~~`src/session_monitor.*` is deliberately not `src/session_guard.*`…~~ — moot with P2-9 dropped. +**Phase 2 now creates no new file at all**, so Phase 3 owns `src/session_guard.*` with nothing to +coordinate around and nothing to `#include`. + +**No protocol surface is touched.** No `CMD_*`/`RESP_*`, no frame layout, no config-packet layout, +no client-observable behaviour change other than "a FastEPD refresh now completes before the device +reports it complete" — which is a bug fix in the client's favour. Run the parent plan's constraint +check before calling the phase done: + +```bash +cd ../opendisplay-protocol && tools/sync_protocol_header.py --check --only Firmware +cd ../Firmware && git diff main --stat -- include/opendisplay_protocol.h include/opendisplay_structs.h # must be empty +``` + +--- + +## Verification + +### Build + +```bash +pio run -e nrf52840custom -e esp32-s3-N16R8 -e esp32-c3-N16 -e esp32-c6-N4 -e esp32-N4 -e esp32-s3-E1004 +``` + +`esp32-s3-E1004` is added to the parent plan's set because it is the FastEPD/IT8951 gate for P2-4. +CI builds all **12** envs on push (`esp32-s3-N16R8-extuart-debug` is easy to miss when counting +from `platformio.ini`). Items P2-1…P2-8 add no meaningful `.bss` (one `bool`, a few `uint32_t` +locals). **P2-9 adds a ~2 KB task stack, and `esp32-N4` is the gate.** Phase 1 as-built *freed* +480 B there (81,940 → 81,468 B), so the headroom is better than this plan originally assumed — see +"Cost" under P2-9. Compare the new figure against **81,468 B**, not the pre-Phase-1 baseline. + +CI now also runs a `host-tests` job (added by Phase 1) alongside the 12-env matrix; Phase 2 adds +nothing to it unless P2-8/P2-9 grow host-testable pure logic, which is worth considering for the +`waitforrefresh` deadline arithmetic. + +### Static + +- ~~`grep -rn "CONFIG_FREERTOS_WATCHDOG_TIMEOUT_S" platformio.ini` → no hits.~~ — moot, P2-6 + dropped; the flag stays and `platformio.ini` must be **unchanged**. +- ~~`pwrmgmLockTake` call sites / lost-`Give` audit~~ — moot, P2-1 dropped; `pwrmgmLockTake()` keeps + its `void` signature and every existing call site is unchanged. **Confirm the diff does not touch + it**, which is now the check that matters. +- ~~`sessionMonitorHeartbeat` placement, P2-9 stack-size units~~ — moot, P2-9 dropped. +- `git diff --stat` should show **no new files**, and changes confined to + `src/display_service.cpp` and `src/display_fastepd.cpp`. Any touch to `src/main.cpp` or + `platformio.ini` means scope has crept back in. + +### Hardware — both targets + +**Every test below must be run on `nrf52840custom` as well as an ESP32 env.** The parent plan's +Phase 6 note applies here too: do not assume nRF parity from an ESP32 pass. nRF-specific +expectations are called out where the platforms legitimately differ. + +| Test | Expect | +|---|---| +| **P2-1** Force a lock timeout (temporary `pwrmgmLock = 1` from a debug command) | ERROR logged once, panel op skipped, **`loop()` continues**, BLE stays responsive, no reboot. ESP32: proves the yielding wait survives the 5 s TWDT. **nRF: proves `millis()` keeps advancing under the `delay(1)` spin** — the timebase check | +| **P2-2** Jumper the button pin low, issue power-off | ESP32 only. Latch drops at ~10 s with a WARN; note whether the held button re-latches (expected on a latching board) | +| **P2-3** Boot with a Spectra panel, `nRF Connect` scanning throughout | ESP32: advertising restart deferred, no disconnect-cleanup during the boot refresh. **nRF: expect no behaviour change** — the flag has no consumers there yet; confirming the *absence* of a regression is the test | +| **P2-4** E1004: full ~960 KB upload → refresh → immediate power-down | ESP32/E1004 only. Image fully painted; no truncated/ghosted waveform. Compare against a pre-change capture — this is the regression that proves the early return was real | +| **P2-4** E1004 boot refresh | Same, on the boot path | +| ~~P2-5~~ | Dropped — no test. *(If you want the evidence on record, instrument a saturated drain once and log its wall-clock; expect ≪ 2 s, which is why the cap went.)* | +| **P2-8** Refresh during a concurrent BLE transfer, both targets | Reported duration matches a stopwatch. **nRF is the real test**: with the callback task (prio 2) busy, the old iteration count would overrun 60 s while the new deadline holds | +| **P2-8** Disconnect the BUSY line to force a timeout | WARN at ~60 s wall clock on both targets, not later | +| **P2-9** Block `loop()` deliberately (debug command doing a non-yielding busy-wait > 2.5 min) | One ERROR at ~150 s naming the phase breadcrumb, rate-limited to one line per 30 s, one INFO on recovery with the total. **nRF: confirm the prio-2 task actually preempts prio-1 `loop()`** — this is the whole premise | +| **P2-9** Normal operation, 1 h idle + several full transfers | **Zero** stall lines. A false positive here means `LOOP_STALL_WARN_MS` (D-J) is too tight | +| **Regression** Full Spectra transfer (60 s+ refresh), both targets; E1004 ~960 KB upload | Complete untouched, no stall lines | + +Every new bound logs exactly one ERROR/WARN with the reason and the elapsed time — per the parent +plan's blanket requirement. + +--- + +## Residual risk after Phase 2 + +- **A hard peripheral hang inside a single library call is still unbounded from our side.** We bound + the *waits we own*; `bbepWaitBusy`'s own 30 s cap and `it8951WaitForLUTReady`'s 30 s cap are the + library's, and a hang below those (a wedged SPI transaction, a stuck DMA) is invisible to us. + Consistent with the software-only decision. +- **`pwrmgmLockTake` is still unbounded (P2-1 dropped).** A panel-lock holder that never releases + blocks its waiter forever, on both targets. This is the largest residual Phase 2 knowingly leaves; + it was previously the item's whole reason for existing. +- **A stalled `loop()` is undetected on both targets (P2-9 dropped).** ESP32's TWDT will not fire — + every long wait yields, so IDLE0 is never starved — and nRF has no watchdog at all. Phase 2 bounds + the refresh waits but reports nothing when a bound is exceeded by something it does not own. + Detection now waits for Phase 6. +- **`powerOff`'s stuck-button wait is still unbounded (P2-2 dropped).** ESP32-only, needs a hardware + fault, and it removes a recovery path rather than creating a freeze. +- **A single long command still owns `loop()` for its duration** — one 60 s refresh blocks the loop + task for 60 s, and no drain cap can change that (the check would be between commands). This is by + design: interrupting a refresh is worse than waiting for it. What bounds it is P2-4/P2-8, not P2-5. +- **A saturated drain costs ~1 s of unserviced touch/buttons.** Measured-order estimate, not a + freeze, and accepted — see D-F. +- **The inert `CONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` flag stays in 9 ESP envs (P2-6 dropped)**, + still reading like a 120 s watchdog guarantee that does not exist. Harmless to execution, but a + standing trap for the next reader of `platformio.ini`. nRF's total absence of a watchdog also goes + unrecorded. +- **Phase 2's own thesis is only partly delivered.** Of the three conditions for a "binding" bound, + condition 3 (observable by a third party) is now met by nothing, and condition 1 fails for the + panel lock. Phase 2 delivers *bounded refreshes*; it does not deliver *detected stalls*. +- ~~**P2-2 may re-latch.**~~ Moot — P2-2 is dropped, so the latch behaviour is unchanged from today. +- **nRF I2C can hang forever on a bus lockup** (`Wire_nRF52.cpp:166-181`, `:230-247` — bare + non-yielding `while(!EVENTS_x);`). Needs a physical fault (stuck SDA, bad pull-up), not a software + condition, so it is outside the freeze class this effort targets — but `[X1]`'s "the driver gives + up after 5 failures" reasoning is ESP32-specific and does **not** transfer. See D-L. +- **On nRF, `millis()` stops if the scheduler stops** (tick-derived, `delay.c:29-31`). Every Phase 2 + deadline goes with it. Only reachable via interrupts-disabled / critical-section / SoftDevice-storm + faults, which are the already-accepted hard-hang class; all cooperative waits yield and keep the + tick running. Documented rather than engineered around — see D-K. +- **P2-9 detects, it does not recover.** Under the no-reboot decision an observer task has no safe + action against a blocked `loop()`. It converts an invisible field freeze into a timestamped ERROR + naming the phase; the recovery story remains the per-wait deadlines in P2-1/P2-4/P2-8. **A hard + hang is therefore still unrecovered after Phase 2** — deliberately, per D-I, with a rate-limited + flash-persisted reset held as the documented follow-up if soak shows stalls actually occur. +- **`epdRefreshInProgress` remains inert on nRF until Phase 6** adds the consumers. P2-3 sets it + correctly on both targets, but on nRF nothing reads it yet. +- **nRF still has no transfer watchdog after Phase 2** — the 900 s direct-write bound and + `checkPartialWriteTimeout()` live in the `#ifdef TARGET_ESP32` arm of `loop()` + ([main.cpp:436-443](../src/main.cpp)). This is Phase 6's `[H3]` "ADD on nRF" item, restated here + so Phase 2 is not mistaken for having closed it. +## Appendix — decisions made moot by the scope cut + +Eight decisions existed only to shape P2-1 (D-A, D-A2, D-B, D-C, D-E) or P2-9 (D-H, D-I, D-J). +Both items are dropped, so none of them needs an answer. Retained because the analysis is the +part worth keeping if either item is ever revived. + +### ~~D-A~~ — `epdSessionAcquire` signature — ⛔ **MOOT: P2-1 dropped** + +#### The problem + +`epdSessionAcquire` currently returns `bool cold` — a *result* (was the rail off?), not a *status*. +Once `pwrmgmLockTake()` can fail there is nowhere to report "I did not acquire anything." The two +values are not merge-able: `false` already means "warm", which is a success. + +#### Proposed signature + +```c +// display_service.cpp:438 +// Bring the panel up for a transfer/refresh. +// +// Returns true iff the session was acquired. On FALSE the pwrmgm lock timed out: +// nothing was powered, nothing was initialised, panelStateUnknown is set, and the +// caller MUST skip all panel work -- driving SPI/CS without the lock is exactly +// the two-tasks-one-bus hazard the lock exists to prevent. +// +// *outCold is written ONLY when the function returns true, and is the old return +// value: true = the rail was off and we did a COLD bring-up (callers may need to +// reopen the address window). May be NULL if the caller does not care. +static bool epdSessionAcquire(bool partialInit, bool* outCold); +``` + +#### The full cascade — bounded, 6 functions, 4 leaf sites + +This is the real cost of D-A and it is worth stating exactly, because "the compiler finds them all" +is only reassuring if the count is small. It is: + +``` +epdSessionAcquire() 2 call sites +├─ :2083 directWriteActivatePanel() void -> must become bool +│ ├─ :2154 handleDirectWriteStart() ← leaf (protocol handler) +│ └─ :2807 handlePipeWriteStart() ← leaf (protocol handler) +└─ :3253 partial_prepare_panel_ram() void -> must become bool + ├─ :2252 handlePartialWriteStart() ← leaf (protocol handler) + └─ :2792 handlePipeWriteStart() ← leaf (same handler, other arm) +``` + +So: two `void`→`bool` changes on file-static helpers, and **three protocol handlers** that need a +failure branch. Every one of the three is a `*Start` handler, which is the best possible place for +this — the failure happens before any state is committed, so the branch is "NACK and return", not +a teardown. + +The `directWriteActivatePanel` site at [:2083](../src/display_service.cpp) is the one that matters +most: it currently **ignores the return value entirely** and sets `directWriteActive = true` at +[:2075](../src/display_service.cpp) *before* acquiring. If the acquire fails there and nothing +checks, the device latches `directWriteActive` with the panel un-acquired — a wedge of exactly the +kind this whole effort exists to remove. Order the new code so `directWriteActive` is set only +after a successful acquire. + +#### Which driver paths does this affect? — **all three, and both targets** + +`epdSessionAcquire` is **driver-agnostic**. It always takes the lock and always actuates the rail +via `pwrmgm(true)` ([:443](../src/display_service.cpp)); only the controller-init step branches, on +`epdSessionUsesFastepd()` ([:371-377](../src/display_service.cpp)): + +| Path | What `epdSessionAcquire` does inside the lock | Affected by D-A? | +|---|---|---| +| **bb_epaper generic** | `pwrmgm(true)` + `bbepInitIO` + `bbepWakeUp` + `bbepSendCMDSequence` + `epdAlignCustomPartialRamMode` ([:453-456](../src/display_service.cpp), warm re-acquire [:478-481](../src/display_service.cpp)) | **Yes** | +| **E1004 dual-CS** (`#ifdef BBEP_T133A01`) | `pwrmgm(true)` + `e1004InitPanel()` ([:448](../src/display_service.cpp)) | **Yes** | +| **FastEPD / IT8951** | `pwrmgm(true)` **only** — the TCON init happens outside, in `fastepd_direct_write_reset()` / `fastepd_epaper_begin()` | **Yes** (the rail is still the lock's business) | + +So this is emphatically **not** a FastEPD-only change. Note also that `epdSessionUsesFastepd()` +returns `false` unless `TARGET_ESP32 && OPENDISPLAY_FASTEPD`, so **on nRF the bb_epaper branch is +the only one that ever runs** — D-A is fully live on `nrf52840custom`, and both leaf call sites +(`directWriteActivatePanel`, `partial_prepare_panel_ram`) are outside every `#ifdef TARGET_ESP32`. + +#### What the lock does and does not guard — why only `epdSessionAcquire` changes + +Two separate questions hide behind "why only this function": + +**(a) Why no other lock-taker needs a signature change.** `epdSessionRelease` and +`epdSessionForceOff` return `void`, so an early return is a complete response; +`epdSessionTick` already uses `pwrmgmLockTryTake()`; `epdSessionForceOffLocked` is caller-holds. +`epdSessionAcquire` is the only one whose *caller must behave differently* on failure. + +**(b) Why `epdSessionAcquire` is not the only path that touches the panel.** It isn't — and that is +by design, not an oversight. Several paths drive the panel without ever taking the lock: + +| Bypass | Why it is safe today | +|---|---| +| `prepareEpdRailForBoot()` [:173-185](../src/display_service.cpp) — raw `pwrmgm(true/false/true)` | Boot only; BLE is not up, no transfer can exist | +| `initBbepPanelSession()` [:341-355](../src/display_service.cpp) — full `bbepInitIO`+`bbepWakeUp`+init-seq | Boot only, same reason | +| `initDisplay()` [:1567-1600](../src/display_service.cpp) — raw `pwrmgm(true)`, `fastepd_epaper_begin()`, `pwrmgm(false)` | Boot only, same reason | +| `fastepd_prepare_hardware()` at [:2140](../src/display_service.cpp) / [:2803](../src/display_service.cpp) | Runs in the START handler *before* the acquire; loop/callback task, serialized by transfer ownership | +| **All data streaming** — `bbepSetAddrWindow`/`bbepStartWrite` [:2098-2099](../src/display_service.cpp), `e1004_begin_plane()`, `fastepd_direct_write_reset()`, every subsequent `bbepWriteData` | The lock is `Give`n at [:488](../src/display_service.cpp) *before* `epdSessionAcquire` returns | + +**The lock is a rail-and-init guard, not a bus mutex.** Streaming is protected by `pwrmgmState` +instead: `epdSessionTick` acts only in `PWR_WARM`, and a live transfer holds `PWR_ACTIVE`, so the +keep-alive tick cannot rail-cut mid-stream. The lock exists for the narrower race the comment at +[:396-400](../src/display_service.cpp) names — the tick's `ForceOff` landing mid-*init*. This is +coherent and Phase 2 should not widen it; noted here so a reviewer does not read the bypass list as +a new defect. + +**Consequence for D-A's failure branch:** at both leaf sites, `fastepd_prepare_hardware()` has +*already run* by the time the acquire fails ([:2140](../src/display_service.cpp), +[:2803](../src/display_service.cpp)). On the FastEPD path the failure branch should therefore call +`fastepd_mark_hw_deinitialized()` so the next attempt does a full TCON re-init rather than a +`wake()` on a controller whose rail state we no longer know — the same reasoning the existing +comment at [:424-426](../src/display_service.cpp) gives for the rail-drop case. + +#### Call-site shape + +```c +static bool directWriteActivatePanel(void) { + ... + bool cold = false; + if (!epdSessionAcquire(false, &cold)) { + od_log_error("ERROR: panel session acquire failed (lock timeout) - not starting direct write"); + return false; // directWriteActive NOT set; nothing to tear down + } + directWriteActive = true; + directWriteStartTime = millis(); + ... + return true; +} +``` + +and at each leaf, the handler's existing NACK idiom: + +```c + if (!directWriteActivatePanel()) { /* NACK, see D-A2 */ return; } +``` + +#### Why not the alternative + +Keeping `bool cold` and having callers poll `panelStateUnknown` is a smaller diff, but it inverts +the default: every happy path proceeds to drive an un-acquired panel unless someone remembered to +add a check. That is the same failure shape as the `[C4]` finding in Phase 4 (a blind flag-setter +with the guard somewhere else). **Recommend the signature change.** + +`epdSessionRelease` / `epdSessionForceOff` do **not** need signature changes — both return `void` +today and an early return on lock-timeout is a complete response for them (P2-1 table). Only +`epdSessionAcquire` has a caller that must change behaviour. + +### ~~D-A2~~ — What does the START handler NACK with? — ⛔ **MOOT: P2-1 dropped** + +The three leaf handlers must tell the client "not started". Checked against +`include/opendisplay_protocol.h:784-804`: **neither error namespace has a "device busy" or +"internal error" code.** `OD_ERR_PIPE_START_*` 0x04 is unused, but assigning it a meaning is a +protocol change and explicitly out of bounds. + +| Option | Assessment | +|---|---| +| Reuse `OD_ERR_PIPE_START_PARTIAL_UNSUPPORTED` / `OD_ERR_PARTIAL_UNSUPPORTED` | Semantically wrong — tells the client the *mode* is unsupported, so it may permanently stop trying partials | +| Bare 2-byte `[RESP_NACK][opcode]` | **Precedent exists** — `handlePartialWriteStart` already sends `{RESP_NACK, 0x76}` at [display_service.cpp:2232](../src/display_service.cpp). But the header specifies the 4-byte form for 0x80 (`:600-608`), so this needs checking against py-opendisplay's NACK parser before use on the pipe path | +| Claim 0x04 | Out of bounds — protocol change | + +**Recommend the 2-byte form, contingent on a read of py-opendisplay's NACK parsing.** If the client +rejects a short NACK on 0x80, fall back to `OD_ERR_PIPE_START_BAD_HEADER` (0x01) — wrong, but +non-poisoning: the client retries rather than disabling a feature. This is the one point where D-A +touches the no-protocol-change constraint, so settle it before writing the handlers. + +### ~~D-B~~ — Who owns `panelStateUnknown`? — ⛔ **MOOT: P2-1 dropped** (no such flag is produced) + +Proposed: `display_service.cpp` owns it, set only by `pwrmgmLockTake`, cleared only by a successful +`epdSessionForceOff`. Alternative: defer the whole flag to Phase 3 and have Phase 2 only log the +timeout. **Recommend keeping it in Phase 2** — the flag is where the knowledge is, and Phase 3 +just reads it. Confirm that a dead-until-Phase-3 flag is acceptable in review. + +### ~~D-C~~ — Does the panel become unusable after a lock timeout? — ⛔ **MOOT: P2-1 dropped** (there is no lock timeout) + +*Confirmed 2026-07-26.* `pwrmgmLockTake` always attempts the full 60 s take; `panelStateUnknown` +is reported but never suppresses a retry. Aggregate cost is bounded by P2-5's drain budget and +(→ Phase 6) the supervisor. Revisit only if hardware soak shows 60 s retries stacking. Options +considered, retained for the record: + +1. **Keep trying** — every subsequent panel op re-attempts the 60 s take. Simple; a genuinely + wedged lock costs 60 s per operation forever. +2. **Fail fast while set** — `pwrmgmLockTake` returns false immediately when `panelStateUnknown` + is already set, until a successful force-off clears it. Bounds the damage; risks latching the + panel off after one transient. +3. **Fail fast with a retry window** — as (2), but allow one full attempt every N seconds. + + +### ~~D-E~~ — `PWRMGM_LOCK_TIMEOUT_MS` value — ⛔ **MOOT: P2-1 dropped** + +Derived above from `bbepWaitBusy`'s `iMaxTime = 30000`. If someone wants it tighter, the derivation +— not the intuition — has to change. Note that a `pwrmgmLockTake` timeout is *itself* a 60 s block +of `loop()`, which is fine under the 5 s TWDT only because of the yielding `delay(1)`. + +### ~~D-H~~ — Does P2-9 belong in Phase 2 at all? — ⛔ **ANSWERED BY THE DROP: no** + +It is arguably Phase 6's job — it is a supervisor. The case for Phase 2: Phase 6's supervisor runs +*inside* `loop()` and therefore cannot observe a blocked `loop()` on either target, so it is not +the same mechanism and it cannot subsume this one. The case against: Phase 2 is otherwise a set of +local edits with no new files or tasks, and P2-9 is neither. + +**Recommend keeping it in Phase 2** but landing it **last**, so it can be dropped without +disturbing the other eight items. If it moves to Phase 6, the parent plan's Phase 6 section must be +amended to say the supervisor has two arms — in-loop (state stalls) and out-of-loop (loop +blockage) — because as written it only has the first. + +### ~~D-I~~ — detect-and-log vs reboot for P2-9 — ⛔ **MOOT: P2-9 dropped** + +*Confirmed 2026-07-26.* **P2-9 is detect-and-log only.** It logs an ERROR with elapsed time and +phase breadcrumb (rate-limited to one line per 30 s), sets `g_loopStalled` for Phase 6 to report on +resume, and takes **no recovery action**. No reboot, no hardware WDT, on either target. + +Accepted consequence, stated plainly so review does not reopen it: **a genuinely blocked `loop()` +is not recovered by Phase 2.** The recovery story for cooperative blocking remains the per-wait +deadlines in P2-1 / P2-4 / P2-8; for a true hard hang there is none, and the device stays frozen — +still displaying the correct image, since e-paper holds it without power — until a power cycle. +What P2-9 buys is that the event stops being invisible. + +Option (3) below (rate-limited, flash-persisted reset) is **deferred, not rejected**. Revisit only +if hardware soak produces stall lines; if it does, the phase breadcrumb tells us where, which is +the input that proposal needs. Do not implement it speculatively. + +The full trade is retained below because the "no reboot" decision rests on a narrower argument than +it first appears — it rules out *unconditional* reboot, and a future reader will need to know that. + +--- + +Under "software-only, never reboot", an observer task has no safe recovery action: it cannot touch +panel/BLE/session state from another task context. So for the one fault class P2-9 exists to catch +— a genuinely blocked `loop()` — **log-only does nothing**. That makes the reboot option worth +arguing properly rather than dismissing by reference to the earlier decision. + +#### What a reboot actually costs (verified) + +The parent plan's justification is "a reboot wipes RTC incl. `displayed_etag` and forces a +boot-screen redraw." Both halves check out, and the mechanism is worse than the summary suggests: + +- **RTC does not survive a reset at all.** The bootloader reloads RTC memory segments from the app + image on every reset *except* a deep-sleep wake ([main.cpp:95-99](../src/main.cpp), captured on + hardware in `docs/FINDINGS_DEEP_SLEEP_WAKE_BOOT_SCREEN_2026-07-07.md`). Lost: `displayed_etag` + ([main.h:294](../src/main.h)), `deep_sleep_count`, `mloopcounter`, `rebootFlag`, and the cached + WiFi BSSID/channel ([wifi_service.cpp:511-513](../src/wifi_service.cpp)). +- **The redraw is automatic and unconditional.** `is_deep_sleep_wake` false → `rebootFlag = 1` → + `initDisplay()` → full boot-screen refresh ([main.cpp:120-129](../src/main.cpp)). On a Spectra + panel that is 30–60 s of powered refresh. +- **`displayed_etag = 0` forces a full re-upload.** The next partial update gets an ETAG mismatch + and falls back to a full push — so HA re-sends the whole image. +- **It is indistinguishable from a first boot.** The code comment says so explicitly: a hidden + mid-cycle reset "lands here with count 0, indistinguishable from a true first boot." + +#### The case FOR rebooting — stronger than the parent plan allows + +1. **It is the only thing that recovers a hard hang.** Every other bound in Phase 2 assumes the + fault is cooperative (the blocked task yields). If it does not, a reset is the sole remaining + mechanism. "Log and hope" is not a recovery story. +2. **The nRF hardware is already there and unused**, and nRF has *no* recovery mechanism of any + kind today. +3. **A frozen tag self-heals within one push cycle if it reboots.** HA pushes on a schedule; a + device that reboots, redraws a boot screen, and accepts the next push is functional again in + minutes. A frozen one is dead until someone physically intervenes. +4. **The physical fallback is weaker than it sounds.** The parent plan's own residual-risk section + establishes that a long-press cannot power off while `loop()` is blocked — the hold evaluation + runs in `processButtonEvents()`, called only from `loop()`/`idleDelay()`. On a + `DEVICE_FLAG_BATTERY_LATCH` unit the user's fallback is "remove the battery." Against that + baseline, an automatic reboot looks generous. + +#### The case AGAINST — and why it wins for *this* device + +The decisive argument is specific to e-paper, and it is not the etag: + +**A frozen tag still displays the correct image.** E-paper holds its image with no power. A wedge +costs you *updates*, not the display. So the trade is not "broken vs working" — it is: + +> **freeze** = correct image, no updates, silent · **reboot** = *wrong* image (boot screen) for a +> push cycle, updates work, silent + +And then the tail risk, which is what settles it: **the wedge this plan targets is loss-driven and +recurrent.** A reboot-on-stall device that hits it repeatedly gives you a tag that reboots every +few minutes, performs a full-panel refresh each time — the single largest energy cost on a battery +unit — and, because RTC is wiped, reports every one of those boots as a first boot. You get a +battery-destroying reboot loop that is *invisible in telemetry*. That is a worse failure than the +freeze, and it is a realistic one, not a hypothetical. + +The secondary argument: a WDT firing on a **false positive** during a legitimate 60 s refresh +interrupts the refresh mid-waveform, which violates the parent plan's "never interrupt a refresh" +rule and can leave the panel in a bad state. + +**So the original decision holds — but for a sharper reason than "reboots are bad": an unbounded, +unlogged reboot loop on a battery e-paper device is worse than the freeze it fixes.** Note that +this reasoning attacks *unconditional* reboot, not reboot as such. + +#### The positions + +1. **Log only** — ✅ **chosen.** ERROR with elapsed time and phase breadcrumb, rate-limited, + `g_loopStalled` for Phase 6 to report on resume. Closes the *diagnosis* gap; leaves the + *recovery* gap open for hard hangs. +2. **Unconditional reboot on stall** — rejected on the reboot-loop argument above. +3. **Rate-limited, persisted reboot** — the middle the plan does not currently offer, and the only + version of (2) that survives the objection. Reboot on stall **at most once per N hours**, with + the reason and a counter written to **flash, not RTC** (LittleFS is configured on the ESP32 envs + via `board_build.filesystem = littlefs`; the nRF equivalent needs checking) so the reboot is + visible afterwards and the loop is bounded. On exceeding the rate limit, stop rebooting and fall + back to (1). +4. **Log + monitor sets abort flags** for `loop()` to service on resume — this is (1) plus a Phase 3 + dependency, and does nothing a *stalled* `loop()` can act on. Not a distinct position. + +**Resolution: (1), with (3) as the documented follow-up.** Soak tells us the thing we actually need +and do not have: whether a blocked `loop()` ever happens once P2-1, P2-4 and P2-8 are in. If soak +produces zero stall lines, (3) is unnecessary complexity; if it produces them, we will have a phase +breadcrumb saying where, which is worth more than a blind reset. Choosing (3) now would be building +a recovery mechanism for a fault we have not yet observed. + +### ~~D-J~~ — `LOOP_STALL_WARN_MS` value — ⛔ **MOOT: P2-9 dropped** + +Derived: longest legitimate pass = `waitforrefresh(60)` = 60 s (real wall clock after P2-8) plus a +worst-case `pwrmgmLockTake` timeout = 60 s (D-E), so 150 s clears both with margin. Note the +dependency — **if D-E lowers the lock timeout, this can come down with it.** Must stay well under +Phase 6's 600 s supervisor so the two signals are distinguishable in a log. + +## Appendix — dropped items, retained for the record + +Everything below was specified for Phase 2 and then cut. It is kept because the *analysis* is the +expensive part and is what stops each item being re-proposed from scratch — `[C2]`'s do-not-steal +argument, P2-5's arithmetic showing the drain cap is powerless, and P2-9's rejected alternatives +(nRF hardware WDT, idle-hook heartbeat) in particular. + +**None of it is in scope.** Each section opens with what was dropped and the residual it leaves; +the residuals are collected in "Residual risk after Phase 2" above. + +--- + +## ~~P2-9~~ — Loop-liveness heartbeat + monitor task — ❌ **DROPPED** + +*Dropped 2026-07-26 by owner decision.* **Not implemented in Phase 2.** No `src/session_monitor.*`, +no monitor task, no `loop()` heartbeat, no `LOOP_STALL_WARN_MS`. `src/main.cpp` is therefore +**untouched by Phase 2 except for P2-6's comment**, which also removes the last point of contact +with Phase 3's drain-loop edit. + +**This is the most consequential of the three drops, so be explicit about what it costs.** P2-9 was +the only item satisfying **condition 3** — *a violation is observable by something other than the +blocked party*. Without it: + +- **A stalled `loop()` is silent on both targets.** ESP32's TWDT watches IDLE0 starvation at 5 s / + panic, but every long wait here yields, so IDLE0 is never starved and the TWDT will not fire on + any fault Phase 2 was about. nRF has no watchdog at all (`NRF_WDT` is never started). +- **nRF keeps zero out-of-`loop()` observers.** This matters more than it did when the plan was + written: Phase 1 demonstrated on hardware that nRF's `loop()` *is* starved mid-transfer (its + deferred link-drop never executed, forcing the inline disconnect in `23ecaed`). Phase 2 now + bounds several waits it cannot report on for that target. +- **Combined with the P2-1 drop**, a panel-lock holder that never releases is both unbounded and + undetected until Phase 6. + +The `~2 KB` stack and the `esp32-N4` headroom question go away with it; so do D-H, D-I and D-J. + +The original design is retained below for the record — including the rejected alternatives (nRF +hardware WDT, idle-hook heartbeat), which are the useful part if this is ever revived. + +--- + +### The gap *(retained for the record — not implemented)* + +Condition (3) — *a violation is observable by someone other than the blocked party* — is unmet on +**both** targets: + +- **nRF has no observer at all.** No task watchdog, no hardware WDT started, `vApplicationIdleHook` + empty. +- **ESP32's observer is the wrong one.** The TWDT watches IDLE0 starvation with a 5 s timeout and + `_PANIC 1` — it **reboots**, which the user's decision explicitly forbids ("reset state, never + reboot; a reboot wipes RTC incl. `displayed_etag`"). And because every Phase 2 wait yields, IDLE0 + is never starved, so the TWDT will not fire on any fault Phase 2 is about. +- **→ Phase 6's supervisor cannot fill this gap** on either target: it is specified to run *inside* + `loop()`, so a blocked `loop()` means the supervisor never executes. Phase 6 detects *state-machine + stalls*; nobody detects *loop blockage*. Worth flagging back into the parent plan. + +### Design: a heartbeat stamped by `loop()`, read by a higher-priority task + +```c +// session_monitor.h (new; deliberately NOT session_guard.* — that is Phase 3's file) +void sessionMonitorBegin(void); // create the task; call at the end of setup() +void sessionMonitorHeartbeat(const char* phase); // stamp from loop() +bool sessionMonitorLoopStalled(void); // -> Phase 3 / Phase 6 +``` + +- `loop()` calls `sessionMonitorHeartbeat("top")` as its first statement — **outside** every + `#ifdef TARGET_ESP32`, so it is one call on both targets. Optional extra stamps with a phase + breadcrumb (`"drain"`, `"refresh"`, `"wifi"`) make the stall log say *where*. +- The monitor task runs at **priority 2** and `vTaskDelay(1000)`s. Priority 2 is above the loop task + on both targets (ESP32 `loopTask` = 1; nRF `TASK_PRIO_LOW` = 1) and at or below the BLE tasks, so + it preempts a spinning or compute-bound `loop()` but never delays the radio. +- On `millis() - g_loopHeartbeatMs > LOOP_STALL_WARN_MS`: log one ERROR with the elapsed time and + the last phase breadcrumb, then **rate-limit to one line per 30 s** so a long stall does not flood + the log. Set `g_loopStalled`. +- On recovery: log one INFO with the total stall duration, clear `g_loopStalled`. + +Creation is the only platform-specific line: + +```c +#if defined(TARGET_ESP32) + xTaskCreatePinnedToCore(monitorTask, "odmon", 2048, NULL, 2, NULL, ARDUINO_RUNNING_CORE); +#else // TARGET_NRF — Adafruit core, cores/nRF5/rtos.h:59 TASK_PRIO_NORMAL == 2 + xTaskCreate(monitorTask, "odmon", 512 /*words = 2 KB*/, NULL, TASK_PRIO_NORMAL, NULL); +#endif +``` + +Note the stack-size unit differs: ESP32's `xTaskCreate` takes **bytes**, vanilla FreeRTOS (nRF) +takes **words**. Getting this wrong is a silent stack overflow — though nRF has +`configCHECK_FOR_STACK_OVERFLOW 1` (`FreeRTOSConfig.h:78`) to catch it in test. + +### What it can and cannot do + +**Deliberately detect-and-log only.** It must not touch panel, BLE, or session state — it runs on a +different task from every one of those subsystems, and `pwrmgmLock` is the only cross-task guard in +the codebase. Under the no-reboot decision there is no safe recovery action available to an +observer task; its value is that a field freeze stops being invisible and starts producing a +timestamped ERROR naming the phase it died in. `g_loopStalled` is a **→ Phase 6** input: the +supervisor, once `loop()` resumes, can report that a stall occurred and how long it lasted. + +This is an honest limit, not a hedge: it closes the *diagnosis* gap, not the *recovery* gap. The +recovery story for a blocked `loop()` remains P2-1/P2-4/P2-8's per-wait deadlines — P2-9 is what +tells you when one of them failed to hold. + +### `LOOP_STALL_WARN_MS` + +Must exceed the longest legitimate single `loop()` pass. That is a full refresh: `waitforrefresh(60)` +after P2-8 is a true 60 s wall-clock bound, and a `pwrmgmLockTake` timeout adds another 60 s. +**`LOOP_STALL_WARN_MS = 150000` (2.5 min)** clears 60 + 60 with margin and still fires long before +Phase 6's 10-minute supervisor. See D-J. + +### Cost + +One task, ~2 KB stack + TCB, plus two `uint32_t` and a `const char*`. nRF52840 has 256 KB RAM and is +not a concern. **`esp32-N4` is still the gate** — it needs `PIPE_SMALL_DRAM_WINDOW` to fit at all — +but the combined-headroom warning that stood here is obsolete in Phase 2's favour: Phase 1 shipped a +**32 B bitmap in place of the 512 B ring**, not the `replay_window[256]` this plan was written +against, so it *returned* 480 B rather than consuming 1,536. Measured on the as-built branch: +`esp32-N4` **81,468 B** vs the 81,940 B pre-Phase-1 baseline. + +So P2-9 starts with ~480 B more room than budgeted. Still measure the link rather than assume — the +figure that matters is the successful link, not the percentage — but the fallback (compile P2-9 out +on that env alone via `-DOPENDISPLAY_NO_LOOP_MONITOR`, rather than shrinking the stack) is now less +likely to be needed. + +### Rejected: the nRF hardware WDT + +`NRF_WDT` is available and unused, and would be the obvious "make it binding" answer. Rejected: + +- It **resets the chip**, which is the one outcome the user's decision rules out. +- Its `EVENTS_TIMEOUT` fires only ~2 LFCLK cycles (~61 µs) before the reset — not remotely enough to + run a state teardown, so it cannot be repurposed into a soft supervisor. +- Once started it **cannot be stopped** except by reset, which complicates DFU and any future + debugging session. + +Recorded here so the option is not rediscovered and re-argued. The same reasoning is why P2-6 +deletes the ESP32 TWDT flag rather than trying to make it work. + +**But note the limit of that rejection.** It rules out an *unconditional* WDT reset. A rate-limited +reset with the reason persisted to flash is a materially different proposal and is not covered by +the arguments above — see **D-I** for the full trade, including why an unlogged reboot loop on a +battery e-paper tag is worse than the freeze it fixes, and why the recommendation is nonetheless to +defer it until soak data exists. + +### Rejected: an idle-hook heartbeat + +`vApplicationIdleHook` is overridable on nRF (weak alias, `hooks.c:33`) and `esp_register_freertos_idle_hook()` +exists on ESP32, so a symmetric idle-hook stamp is cheap and needs no task. But the idle hook +answers *"did anything run?"*, not *"did `loop()` advance?"* — and every fault in scope leaves the +system busily yielding, so idle keeps running throughout. It would detect only total CPU +starvation, which is the fault class already accepted as unrecoverable. The extra task is what buys +the actual signal. + +--- + +## ~~P2-1~~ — Bound `pwrmgmLockTake`, do not steal — ❌ **DROPPED** + +*Dropped 2026-07-26 by owner decision.* **Not implemented in Phase 2.** `pwrmgmLockTake()` +([display_service.cpp:401-408](../src/display_service.cpp)) keeps its unbounded +`while (__atomic_exchange_n(...)) { delay(1); }` spin, unchanged. No deadline, no `bool` return, no +`panelStateUnknown` flag, and no change to any `epdSession*` signature. + +**What this leaves open — state it plainly.** The parent plan lists this spin as one of its five +unbounded waits. A holder that never releases still blocks its waiter forever. The two known +long-but-legitimate holds remain the reason a naive bound was risky in the first place +(`bbepWaitBusy` caps at 30 000 ms on 3/4/7-colour panels; `epdSessionForceOffLocked` holds across +`bbepSleep` → `bbepWaitBusy`), so the residual is specifically "a hold that never *ends*", not "a +hold that runs long". With P2-9 also dropped, **nothing in Phase 2 detects or reports that stall on +either target** — recovery rests entirely on Phase 6's supervisor, and on nRF (where the ESP32 +wall-clock watchdogs do not run) on nothing at all until Phase 6 lands. The `[C2]` reasoning stands +and is worth preserving: +if anyone revisits this, **do not steal the lock** — it is a bare 0/1 flag with no owner, so a steal +makes the true holder's later `Give` unlock it underneath the stealer, permanently destroying mutual +exclusion on the panel's SPI/CS lines. + +**Knock-on: five decisions become moot** — D-A, D-A2, D-B, D-C and D-E all existed only to shape +this item. See the Decisions section. + +The original specification is retained below for the record. + +--- + +### The rule *(retained for the record — not implemented)* + +> `pwrmgmLockTake()` gets a deadline and a `bool` return. On expiry it **does not acquire**. The +> caller skips its panel work, reports failure upward, and sets a sticky `panelStateUnknown` flag. +> Nothing is ever stolen. + +Stealing a bare 0/1 flag is unrecoverable: two tasks would drive the same SPI/CS lines, and the +original holder's eventual `pwrmgmLockGive()` ([:412](../src/display_service.cpp)) unlocks the +lock *out from under the stealer*, permanently killing mutual exclusion. There is no owner field +to detect it with. This is the whole point of `[C2]`. + +### Deadline: 60 000 ms — justification + +The bound must exceed the longest **legitimate** hold, or it converts a slow panel into a +spurious failure. Longest legitimate hold, measured from the sources: + +- `epdSessionAcquire` ([:437-489](../src/display_service.cpp)) holds across `bbepWakeUp` + + `bbepSendCMDSequence`, each of which can call `bbepWaitBusy` → up to **30 000 ms** on a + 3/4/7-colour panel (`bb_ep.inl:3966-3968`). +- `epdSessionForceOffLocked` ([:416-433](../src/display_service.cpp)) holds across `bbepSleep` → + `bbepWaitBusy` → another **30 000 ms**, plus a `delay(50)` loop. + +So a single hold can legitimately approach 30 s, and a queued acquire behind a force-off can +legitimately wait ~30 s more. **60 s = 2× worst-case single busy wait** is the smallest number +that cannot fire on healthy hardware. Do not tune it down without re-deriving from +`bbepWaitBusy`'s `iMaxTime`. + +### Code shape + +```c +// display_service.cpp — replace :401-408 +// Bounded acquire. Returns false on timeout WITHOUT acquiring; the caller must +// then skip all panel work. We deliberately do NOT steal: pwrmgmLock is a bare +// flag with no owner, so a steal lets two tasks drive the same SPI/CS and the +// true holder's later Give unlocks it under the stealer -- mutual exclusion +// permanently dead. Deadline is 2x bbepWaitBusy's 30 s multi-colour cap. +#define PWRMGM_LOCK_TIMEOUT_MS 60000u + +static bool pwrmgmLockTake(void) { + const uint32_t start = millis(); + while (__atomic_exchange_n(&pwrmgmLock, 1, __ATOMIC_ACQUIRE)) { + if ((uint32_t)(millis() - start) > PWRMGM_LOCK_TIMEOUT_MS) { + od_log_error("[EPD session] pwrmgm lock TIMEOUT after %u ms - panel state UNKNOWN", + PWRMGM_LOCK_TIMEOUT_MS); + panelStateUnknown = true; + return false; + } + delay(1); // vTaskDelay: must yield, see priority-inversion note below + } + return true; +} +``` + +Keep the existing priority-inversion comment verbatim — the `delay(1)` is load-bearing on nRF and +someone will otherwise "optimise" it back to a busy-spin. + +### The five call sites + +`pwrmgmLockTake()` has five callers today. Each needs an explicit failure branch. `pwrmgmLockGive()` +must be reachable on **every** path that took the lock and on **no** path that didn't. + +| Site | Function | Failure behaviour | +|---|---|---| +| [:439](../src/display_service.cpp) | `epdSessionAcquire(bool partialInit)` | **Signature change** — must report failure. See below. | +| [:496](../src/display_service.cpp) | `epdSessionRelease(bool)` | Return early. Panel is left however it is; `panelStateUnknown` is set. | +| [:513](../src/display_service.cpp) | `epdSessionForceOff(void)` | Return early. Log ERROR — this is the worst one to lose (rail stays up). | +| [:520](../src/display_service.cpp) | `epdSessionTick(void)` | Already `pwrmgmLockTryTake()` — **unchanged**. | +| — | `epdSessionForceOffLocked` | Caller-holds-lock; **unchanged**. | + +`epdSessionAcquire` currently returns `bool cold` — a *result*, not a status. Two options: + +- **(a)** `static bool epdSessionAcquire(bool partialInit, bool* outCold)` — returns success. +- **(b)** Keep `bool cold` and expose the failure via `panelStateUnknown`, checked by callers. + +**Recommend (a).** Option (b) makes every caller's happy path silently proceed to drive an +un-acquired panel, which is exactly the class of bug this phase exists to remove. (a) is a +mechanical change across the `epdSessionAcquire` call sites and the compiler finds them all. + +### `panelStateUnknown` → Phase 3 + +```c +// display_service.h +extern volatile bool panelStateUnknown; // sticky: a panel op was skipped on a lock timeout +``` + +- Set: only by `pwrmgmLockTake()` on expiry. +- Cleared: on the next **successful** `epdSessionForceOff()` — that is the one operation that + restores a known state (rail down). +- Consumed: **→ Phase 3.** `abortToKnownState()` reports it and skips `epdSessionForceOff()` + when set (retrying a lock that just timed out costs another 60 s inside the abort path). + Phase 2 only produces it; nothing reads it yet, which is intentional and should be noted in + the commit message so review does not flag it as dead code. + +### Deferred: the owner handle + +`volatile TaskHandle_t pwrmgmOwner` (so a stale `Give` becomes detectable) is **not** in Phase 2. +It is only needed if a forced take ever becomes necessary, and Phase 2's position is that it never +is. Recorded here so the option is not rediscovered from scratch. + +--- + +## ~~P2-2~~ — Bound the `powerOff` stuck-button wait — ❌ **DROPPED** + +*Dropped 2026-07-26 by owner decision.* **Not implemented in Phase 2.** +[power_latch.cpp:85-90](../src/power_latch.cpp) is unchanged; a stuck-low button pin still means the +device never powers off. Narrow residual: ESP32-only (the whole file is `#if defined(TARGET_ESP32)`), +requires a hardware fault in the button itself, and it removes a *recovery* path rather than adding a +freeze — the device is not wedged by it, the user's last-resort power-off is simply unavailable. It +compounds the parent plan's residual-risk note that "hold the button" is already a weak fallback +while `loop()` is blocked. + +The original specification is retained below for the record. + +--- + +*(retained for the record — not implemented)* Today a shorted or stuck-low button pin means the +device never powers off — the user's last-resort recovery is gone (see the parent plan's residual-risk +section, which already calls this out as the reason "hold the button" is a weak fallback). + +```c +void powerOff() { + const gpio_num_t latch = latchPin(); + if (hasButton()) { + pinMode(buttonPin(), INPUT_PULLUP); + // Bounded: a stuck-low button must not block power-off forever. After + // 10 s we drop the latch anyway -- worst case the rail cycles and the + // still-held button re-latches, which is indistinguishable to the user + // from a normal press-and-hold-too-long. + const uint32_t start = millis(); + while (digitalRead(buttonPin()) == LOW) { + if ((uint32_t)(millis() - start) > 10000u) { + od_log_warn("[power] button still held after 10 s - dropping latch anyway"); + break; + } + delay(20); + } + } + ... +``` + +**Why the wait exists at all:** it prevents the rail dropping while the button is still held, which +on a latching board would immediately re-latch and power the device back on. Bounding it +reintroduces exactly that possibility after 10 s — which is the correct trade (a power-cycle is +recoverable; a device that cannot be turned off is not). + +`power_latch.cpp` is inside `#if defined(TARGET_ESP32)`; **nRF is unaffected** by this item. + +--- + +## ~~P2-5~~ — Wall-clock cap on the loop command drain — ❌ **DROPPED** + +*Decided 2026-07-26.* **Not implemented in Phase 2.** No change to the drain loop at +[main.cpp:406-424](../src/main.cpp) — no `COMMAND_DRAIN_BUDGET_MS`, no cap, and no saturation WARN +either. The whole item is out. + +Two consequences worth carrying forward: + +- **Phase 3 owns the drain loop uncontested.** `[M5]`'s drain-trap fix is now the only edit to those + five lines; there is no `drainStart` in scope and no merge conflict to sequence around. +- **The saturation signal moves to Phase 7, where it belongs.** "The command ring ran full" is + `[H1]`'s subject matter, not a Phase 2 bound. If a diagnostic is wanted, Phase 7 should add it + alongside its overflow handling rather than Phase 2 bolting a WARN onto a loop it otherwise does + not touch. + +The analysis below is retained so this is not re-proposed. + +### The stated rationale does not survive checking + +The parent plan asks for a 2 s cap because "a full window of commands can hold `loop()` for +minutes." Walked through against the code, that cannot happen: + +| Scenario | Actual cost | Does a 2 s cap help? | +|---|---|---| +| Full window of 32 pipe DATA frames | Each is a zlib inflate + SPI write — single-digit ms. 32 of them ≈ **0.1–1 s** even on a slow target | **No** — never reaches 2 s | +| One END frame triggering a full refresh | **30–60 s**, the genuinely long case | **No** — the check is *between* commands and cannot interrupt one | +| Several refreshes stacked in one drain — the only case a cap would catch | **Unreachable.** A second `0x0072` short-circuits at [display_service.cpp:2366](../src/display_service.cpp) (`if (!directWriteActive) return;`); the pipe END paths are guarded by `pipeState.active` | **N/A** | + +So the cap fires in no realistic scenario, and in the one case where `loop()` really is blocked for +a minute it is powerless by construction. **The drain is already bounded** — 33 × (per-command +time), and per-command time is bounded by P2-1, P2-4 and P2-8. The real cost of a saturated drain +is ~1 s of unserviced touch/buttons, which is not the unbounded-wait class Phase 2 exists to fix. + +I had also flagged this item as a **merge hazard with Phase 3's `[M5]`** — it edits the same five +lines. Dropping it removes that conflict for free. + +### The one residual argument, and why it is not enough + +The drain is the one place where many non-yielding operations run back-to-back, and ESP32's TWDT +panics at 5 s of IDLE0 starvation. A 2 s cap would guarantee a yield point inside the drain and so +buy TWDT margin if commands ever got much slower. But at today's ~1 s worst case the margin is +already 5×, and adding a mechanism against a hypothetical future regression is exactly the +speculative engineering this plan rejects elsewhere (see D-I option 3). + +### Original proposal, retained for the record + +```c +{ + uint8_t drained = 0; + const uint32_t drainStart = millis(); + while (drained < COMMAND_QUEUE_SIZE) { + // Wall-clock cap alongside the count cap: a single command (a pipe END + // frame runs a full refresh inline) can take seconds, so 33 of them can + // hold loop() for minutes and starve disconnect cleanup / epdSessionTick / + // WiFi / buttons. Unconsumed commands stay queued and drain next pass. + if (drained > 0 && (uint32_t)(millis() - drainStart) > COMMAND_DRAIN_BUDGET_MS) { + od_log_warn("[drain] budget exceeded after %u commands - deferring rest", drained); + break; + } + ... +``` + +**`COMMAND_DRAIN_BUDGET_MS = 2000`** (parent plan's number). Note the `drained > 0` guard: the cap +must never prevent the *first* command from running, or a single slow command starves the queue +forever. + +Notes that would have applied: break-don't-drop (unconsumed entries stay in the ring, serviced next +pass); the check cannot fire mid-command; `flushResponseQueueToBle()` already runs between commands +([:422](../src/main.cpp)) so breaking early could not strand pipe ACKs. + +**Merge hazard (now avoided by dropping the item):** Phase 3's `[M5]` edits the same five lines — +it inserts a `commandDrainAbortPending` check between [:415](../src/main.cpp) and `:416` and deletes +the vestigial `pending` field. If P2-5 is kept after all, land it before Phase 3 and note there that +`drainStart` is already in scope. + +--- + +## ~~P2-6~~ — Delete the inert TWDT flag, document the real one — ❌ **DROPPED** + +*Dropped 2026-07-26 by owner decision.* **Not implemented in Phase 2.** +`-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` stays in all 9 ESP envs +(`platformio.ini:53, 83, 112, 140, 189, 209, 229, 253, 295`), and no comment is added recording the +real setting. + +**Residual: a misleading dead knob stays in the tree.** The symbol is not an IDF 5.x setting — the +real one is `CONFIG_ESP_TASK_WDT_TIMEOUT_S`, and the precompiled `sdkconfig.h` wins regardless, so +the true watchdog is **5 s / panic on IDLE0 starvation**, not the 120 s the flag implies. Today's +30–60 s waits survive only because every one of them yields. The next reader of `platformio.ini` +has no way to know that from the tree. Not a freeze risk — the flag has never done anything — but +it is a live source of wrong conclusions, and it is the cheapest item in the phase (a build-flag +deletion plus one comment). Worth revisiting whenever anything else touches `platformio.ini`. + +**Consequence:** Phase 2 now touches **no build configuration and no `src/main.cpp`** — the item was +the only reason for either. + +The original specification is retained below for the record. + +--- + +### Original specification *(retained for the record — not implemented)* + +Remove `-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` from all 9 ESP envs (`platformio.ini:53, 83, 112, +140, 189, 209, 229, 253, 295`). It is an IDF 4.x symbol name; IDF 5.x uses +`CONFIG_ESP_TASK_WDT_TIMEOUT_S`, which the precompiled `sdkconfig.h` fixes at 5 and which a +`build_flags` define cannot override anyway (same mechanism as the documented +`CONFIG_BT_NIMBLE_MAX_CONNECTIONS` trap). Delete rather than rename — renaming to the correct +symbol would be a no-op that *looks* effective, which is worse than the current dead knob. + +Replace with a comment at the top of `loop()` in `main.cpp`: + +```c +// Task watchdog reality check (do not "fix" this with a build flag): +// the precompiled IDF 5.5.4 sdkconfig.h fixes CONFIG_ESP_TASK_WDT_TIMEOUT_S=5 +// with _PANIC=1 and _CHECK_IDLE_TASK_CPU0=1, and a -D in build_flags cannot +// override it. So the real bound on this task is 5 s of IDLE0 starvation -> +// panic reboot. Today's 30-60 s panel waits survive only because every one of +// them yields (delay()/vTaskDelay()/bbepLightSleep()). Any new busy-spin in a +// panel or BLE path WILL reboot the device. A reboot is also the one outcome +// this whole effort is trying to avoid -- it wipes RTC state including +// displayed_etag and forces a boot-screen redraw. +``` + +Optionally add one line to `docs/TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md` recording that the +120 s entry was fiction. + +--- + diff --git a/docs/PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md b/docs/PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md new file mode 100644 index 0000000..60f9664 --- /dev/null +++ b/docs/PLAN_PHASE2_REFRESH_BOUNDS_2026-07-26.md @@ -0,0 +1,278 @@ +# Phase 2 Implementation Plan — Bound the Refresh Waits + +**Branch:** `debug/freeze-fix-phase2` (cut from Phase 1 as-built) · **Date:** 2026-07-26 +**Parent plan:** [`PLAN_FREEZE_PROOFING_2026-07-26.md`](PLAN_FREEZE_PROOFING_2026-07-26.md) § "Phase 2" +**Supersedes:** [`PLAN_PHASE2_BOUND_WAITS_2026-07-26.md`](PLAN_PHASE2_BOUND_WAITS_2026-07-26.md) +— **obsolete**, retained for its analysis of the five cut items and the eight decisions they carried. + +--- + +## What Phase 2 is now + +**Three edits, two files, one subsystem: the e-paper refresh wait.** + +| # | Item | File | Targets | +|---|---|---|---| +| **P2-3** | `epdRefreshInProgress` around both boot-refresh paths | `display_service.cpp` | both (inert on nRF today) | +| **P2-8** | `waitforrefresh` → wall-clock deadline instead of an iteration count | `display_service.cpp` | both — **nRF-critical** | +| **P2-4** | `fastepd_wait_refresh` → a real bounded wait instead of a stub | `display_fastepd.cpp` | ESP32 + FastEPD only | +| *(P2-7)* | *optional, recommend defer* — `Wire.setTimeOut(25)` | `display_service.cpp` | ESP32 | + +Landing order **P2-3 → P2-8 → P2-4**. Independent of each other; each is separately revertable. + +**Not in scope, and deliberately so.** The earlier plan also proposed a `pwrmgmLockTake` deadline, a +`powerOff` stuck-button bound, a loop-drain cap, deleting the inert TWDT build flag, and a +loop-liveness monitor task. All five were cut. Their specifications and the reasoning behind them +live in the superseded document; the residual each leaves is recorded in the parent plan and +restated under "What Phase 2 does not do" below. + +**Constraints.** No new file. No new task. No change to `src/main.cpp`, `platformio.ini`, or +anything under `include/`. `src/session_guard.*` belongs to Phase 3 and is not referenced here. + +--- + +## The problem, stated precisely + +A refresh is the longest thing this firmware does — 30–60 s on a colour panel. Three defects mean +that wait is either unmeasured, unbounded, or invisible: + +### 1. `waitforrefresh` counts iterations, not time + +[display_service.cpp:747-776](../src/display_service.cpp): + +```c +for (size_t i = 0; i < (size_t)(timeout * 100); i++){ + delay(10); + ... + if(!bbepIsBusy(&bbep)){ ... return true; } +} +od_log_warn("Refresh timed out"); +``` + +`timeout * 100` iterations of `delay(10)` equals `timeout` seconds **only if each iteration really +takes 10 ms**. `delay()` is `vTaskDelay` on both targets — it yields, and it guarantees only a +*minimum*. Under contention each pass can take arbitrarily longer, so the "60 s" bound is really +"60 s of scheduled time for this task", with no ceiling in wall-clock terms. + +**This is worst exactly where it matters most.** Phase 1 established on hardware that nRF's `loop()` +task (priority 1, `rtos.h:58`) is starved mid-transfer by the callback task (2) and the Bluefruit +task (3) — its deferred link-drop never ran, forcing the inline disconnect in `23ecaed`. A refresh +wait that measures scheduled iterations is precisely the wrong instrument on a task that can be +descheduled for long stretches. + +### 2. On FastEPD panels there is no wait at all + +[display_fastepd.cpp:228-231](../src/display_fastepd.cpp) — the whole function: + +```c +bool fastepd_wait_refresh(int timeout_sec) { + (void)timeout_sec; + return !s_init_failed; +} +``` + +It ignores its timeout and returns immediately. And [display_service.cpp:749](../src/display_service.cpp) +short-circuits to it before any of the polling loop above: + +```c +if (fastepd_driver_used()) return fastepd_wait_refresh(timeout); +``` + +So on IT8951/E1004, `waitforrefresh(60)` returns `true` in microseconds. **The documented 60 s cap +does not exist on those panels, and neither does the wait** — callers proceed as though the panel +had finished. This is `[X3]` from the original review, confirmed against the current tree. + +### 3. Boot refreshes are invisible to every gate + +`epdRefreshInProgress` ([display_service.cpp:85](../src/display_service.cpp)) is set/cleared around +the two *transfer* refresh paths — [:2415](../src/display_service.cpp)/[:2436](../src/display_service.cpp) +and [:3284](../src/display_service.cpp)/[:3294](../src/display_service.cpp) — but **not** around +either boot path: + +| Boot path | Site | Sets the flag? | +|---|---|---| +| `refreshBootScreenFull()` | [:532-542](../src/display_service.cpp) — `bbepRefresh` then `waitforrefresh(60)` | ❌ | +| FastEPD boot | [:1587-1595](../src/display_service.cpp) — `fastepd_full_update()` then `waitforrefresh(60)` | ❌ | + +Its consumers all treat the flag as "a refresh is in flight, do not disturb": +[ble_init.cpp:236](../src/ble_init.cpp) (advertising restart), +[main.cpp:322](../src/main.cpp) (defer disconnect cleanup), +[main.cpp:482](../src/main.cpp) (`workInFlight` / deep-sleep gate), and Phase 6's supervisor rule +"never interrupt a refresh". During a 30–60 s boot refresh every one of them believes the device is +idle. + +--- + +## P2-3 — Set `epdRefreshInProgress` around both boot paths + +Wrap each boot refresh exactly as the transfer paths already do: set before the refresh call, clear +after the wait returns, on **every** exit path including the failure returns. + +- `refreshBootScreenFull()` [:532-542](../src/display_service.cpp) — note the early `return false` + when `writeBootScreenWithQr()` fails; the flag must not be left set on that path. +- FastEPD boot [:1587-1595](../src/display_service.cpp) — the flag must be cleared before + `epdSessionForceOff()`. + +**Scoped-guard pattern preferred** over paired assignments, so a future early return cannot leak the +flag. If a plain pair is used instead, say why in the commit message. + +**On nRF this is inert today, and that is fine.** All three current consumers are inside ESP32-only +code, so setting the flag on nRF changes nothing at runtime. It is still correct, it costs nothing, +and Phase 6 adds the nRF consumers. **Say so in the commit message** — otherwise the next reader +sees a flag set and never read, and "fixes" it. + +--- + +## P2-8 — Give `waitforrefresh` a real deadline + +Replace the iteration count with a `millis()` deadline. Keep everything else: the 10 ms poll, the +`i == 0` "never went busy" error, the progress dots, the completion log. + +```c +const uint32_t deadline = millis() + (uint32_t)timeout * 1000u; +bool first = true; +while ((int32_t)(millis() - deadline) < 0) { + delay(10); + ... + if (!bbepIsBusy(&bbep)) { /* first-pass error check, elapsed log, return true */ } + first = false; +} +od_log_warn("Refresh timed out after %u ms (deadline %d s)", elapsed, timeout); +return false; +``` + +Three details that matter: + +1. **Signed-difference comparison**, `(int32_t)(millis() - deadline) < 0`, not `millis() < deadline` + — correct across the 49.7-day `millis()` wrap. The codebase already uses this idiom + ([display_service.cpp:522](../src/display_service.cpp)). +2. **Keep the `i == 0` semantics** as a *first-iteration* check, not an index test. It catches "the + panel never asserted BUSY", i.e. the refresh never started, which is a different failure from a + timeout and is worth keeping distinct in the log. +3. **Report elapsed wall-clock on both exits.** The current success path logs `i / 100` — an + iteration count presented as seconds, which is exactly the confusion this item removes. + +**Timebase caveat, accepted (was D-K).** On nRF `millis()` is `tick2ms(xTaskGetTickCount())` — the +FreeRTOS tick at 1024 Hz with `configUSE_TICKLESS_IDLE 1` — not a hardware timer as on ESP32. It +advances while the task is descheduled, which is what this item needs, but it is not a +high-integrity clock: a fault that stops the scheduler also stops the deadline. That fault class is +already accepted as unrecoverable software-side. **Do not chase it here.** + +--- + +## P2-4 — Make `fastepd_wait_refresh` real + +Implement it as a bounded poll of the IT8951 LUT-busy state, honouring `timeout_sec` with the same +`millis()` deadline idiom as P2-8, returning `false` on expiry. + +**Wrap the path a real transfer takes.** [display_service.cpp:2422-2423](../src/display_service.cpp): + +```c +fastepd_direct_refresh(refreshMode); +refreshSuccess = waitforrefresh(60); +``` + +`fastepd_direct_refresh` is what a transfer calls — not only `fastepd_full_update`. Both must end up +covered; the original `[X3]` finding specifically called out wrapping the wrong one. + +**Blast radius is one env family.** Guarded by `TARGET_ESP32 && OPENDISPLAY_FASTEPD` and reached +only when `fastepd_driver_used()`, so `esp32-s3-E1004` is the build and behaviour gate. Every other +env keeps today's `bbepIsBusy` loop unchanged. + +**This changes observable timing.** Today the call returns immediately; afterwards it blocks until +the panel is genuinely idle. That is the point — callers currently believe a refresh finished when +it had not — but it means the E1004 upload regression test is not optional (see Verification). + +--- + +## P2-7 — *(optional, recommend defer)* + +`Wire.setTimeOut(25)` after each `Wire.begin()`, halving the ~50 ms Arduino default that a failing +GT911 transaction blocks for. Worst case today is ~250 ms of blocked `loop()` before the driver +disables the controller after 5 consecutive failures — bounded and acceptable, which is why `[X1]` +was downgraded. **ESP32 only: the API does not exist on nRF**, whose TWIM driver busy-spins with no +timeout at all (`Wire_nRF52.cpp:166-181`). That nRF gap needs a physical fault to trigger, is +outside this effort's freeze class, and is *not* addressed here — but note that `[X1]`'s +"the driver gives up after 5 failures" reasoning is ESP32-specific and does **not** transfer to nRF. + +--- + +## What Phase 2 does not do + +Recorded so nobody assumes Phase 2 covered it: + +- **`pwrmgmLockTake` stays unbounded** on both targets ([display_service.cpp:401-408](../src/display_service.cpp)). + A panel-lock holder that never releases blocks its waiter forever. No `panelStateUnknown` flag is + produced — **Phase 3 must not expect one**. +- **A stalled `loop()` is undetected on both targets.** ESP32's TWDT (5 s, panic on IDLE0) will not + fire, because every long wait here yields and IDLE0 is never starved. nRF has no watchdog at all. + Detection is entirely Phase 6's. +- **`powerOff`'s stuck-button wait stays unbounded** (ESP32-only, needs a hardware fault). +- **The inert `-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` stays** in 9 ESP envs, still implying a + 120 s watchdog that does not exist. + +**So Phase 2 bounds the refresh waits. It does not detect stalls, and it is not the "defensive +floor" the earlier plan described** — that framing depended on the monitor task and no longer holds. + +--- + +## Decisions + +None blocking. Four carried over, all with a default: + +| | Question | Default | +|---|---|---| +| **D-D** | Accept the `[X3]` downgrade (implement the real wait rather than a larger redesign)? | **yes** — this plan assumes it | +| **D-G** | Include P2-7? | **no**, defer | +| **D-K** | Accept nRF's tick-derived `millis()`? | **yes** — see P2-8 | +| **D-L** | nRF I2C busy-spins with no timeout: bound it or accept it? | **accept**, out of scope | + +--- + +## Verification + +### Build + +```bash +/home/davelee/.platformio/penv/bin/pio run # all 12 envs; pio is NOT on PATH +``` + +`esp32-s3-E1004` is the P2-4 gate; `nrf52840custom` is the P2-8 gate. CI also runs the `host-tests` +job added by Phase 1 — Phase 2 adds nothing to it. + +### Static + +- `git diff --stat` touches **only** `src/display_service.cpp` and `src/display_fastepd.cpp`. + Any new file, or any change to `src/main.cpp`, `platformio.ini` or `include/`, means scope crept. +- `pwrmgmLockTake` is **unchanged**, signature included. +- No `epdRefreshInProgress = true` without a matching clear on every path out, including failures. +- No remaining `timeout * 100` iteration arithmetic in `waitforrefresh`. + +### Hardware — both targets required + +1. **Boot refresh (P2-3)** — cold boot with a colour panel; confirm a BLE connect during the boot + refresh is deferred rather than acted on mid-refresh, and that deep sleep is not entered during it. +2. **Normal refresh (P2-8)** — full transfer + refresh completes; the completion log now reports + **elapsed milliseconds**, and it should be close to the real refresh duration. +3. **Timeout path (P2-8)** — force a panel that never clears BUSY; confirm the warning fires at + ~`timeout` seconds of *wall clock*, not later. +4. **Never-started path (P2-8)** — confirm the "not busy after refresh command" error still fires, + distinct from a timeout. +5. **E1004 regression (P2-4)** — a ~960 KB upload + refresh completes, and the wait now takes real + time instead of returning instantly. **This is the test most likely to surface a surprise**, since + callers have never previously waited on this path. +6. **nRF under load (P2-8)** — refresh during a BLE transfer, where the loop task is contended. The + deadline should hold in wall-clock terms; an iteration-counted wait would have overrun. + +--- + +## Residual risk after Phase 2 + +- A hard hang inside a library call below our waits (wedged SPI, stuck DMA) is still invisible — + `bbepWaitBusy`'s own 30 s cap and `it8951WaitForLUTReady`'s 30 s cap are the library's, not ours. + Consistent with the software-only decision. +- A single long refresh still owns `loop()` for its duration. By design: interrupting a refresh is + worse than waiting for it. P2-8 bounds it; it does not shorten it. +- On nRF, a scheduler-stopping fault stops `millis()` and therefore the deadline (D-K). +- Everything under "What Phase 2 does not do" — most consequentially, **nothing here detects a + stalled `loop()`**. That now rests entirely on Phase 6, on both targets. diff --git a/docs/PLAN_PHASE3_SESSION_GUARD_2026-07-26.md b/docs/PLAN_PHASE3_SESSION_GUARD_2026-07-26.md new file mode 100644 index 0000000..10ebc53 --- /dev/null +++ b/docs/PLAN_PHASE3_SESSION_GUARD_2026-07-26.md @@ -0,0 +1,1469 @@ +# Phase 3 Implementation Plan — `abortToKnownState()` + queue flushes + drain-trap fix + +> Companion to `PLAN_FREEZE_PROOFING_2026-07-26.md` (§ "Phase 3") and +> `FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md` (findings `[M3]`, `[M5]`, `[H4]`, +> build/portability check). +> Branch: `debug/ble-hardening`. Target: land after Phase 1 (nonce) and Phase 2 (bounded +> waits), before Phase 4 (owner token). +> +> **Bound by the parent plan's *"Hard constraint — NO wire protocol changes"* (§32-57 there). +> Its application to Phase 3 is §2 below; it settles Decision D3 and removes step 3 from the +> teardown.** + +--- + +## 1. What Phase 3 is, and what it deliberately is not + +Phase 3 builds the **recovery mechanism** — a single, ordered, idempotent teardown that +returns the device to a known-good state from any wedged transfer — plus the two +ring-buffer primitives it needs and one latent correctness bug in the command drain. + +It does **not** decide *when* to recover. Every trigger lives elsewhere: + +| Trigger | Phase | Calls | +|---|---|---| +| **BLE/LAN disconnect teardown** | **3 (D1b — see below)** | `abortToKnownState("disconnect", false)` | +| `integrity_failures >= 3` | 5 | `abortToKnownState(..., true)` | +| `reloadConfigAfterSave` | 5 | `abortToKnownState(..., true)` | +| 10-min no-progress supervisor | 6 | `abortToKnownState("supervisor", true)` | +| Recurring command-ring overflow | 7 | via supervisor, not directly | +| BLE idle timeout (5 min) | 7 | `abortToKnownState("idle", true)` | + +**Decided (D1 = b): Phase 3 wires the two existing disconnect teardowns to +`abortToKnownState()` now.** As originally scoped by the parent plan Phase 3 would have +shipped with zero callers — the review flags this ("`abortToKnownState` has no callers until +Phase 5/6, so it is dead code"). Instead, [main.cpp:343-347](../src/main.cpp) (ESP32) and +[device_control.cpp:237-239](../src/device_control.cpp) (nRF) are replaced with +`abortToKnownState("disconnect", false)`, which is a strict superset of what those sites do +today. The teardown therefore becomes the most-exercised path in the firmware immediately, +and the hardware tests in §12 test real behaviour rather than a synthetic trigger. + +This has three consequences that shape the rest of this plan; all are handled in **§9**: +1. The ESP32 `ownerStillUp` guard must stay **in front of** the call — a WiFi drop also routes + into `serviceBleDisconnectCleanup` ([wifi_service.cpp:812](../src/wifi_service.cpp)). +2. The nRF site runs on the Bluefruit Callback task, not `loop()`, and the teardown now + includes work ESP32 deliberately defers. nRF needs the same deferral — see §9.2. +3. Combined with **D6**, Phase 3 now delivers the "clear encryption session on BLE disconnect" + behaviour the parent plan assigned to Phase 5. See §9.3. + +Deliverables: + +1. `src/session_guard.h` / `src/session_guard.cpp` (new) — flags, progress stamp storage, + `abortToKnownState()`. +2. `flushCommandQueue()` / `flushResponseQueue()` in `main.cpp`. +3. Drain-trap fix `[M5]` in `main.cpp`, + removal of the vestigial `pending` field. +4. `g_commandInFlight` as a `volatile uint8_t` depth counter `[H4]`. +5. New helpers the teardown needs and that do not exist today: + `resetChunkedWriteState()`, `touchForceResume()` `[M3]`, `buzzerForceStop()`, + `ledForceStop()`. +6. **(D1b)** Both disconnect teardowns rewired to `abortToKnownState()`, plus a new + loop-serviced deferral on nRF (§9). +7. **(D6c)** `linkIsUp()` — portable per-target predicate defined in `main.cpp`, declared in + `session_guard.h` (§3a), gating the session clear. +8. **(D7)** `epdStreamInProgress` + `epdForceOffPending` + `serviceDeferredPanelOff()` — the + panel-safety pair that makes an ungated abort safe and guarantees a refresh always completes + (§8.3.1); and the invalidate/scrub split in `clearEncryptionSession()`. + +--- + +## 2. Hard constraint — NO wire protocol changes, applied to Phase 3 + +The parent plan's *"Hard constraint — NO wire protocol changes"* (§32-57) governs every phase. +Phase 3 is the phase least likely to bump into it — it is pure internal state management — +but it touches two things that *look* like protocol surface and one decision that genuinely +was. Recorded here so implementation does not have to re-derive the reasoning. + +**In bounds, and why:** + +| Phase 3 change | Why it stays inside the constraint | +|---|---| +| Deleting the `pending` field from `CommandQueueItem` / `ResponseQueueItem` (§5) | Both are **firmware-local runtime structs** — [esp32_ble_callbacks.h:25](../src/esp32_ble_callbacks.h) and [structs.h:86](../src/structs.h). Neither is in `include/opendisplay_structs.h`; neither appears on the wire. This is *not* the config-packet layout the constraint protects. | +| New flags, `g_commandInFlight`, `g_lastProgressMs` | Firmware-local scalars in a new `.cpp`. No client can observe them. | +| `flushCommandQueue()` discarding queued commands | Client-observably identical to the existing ring-full drop ([esp32_ble_callbacks.h:128](../src/esp32_ble_callbacks.h)) — a frame the device never answers. The pipe protocol is designed for exactly this: the zero bit in the next SACK triggers a client retransmit (`docs/pipe-write-protocol.md` §5.2). No new behaviour, no new code on the wire. | +| `flushResponseQueue()` discarding queued responses | Same shape as the existing full-ring drop at [communication.cpp:113-116](../src/communication.cpp), and as the unconditional drain-to-nowhere when no central is connected ([main.cpp:307-312](../src/main.cpp)). | +| `abortToKnownState(..., dropLink=true)` terminating the link | Explicitly in bounds: *"Dropping a link … is always a legal outcome; clients already handle it and reconnect."* | +| `resetChunkedWriteState()` | Internal bookkeeping for a transfer the device has already abandoned. The client's own timeout is what it reacts to. | +| Drain-trap fix, depth counter, `main.h:365` comment fix | Correctness and documentation only. | + +**Out of bounds for Phase 3 — do not do these:** + +- **Do not invent a new abort/NACK frame.** A generic `{RESP_NACK, 0x00, reason}` was a + candidate for the teardown's client notification; it is a new response shape and therefore + forbidden. This is now settled in **Decision D3**, not an open option. +- **Do not send an existing `RESP_*`/NACK code in a situation that changes its documented + meaning.** Reuse is permitted *"as long as the code's documented meaning is unchanged"* — + so a `{0xFF,0x81}` pipe NACK may only be sent for a genuine pipe failure, never as a + general-purpose "I aborted" signal for a chunked-config or direct-write abort. +- **Do not touch** `include/opendisplay_protocol.h` or `include/opendisplay_structs.h`, and + do not push anything through `../opendisplay-protocol`. +- **Do not change** SACK semantics, discard rules, or NACK meaning as documented in + `docs/pipe-write-protocol.md`. Phase 3 adds no note to that file; the §5.1 documentation + note the parent plan permits belongs to **Phase 5** (the pipe error-release deadline). + +**Escalation rule:** if any part of Phase 3 appears to need a protocol change to work, stop +and escalate rather than pushing a header change. Nothing in the scope above should reach +that point — the only candidate was D3's notification frame, and it is resolved by sending +nothing. + +The constraint's own verification (§12) must pass before Phase 3 is called done. + +--- + +## 3. Current-state facts this plan is built on (verified) + +| Fact | Location | +|---|---| +| Command ring is ESP32-only, SPSC: producer = NimBLE host task (`onWrite`), consumer = loop | [esp32_ble_callbacks.h:118-128](../src/esp32_ble_callbacks.h), [main.cpp:406-423](../src/main.cpp) | +| `COMMAND_QUEUE_SIZE 33` is defined **twice** — `main.h:371` and `esp32_ble_callbacks.h:19` (guarded `#ifndef`) | [main.h:371](../src/main.h), [esp32_ble_callbacks.h:18-19](../src/esp32_ble_callbacks.h) | +| Response ring is ESP32-only (10 slots), head **and** tail both written on the loop task | [communication.cpp:112-120](../src/communication.cpp), [main.cpp:276-312](../src/main.cpp) | +| nRF has **neither** ring — `imageDataWritten` runs inline on the Bluefruit Callback task; `sendResponse` notifies directly | [communication.cpp:130-132](../src/communication.cpp) | +| Drain caches `tail` at [main.cpp:409](../src/main.cpp), stores `tail+1` at [:417](../src/main.cpp) — a flush from handler context is clobbered | [main.cpp:408-421](../src/main.cpp) | +| `CommandQueueItem.pending` / `ResponseQueueItem.pending` are written but never read | grep: only assignments | +| Existing disconnect teardown (the closest thing to `abortToKnownState` today) | [main.cpp:339-350](../src/main.cpp) ESP32, [device_control.cpp:237-239](../src/device_control.cpp) nRF | +| `transferActive() == directWriteActive \|\| pipeState.active \|\| partialCtx.active` | [display_service.cpp:2502-2504](../src/display_service.cpp) | +| Touch suspend is a **counter** `s_epd_refresh_suspend` (file-static in touch_input.cpp) paired with a **bool** `directWriteTouchSuspended` (file-static in display_service.cpp) | [touch_input.cpp:115-119](../src/touch_input.cpp), [display_service.cpp:2008](../src/display_service.cpp), [:2035-2038](../src/display_service.cpp) | +| Buzzer stop is `static buzzer_stop_internal()` — no public stop entry point | [buzzer_control.cpp:147](../src/buzzer_control.cpp) | +| LED stop is `static led_stop_internal(bool)` — no public stop entry point | [device_control.cpp:341](../src/device_control.cpp) | +| `chunkedWriteState` has no reset function; cleared field-by-field at 4 sites | [communication.cpp:496-513](../src/communication.cpp), [:550](../src/communication.cpp), [:558](../src/communication.cpp), [:574-576](../src/communication.cpp) | +| `esp32-N4` is ESP32 **without** WiFi → LAN code must be `#ifdef OPENDISPLAY_HAS_WIFI`, never `TARGET_ESP32` | platformio.ini:284 | +| nRF sets `lib_ignore = NimBLE-Arduino` → no NimBLE types may leak into shared headers | platformio.ini:36 | + +--- + +## 3a. Header placement — `main.h` is NOT a header `[C1]` + +**Read this before writing any declaration.** `src/main.h` looks like a header and is not one: +it has **no include guard** and it **defines** globals rather than declaring them — +[main.h:91](../src/main.h) `BBEPDISP bbep;`, [:165](../src/main.h) `bool directWriteActive = false;`, +[:283](../src/main.h) `chunked_write_state_t chunkedWriteState = {...};`, [:284](../src/main.h) +`globalConfig`, [:289](../src/main.h) `encryptionSession`, [:374-391](../src/main.h) the rings, +`pServer`, and the callback objects. + +``` +$ grep -rn '#include "main.h"' src/ +src/main.cpp:1:#include "main.h" +``` + +**Exactly one translation unit includes it, and that is load-bearing.** A second includer gets +`multiple definition of 'bbep'` at link time; on nRF it additionally drags in `` +and the NimBLE-aliased `BLE*` types that §10 forbids in shared headers. Note also that +`communication.cpp` does **not** include `main.h` — it re-declares what it needs itself +(`extern chunked_write_state_t chunkedWriteState;` at [communication.cpp:83](../src/communication.cpp)). + +So every new declaration goes in a real guarded header. All of these exist and are correctly +guarded already: + +| New symbol | Declared in | Defined in | +|---|---|---| +| `resetChunkedWriteState()` | `communication.h` | `communication.cpp` | +| `flushCommandQueue()` / `flushResponseQueue()` | `session_guard.h` | `main.cpp` (ESP32 real, nRF empty — **both out-of-line**, no `static inline` in a header) | +| `linkIsUp()` | `session_guard.h` | `main.cpp`, per-target `#ifdef` | +| `serviceLinkDrop()` / `g_linkDropPending` | `session_guard.h` | `main.cpp` / `session_guard.cpp` | +| `serviceDeferredPanelOff()` / `epdForceOffPending` | `session_guard.h` | `main.cpp` / `session_guard.cpp` | +| `nrfDisconnectCleanupPending` | `session_guard.h` | `session_guard.cpp` | +| `epdStreamInProgress` | `display_service.h` (next to `epdRefreshInProgress`, [:77](../src/display_service.h)) | `display_service.cpp` | +| `pipeWriteActive()` / `partialWriteActive()` | `display_service.h` | `display_service.cpp` | +| `touchForceResumeAll()` | `touch_input.h` | `touch_input.cpp` | +| `touchForceResume()` | `display_service.h` | `display_service.cpp` | +| `buzzerForceStop()` | `buzzer_control.h` | `buzzer_control.cpp` | +| `ledForceStop()` | `device_control.h` | `device_control.cpp` | + +**`session_guard.h` may include** ``, ``, `structs.h`. **Never** `main.h`, +`ble_init.h`, ``, ``, or ``. +**`session_guard.cpp` may additionally include** `display_service.h`, `communication.h`, +`encryption.h`, `od_log.h`. **Never `main.h`.** + +The nRF no-op flushes must be **out-of-line functions in `main.cpp`**, not `static inline` in a +header — a `static inline` defined in `main.h` is invisible to `session_guard.cpp`, which is the +whole point of the exercise. + +`main.h` edits in this plan are therefore limited to what is *already* there: the `pending` +field removal (step 4) and the [main.h:365-370](../src/main.h) capacity comment (D5). Nothing +new is added to it. + +--- + +## 4. Step 1 — Public stop/reset helpers (prerequisites) + +`abortToKnownState()` needs four entry points that do not exist. Do these first; each is +independently mergeable and independently testable. + +### 4.1 `resetChunkedWriteState()` + +New in `communication.cpp`, declared in **`communication.h`** (§3a — *not* `main.h`, which +cannot be included twice; `communication.cpp` already re-declares `chunkedWriteState` itself at +[communication.cpp:83](../src/communication.cpp)). + +```c +void resetChunkedWriteState(void) { + chunkedWriteState.active = false; + chunkedWriteState.receivedSize = 0; + chunkedWriteState.expectedChunks = 0; + chunkedWriteState.receivedChunks = 0; + chunkedWriteState.totalSize = 0; + // buffer intentionally NOT zeroed: MAX_CONFIG_SIZE memset on every abort is + // wasted work; `active=false` makes the contents unreachable. +} +``` + +Then replace the four ad-hoc clear sites ([:550](../src/communication.cpp), +[:558](../src/communication.cpp), [:574-576](../src/communication.cpp)) with calls to it, so +a future field addition cannot leave a partial reset behind. (The [:496-513](../src/communication.cpp) +*start* path stays as-is — it initialises rather than resets.) + +**Note:** [:574-576](../src/communication.cpp) currently clears only 3 of the 5 fields — +`expectedChunks` and `totalSize` survive a completed config write. Harmless today because +`active=false` gates every reader, but the consolidation fixes it for free. + +### 4.2 `touchForceResume()` `[M3]` + +Two statics in two translation units, so this is two functions: + +```c +// touch_input.cpp / touch_input.h +void touchForceResumeAll(void) { + s_epd_refresh_suspend = 0; +} +``` + +```c +// display_service.cpp / display_service.h — the one abortToKnownState calls +void touchForceResume(void) { + directWriteTouchSuspended = false; + touchForceResumeAll(); +} +``` + +Rationale (review `[M3]`): zeroing the counter alone leaves `directWriteTouchSuspended` +`true`, so the *next* `cleanupDirectWriteState` calls `touchResumeAfterEpdRefresh()` against +an already-zero counter (early-returns at [touch_input.cpp:418](../src/touch_input.cpp)) and +the bool is consumed against a resume that never happened — the two drift apart. Clearing +both keeps them coupled. + +Ordering is load-bearing and matches the parent plan: `cleanupDirectWriteState(true)` runs +**first** (it does the correct paired decrement for the normal case), `touchForceResume()` +runs **later** as the belt-and-braces reset. Do not reorder. + +Add a debug-only assertion after the call that `s_epd_refresh_suspend == 0`. + +### 4.3 `buzzerForceStop()` / `ledForceStop()` + +Thin public wrappers, no behaviour change: + +```c +// buzzer_control.cpp / buzzer_control.h +void buzzerForceStop(void) { buzzer_stop_internal(); } +``` +```c +// device_control.cpp / device_control.h +void ledForceStop(void) { led_stop_internal(false); } // false: leave configured mode intact +``` + +`clear_mode=false` matches [device_control.cpp:394](../src/device_control.cpp) and +[:562](../src/device_control.cpp) — an abort stops the *sequence*, it does not reconfigure +the LED. `clear_mode=true` is reserved for an explicit `0x0075` stop. + +Both are no-ops when nothing is playing, so they are safe to call unconditionally. + +--- + +## 5. Step 2 — The drain-trap fix `[M5]` + +The single live-on-merge correctness fix. Today: + +```c +imageDataWritten(NULL, NULL, commandQueue[tail].data, commandQueue[tail].len); // :415 +commandQueue[tail].pending = false; // :416 +__atomic_store_n(&commandQueueTail, (tail + 1) % COMMAND_QUEUE_SIZE, RELEASE); // :417 +``` + +`imageDataWritten` can (after Phase 5/6) call `abortToKnownState` → `flushCommandQueue()`, +which sets `commandQueueTail := commandQueueHead`. Line 417 then **overwrites** that with a +stale `tail+1`, resurrecting every command the flush just discarded. + +**Exact placement — between `:415` and `:416`**, breaking *without* the tail store: + +```c +{ + // [C6] Clear any flag left over from an abort that ran OUTSIDE a drain -- + // serviceBleDisconnectCleanup() calls abortToKnownState() at main.cpp:370 and + // :428, neither of which is inside this loop. A stale flag would break the + // FIRST command of the next drain out without storing the tail, and that same + // slot would then be dispatched AGAIN on the following pass. See below. + commandDrainAbortPending = false; + + uint8_t drained = 0; + while (drained < COMMAND_QUEUE_SIZE) { + uint8_t tail = __atomic_load_n(&commandQueueTail, __ATOMIC_RELAXED); + uint8_t head = __atomic_load_n(&commandQueueHead, __ATOMIC_ACQUIRE); + if (tail == head) break; + + imageDataWritten(NULL, NULL, commandQueue[tail].data, commandQueue[tail].len); + + // [M5] Must sit HERE -- after the dispatch, before the tail store. A flush + // from handler context already did tail := head; storing tail+1 now would + // resurrect everything it discarded. + if (commandDrainAbortPending) { + commandDrainAbortPending = false; + break; // flushCommandQueue() already advanced the tail + } + __atomic_store_n(&commandQueueTail, (uint8_t)((tail + 1) % COMMAND_QUEUE_SIZE), __ATOMIC_RELEASE); + drained++; + flushResponseQueueToBle(); + } +} +``` + +Placing the check *after* the tail store would be wrong twice over: the store already +clobbered the flush, and the slot at `tail` may have been re-filled by the producer. + +#### `[C6]` The flag must not survive a pass — double-dispatch hazard + +`commandDrainAbortPending` is set by `flushCommandQueue()`, which is called from +`abortToKnownState()`, which D1b calls from `serviceBleDisconnectCleanup()` — and that runs at +[main.cpp:370](../src/main.cpp) (deep-sleep-wake branch, **before** the drain, then `return`s) +and [main.cpp:428](../src/main.cpp) (**after** the drain). Neither is inside the drain loop. + +So without the reset above, with a non-empty ring at that moment: + +1. Abort sets the flag. No drain consumes it this pass. +2. Next pass the drain dispatches `commandQueue[tail]`, *then* sees the stale flag, clears it, + and `break`s **without storing the tail**. +3. The pass after that dispatches the **same slot again**. + +For `CMD_CONFIG_WRITE`, `CMD_POWER_OFF`, `CMD_DEEP_SLEEP` or `CMD_REBOOT` a double dispatch is +not benign — and it would ship in the same commit as the `[M5]` fix it accompanies. Clearing at +the top of the drain block closes it: the flag then only ever spans the dispatch it was raised +during. + +No race: `commandDrainAbortPending` is written only by `flushCommandQueue()` and this reset, +both loop-task-only. The producer never touches it. + +**Alternative considered:** scope the flag to an active drain with a separate `commandDrainActive` +bool, so `flushCommandQueue()` raises the abort flag only when a drain is actually running. It is +more explicit but adds a second flag and a second invariant to keep true; the top-of-block reset +achieves the same thing in one line. **Take the reset; note the alternative if a future caller +ever needs to know whether it interrupted a drain.** + +**Minor, worth a comment not a fix:** when `flushCommandQueue()` runs from *inside* the drain, +its `dropped` count over-reports by one — the currently-dispatching slot has not had its tail +store yet, so it is still counted as queued. Log-only. + +**Delete the `pending` field** from both `CommandQueueItem` and `ResponseQueueItem` in +`structs.h`/`main.h` while here — it has no readers in either ring +(`grep -n 'pending' src/` shows assignments only, at +[esp32_ble_callbacks.h:125](../src/esp32_ble_callbacks.h), +[communication.cpp:119](../src/communication.cpp), +[main.cpp:291](../src/main.cpp), [:309](../src/main.cpp), [:416](../src/main.cpp)). Removing +it saves a byte per slot × 43 slots and, more importantly, removes a field that *looks* like +it participates in the SPSC protocol but does not. Update `tools/od-device-cli.py` only if +these structs are mirrored there — they are not (that file mirrors config packets, not +runtime rings), so no CLI change. + +**Also fold in the parent plan's `[H1]` comment fix while in `main.h`** — the +[main.h:365-370](../src/main.h) comment claims 33 slots hold "a full W=32 window + END", +but the producer refuses at `nextHead == tail`, so usable capacity is 32. Fix the comment +here (a comment-only change, safe in Phase 3); the actual `COMMAND_QUEUE_SIZE` bump to 34 +stays in Phase 7 where the DRAM decision belongs. **Decision D5.** + +--- + +## 6. Step 3 — `g_commandInFlight` depth counter `[H4]` + +Declared in `session_guard.h`, defined in `session_guard.cpp`: + +```c +extern volatile uint8_t g_commandInFlight; +``` + +Incremented/decremented around the `imageDataWritten` body — **inside** `imageDataWritten` +itself (`communication.cpp`), not at each call site, so every dispatcher (ESP32 drain, nRF +inline callback, LAN frame dispatch) is covered by construction. Use an RAII guard or a +single-exit `goto done` — `imageDataWritten` has multiple `return`s, so verify every path +decrements. + +Why a counter and not a bool (review `[H4]`): Bluefruit's `ada_callback_invoke()` falls back +to invoking the write callback **inline on the BLE event task** when `rtos_malloc` fails +(`BLECharacteristic.cpp:538-542`). Heap pressure during a large nRF52840 transfer is exactly +when that happens, so "one task on nRF" is not an invariant. With a bool, whichever +invocation nests out first clears it and the supervisor could abort under a live handler. + +Phase 3 only *maintains* the counter. Phase 6 consumes it ("abort only when depth is 0"). +Phase 5 consumes it for `nrfSessionClearPending`. + +Increment/decrement need not be atomic RMW on either target (single writer per nesting +level, and the nested case is same-core), but mark it `volatile` and add a comment saying +so, rather than leaving the reader to work it out. + +--- + +## 7. Step 4 — Queue flushes + +Both defined in `main.cpp` (where the rings live), declared in **`session_guard.h`** (§3a), +bodies guarded `#ifdef TARGET_ESP32`. `session_guard.cpp` calls them through that declaration — +it must never touch the rings directly, or it drags NimBLE types into a nRF build. + +```c +#ifdef TARGET_ESP32 +// Loop-task only. SPSC-safe: commandQueueTail has exactly one writer (the consumer, +// i.e. this task), so snapshotting head into tail cannot race the producer's head +// store. Discards payloads without dispatching them. +void flushCommandQueue(void) { + uint8_t head = __atomic_load_n(&commandQueueHead, __ATOMIC_ACQUIRE); + uint8_t tail = __atomic_load_n(&commandQueueTail, __ATOMIC_RELAXED); + if (tail == head) return; + uint8_t dropped = (head - tail + COMMAND_QUEUE_SIZE) % COMMAND_QUEUE_SIZE; + __atomic_store_n(&commandQueueTail, head, __ATOMIC_RELEASE); + // Breaks an in-progress drain (§5). Harmless when no drain is running: the drain + // block clears this at its top, so a flag set from serviceBleDisconnectCleanup() + // cannot leak into the next pass and double-dispatch a slot -- see [C6] in §5. + commandDrainAbortPending = true; + od_log_warn("Command queue flushed (%u dropped)", dropped); +} + +// Both head and tail are loop-task-only, so this is trivially safe. +void flushResponseQueue(void) { + if (responseQueueTail == responseQueueHead) return; + uint8_t dropped = (responseQueueHead - responseQueueTail + RESPONSE_QUEUE_SIZE) % RESPONSE_QUEUE_SIZE; + responseQueueTail = responseQueueHead; + od_log_warn("Response queue flushed (%u dropped)", dropped); +} +#endif +``` + +On nRF both are **empty out-of-line definitions in `main.cpp`** — `void flushCommandQueue(void) {}` — +so `session_guard.cpp` stays free of `#ifdef` clutter at the call sites. Deliberately *not* +`static inline` in a header: `main.h` cannot be included by `session_guard.cpp` at all (§3a), and +a `static inline` there would be invisible to it. + +**Callable only from the loop task — documented, not asserted (D4).** No `configASSERT`, no +`g_loopTaskHandle` capture, no `OD_DEBUG_ASSERTS` block. The invariant holds structurally today +(every ESP32 handler runs on the loop task), so the enforcement is a doc comment carrying the +full reasoning. **D4 specifies the exact comment text — use it verbatim; it is the only thing +protecting this property.** + +--- + +## 8. Step 5 — `src/session_guard.h` / `.cpp` + +### 8.1 Header surface + +```c +#pragma once +#include +#include + +// --- flags, serviced on the loop task --- +extern volatile bool commandDrainAbortPending; // set by flushCommandQueue(), consumed by the drain +extern volatile bool commandQueueOverflowAbort; // set by onWrite on ring-full (Phase 7 consumes) +extern volatile bool responseQueueOverflowAbort; // set by sendResponse on ring-full (Phase 7 consumes) +extern volatile bool epdForceOffPending; // deferred panel off — a refresh/stream must finish first (8.3.1) + +// --- in-flight depth (H4) --- +extern volatile uint8_t g_commandInFlight; + +// --- progress accounting (Phase 6 consumes) --- +extern volatile uint32_t g_lastProgressMs; +void markSessionProgress(void); + +// --- the recovery --- +void abortToKnownState(const char* reason, bool dropLink); +``` + +No NimBLE, no `WiFiClient`, no Arduino `String`. The link drop is delegated (§8.4) so the +header stays portable across all eleven envs. + +### 8.2 `markSessionProgress()` + +```c +void markSessionProgress(void) { g_lastProgressMs = millis(); } +``` + +Phase 3 defines the storage and the setter. **Whether Phase 3 also installs the six stamp +call sites is Decision D2.** The sites, per `[C1]` (recorded here so they are not re-derived +in Phase 6): + +1. `pipeState.expected_seq` advancing — inside the in-order accept, `display_service.cpp` +2. `directWriteBytesWritten` increasing +3. `chunkedWriteState.receivedChunks` incrementing — `communication.cpp:565` +4. `partialCtx` byte counter advancing +5. Refresh completion +6. `handleAuthenticate` success + +**Never** on command dispatch, notify, or LAN frame dispatch — that is the defect `[C1]` +exists to prevent (a post-`clearEncryptionSession` `RESP_AUTH_REQUIRED` retry flood is a +dispatch *and* a notify per retry, and would keep the stamp fresh forever in exactly the +wedge the supervisor exists to catch). + +### 8.3 `abortToKnownState()` + +```c +void abortToKnownState(const char* reason, bool dropLink) { + // 1. LOG FIRST — before any state is destroyed, so the log line describes the + // wedge and not the aftermath. + od_log_error("ABORT: %s (dropLink=%d) transfer=%d direct=%d pipe=%d partial=%d " + "chunked=%d refresh=%d inflight=%u", + reason, (int)dropLink, (int)transferActive(), (int)directWriteActive, + (int)pipeState.active, (int)partialCtx.active, + (int)chunkedWriteState.active, (int)epdRefreshInProgress, + (unsigned)g_commandInFlight); + + // 2. Discard queued responses. No client-facing abort frame is sent — sending one + // would need either a new response shape (forbidden, §2) or repurposing an + // existing code (changes its documented meaning, also forbidden). The client's + // own timeout is the notification. See Decision D3. + flushResponseQueue(); + + // 3. Discard undispatched commands (also raises commandDrainAbortPending) + flushCommandQueue(); + + // 4. Transfer state, in dependency order + cleanupDirectWriteState(true); // paired touch resume + panel release + cleanupPartialWriteOnDisconnect(); // 0x76 / pipe-partial session bookkeeping + resetPipeWriteState(); // pipe + reorder queue + resetChunkedWriteState(); // NEW (§4.1) + + // 5. Belt-and-braces peripheral reset + touchForceResume(); // NEW (§4.2) — after cleanupDirectWriteState + buzzerForceStop(); // NEW (§4.3) + ledForceStop(); // NEW (§4.3) + + // 6. Panel power. HARD INVARIANT: a refresh in flight ALWAYS runs to completion, + // and an in-flight controller stream is never cut mid-write (D7 hazard 1). + // Deferred, NOT abandoned -- serviceDeferredPanelOff() completes it. + if (!epdRefreshInProgress && !epdStreamInProgress) { + epdSessionForceOff(); + } else { + epdForceOffPending = true; + od_log_warn("ABORT: %s in progress — panel force-off deferred, not skipped", + epdRefreshInProgress ? "refresh" : "stream"); + } + + // 7. Crypto + link (D6c). The condition is the invariant, not the caller's + // opinion: a cleared session under a LIVE link is invisible to the client -- + // it keeps sending encrypted frames that all bounce 0xFE and never + // re-authenticates mid-stream. Clear only when the link is going or gone. + if (dropLink || !linkIsUp()) { + clearEncryptionSession(); + } + if (dropLink) { + odLinkDropRequest(); // deferred; serviced in loop() + } + + // 8. Owner token release — Phase 4 fills this in (no-op stub in Phase 3) + linkReleaseIfHeld(); + + markSessionProgress(); // clean slate: do not re-fire immediately +} +``` + +**The ordering defect in the parent plan, and how D3 dissolves it.** The parent plan orders +the teardown "optional client NACK → … → flush response ring". On ESP32 `sendResponse` +*enqueues* ([communication.cpp:112-120](../src/communication.cpp)) and `flushResponseQueue()` +*discards*, so NACK-then-flush would throw the NACK away and the client would learn nothing. +The fix was going to be an inversion (flush, then NACK, then `flushResponseQueueToBle()`). + +D3 removes the notification entirely, so the ordering question disappears with it: the +response flush is unconditional and nothing is queued after it. Recorded because the defect is +real and will resurface if the pipe-only NACK follow-up in D3 is ever implemented — that +sender must run **after** the flush, not before. + +**Idempotence.** Every callee is already idempotent (`cleanupDirectWriteState` no-ops when +`!directWriteActive`, `epdSessionForceOff` is documented idempotent at +[display_service.h:21](../src/display_service.h), the flushes early-return on empty). + +**Re-entrancy guard must be ATOMIC (per D7).** A plain `static bool inAbort` is not enough: +D7 settles that `abortToKnownState` is **never gated**, so on nRF two tasks can enter it +concurrently and both read `false`. Use a test-and-set: + +```c +static volatile uint8_t inAbort = 0; +if (__atomic_exchange_n(&inAbort, 1, __ATOMIC_ACQ_REL)) { + od_log_warn("ABORT: re-entered (%s) — first pass owns the teardown", reason); + return; +} +// ... teardown ... +__atomic_store_n(&inAbort, 0, __ATOMIC_RELEASE); +``` + +The guard is also needed for the plain nested case: step 4 can, through +`cleanupDirectWriteState`, reach code that could later grow an abort call. + +**Never gated.** `abortToKnownState` runs whenever it is called, at any in-flight depth, on +either task. The `g_commandInFlight` check lives in nRF `loop()`-side **callers** (§9.2), never +here — see D7 for the research behind that, and copy D7's header comment onto the function so +Phase 6 does not reintroduce a gate. + +#### 8.3.1 Invariant: a panel refresh always completes + +**`abortToKnownState` must never interrupt an e-paper refresh.** This ranks above the teardown's +own urgency, and it is the one place where the abort defers rather than acts. Three reasons it +is not negotiable: + +1. **An interrupted refresh damages the image, and on some panels the panel.** Cutting the rail + or sleeping the controller mid-waveform leaves a partially-driven frame — ghosting, or a + latched pixel state that the next full refresh has to clear. On 3/4/7-colour Spectra the + waveform runs 30-60 s and `bbepWaitBusy` caps at 30 000 ms (`bb_ep.inl:3959-3975`); there is + no safe abort point inside it. +2. **The device is not wedged during a refresh — it is working.** A refresh is the successful + end of a transfer, not a symptom. Aborting one converts a completing operation into a failed + one and forces the client to re-push the whole image. +3. **`displayed_etag` correctness.** A refresh that is cut partway leaves the panel showing + neither the old nor the new image while RTC state may already claim the new etag, so the next + push can be skipped as a no-op against an image that was never actually drawn. + +**Deferred means deferred, not skipped.** The abort sets `epdForceOffPending` and returns; the +rest of the teardown (queues, transfer state, touch, buzzer/LED, session) completes immediately, +because none of it touches the panel. A loop-serviced tick then finishes the job the moment the +refresh does: + +```c +// main.cpp — called from loop() on both targets, next to serviceBleDisconnectCleanup() +static void serviceDeferredPanelOff(void) { + if (!epdForceOffPending) return; + if (epdRefreshInProgress || epdStreamInProgress) return; // still busy — try next pass + epdForceOffPending = false; + od_log_info("Deferred panel force-off completing (refresh/stream finished)"); + epdSessionForceOff(); +} +``` + +Note the ESP32 disconnect path defers the **whole** teardown while a refresh is in flight +([main.cpp:322](../src/main.cpp)) and, per §9.2, nRF now does the same. So on the D1b disconnect +path the refresh is protected twice over. `epdForceOffPending` covers the callers that do *not* +defer wholesale — Phase 6's supervisor and Phase 7's idle timeout — where the abort itself must +run promptly but the panel must still be left alone. + +**Do not add a timeout to this deferral in Phase 3.** The refresh is already bounded: Phase 2 +`[X3]` makes `fastepd_wait_refresh` honour its timeout, `waitforrefresh(60)` bounds the bbep +path, and `bbepWaitBusy` caps at 30 s. If those bounds hold, `epdForceOffPending` clears within +a minute; if they do not, the bug is in the bound, not here. A timeout here would reintroduce +exactly the mid-refresh cut this invariant exists to prevent. + +**Re-entrancy from the drain.** `abortToKnownState` will (Phase 5/6) be reached from +*inside* `imageDataWritten`, i.e. mid-drain. That is precisely why step 3 sets +`commandDrainAbortPending` rather than relying on the tail store — see §5. + +### 8.4 `odLinkDropRequest()` — deferred, not inline + +Dropping the link from inside `abortToKnownState` would mean calling +`pServer->disconnect()` (NimBLE) or `Bluefruit.disconnect()` from a header shared with an +env that `lib_ignore`s NimBLE. Instead: + +```c +// session_guard.cpp — portable +void odLinkDropRequest(void) { g_linkDropPending = true; } +``` + +and a `serviceLinkDrop()` in `main.cpp` under the existing per-target `#ifdef`, called from +`loop()` next to `serviceBleDisconnectCleanup()`. Phase 4 extends it with the owner token +and the `BLE_ERR_REM_USER_CONN_TERM` (0x13) reason code + return check `[C3]`. + +This also fixes a subtler problem: an inline disconnect from the NimBLE host task while +`abortToKnownState` is mid-teardown would fire `onDisconnect` → `bleDisconnectCleanupPending` +→ a *second* teardown pass. Deferring keeps it to one. + +--- + +## 9. Step 6 — Wiring the two disconnect teardowns (D1b) + +### 9.1 ESP32 — `serviceBleDisconnectCleanup()` + +Replace [main.cpp:343-347](../src/main.cpp): + +```c + if (directWriteActive) cleanupDirectWriteState(true); + cleanupPartialWriteOnDisconnect(); + resetPipeWriteState(); +``` + +with a single call: + +```c + abortToKnownState("disconnect", /*dropLink=*/false); +``` + +Three things must hold, and each is a review point: + +- **The `ownerStillUp` early-return at [main.cpp:328-338](../src/main.cpp) stays strictly in + front of the call.** This is not optional. `disconnectWiFiServer()` raises the *same* + `bleDisconnectCleanupPending` flag ([wifi_service.cpp:812](../src/wifi_service.cpp)), and it + is called from the WiFi-lost tick at [main.cpp:447](../src/main.cpp) — so on a WiFi-enabled + env, losing WiFi routes into this function while a BLE client is mid-transfer. The guard is + what stops that from tearing the BLE transfer down. Wiring the abort in front of the guard + would create the exact bug Phase 4 `[C4]` exists to fix. +- **`dropLink=false`, always.** The link is already gone by definition on this path; requesting + a drop would queue a disconnect against a dead handle. +- **Do not move the guard out of `#ifdef OPENDISPLAY_HAS_WIFI` yet.** That is Phase 4 `[C4]` + and it is tied to the refused-gatecrasher handle discrimination, which does not exist in + Phase 3. On `esp32-N4` (no WiFi) the guard is absent, but so is the LAN path that raises the + flag spuriously — the flag is only ever set by a genuine BLE disconnect there, so Phase 3 is + safe without it. **Recheck this the moment Phase 4 lands.** + +Net behavioural delta on ESP32: the teardown additionally resets chunked-config state, force- +resumes touch, stops buzzer/LED, and force-offs the panel when no refresh is in flight. All are +either no-ops or strictly correct on a link that just went away. + +### 9.2 nRF — needs a new deferral, not a direct substitution + +`disconnect_callback` ([device_control.cpp:227-240](../src/device_control.cpp)) runs on the +Bluefruit **Callback** task, not `loop()`. Today it calls three cleanup functions inline from +that task. A naive substitution would additionally run `epdSessionForceOff()` (bbepSleep → +`bbepWaitBusy` → SPI teardown → rail cut) and — via **D6** — `clearEncryptionSession()` from +the callback task. + +Both are unsafe there, for reasons already established in this plan family: + +- `epdSessionForceOff()` is precisely the heavyweight, SPI-touching work ESP32 defers to + `loop()` for ("the session teardown below … is heavyweight, state-mutating work that races + loop()'s SPI streaming", [esp32_ble_callbacks.h:62-68](../src/esp32_ble_callbacks.h)). nRF has + no equivalent excuse to run it inline. +- `clearEncryptionSession()`'s `memset(session_key, 0, 16)` from a non-loop task is the `[H4]` + race verbatim: Bluefruit's `ada_callback_invoke()` falls back to invoking the write callback + inline on the BLE task when `rtos_malloc` fails, so `aes_ccm_decrypt` can be running + concurrently. + +**So nRF gets the same flag-and-defer shape ESP32 already has:** + +```c +// device_control.cpp — disconnect_callback, now flag-only +void disconnect_callback(uint16_t conn_handle, uint8_t reason) { + od_log_info("=== BLE CLIENT DISCONNECTED === reason: %u", reason); + nrfDisconnectCleanupPending = true; // NEW +} +``` + +```c +// main.cpp, nRF arm of loop() — next to the other loop-serviced work +if (nrfDisconnectCleanupPending && !epdRefreshInProgress && g_commandInFlight == 0) { + nrfDisconnectCleanupPending = false; + abortToKnownState("disconnect", /*dropLink=*/false); +} +``` + +The `g_commandInFlight == 0` term is what makes the deferral actually close `[H4]` rather than +just move it: it guarantees no `imageDataWritten` is on the stack (on either task) when the +session key is zeroed. This is the one place in Phase 3 where the depth counter from §6 is +*read* rather than merely maintained — everywhere else it is Phase 5/6's to consume. + +**This pulls part of `[H4]` forward from Phase 5 into Phase 3.** That is a deliberate +consequence of D1b: wiring the caller early means the safety property the caller depends on has +to come with it. Phase 5's `nrfSessionClearPending` becomes redundant — record that there so it +is not implemented twice. + +Secondary benefit: nRF gains the `epdRefreshInProgress` deferral it does not have today. A +disconnect mid-refresh currently runs `cleanupDirectWriteState(true)` straight through the +refresh on nRF; after this it waits, matching ESP32. + +### 9.3 D1b × D6 — Phase 3 now delivers the BLE-disconnect session clear + +With D6(c)'s `dropLink || !linkIsUp()` condition, `abortToKnownState("disconnect", false)` +evaluates `!linkIsUp()` → true (the link is gone) → clears the session. That is the parent +plan's confirmed user decision *"clear encryption session on BLE disconnect"*, which the parent +plan scheduled for **Phase 5**. + +**One documented exception:** on a WiFi-enabled env with a LAN client connected, `linkIsUp()` +returns true and the session is not cleared — `EncryptionSession` records no origin, so Phase 3 +cannot tell whose session it is. Deliberate and safe (erring the other way would destroy a live +LAN session from a BLE event); Phase 4's owner token closes it. Full reasoning in D6. + +This is a scope gain, not a scope creep — the behaviour is required, it is one condition, and +D1b makes it fall out for free. But it must be recorded so Phase 5 does not re-implement it: + +| Parent-plan Phase 5 item | Status after Phase 3 + D1b | +|---|---| +| Clear session on BLE disconnect | **Delivered here.** Phase 5 verifies, does not re-add. | +| nRF deferred session clear `[H4]` | **Delivered here** (§9.2). `nrfSessionClearPending` is redundant. | +| `[H2]` clear placed after the `ownerStillUp` guard | **Satisfied here** by §9.1's ordering rule. Phase 5 re-checks it under the owner token. | +| Guard *inside* `clearEncryptionSession()` (dead-session-with-live-link) | **Still Phase 5.** Phase 3 relies on the call-site condition; Phase 5 makes it un-regressable. | +| `session_timeout_seconds` expiry disabled | **Still Phase 5.** Untouched here. | +| Pipe NACK latch / `error_since_ms` | **Still Phase 5.** Untouched here. | + +**Risk this introduces:** the session clear goes live one phase earlier than planned, without +Phase 5's in-function guard as a backstop. The mitigation is that both call sites pass +`dropLink=false` on a path where the link is provably down, so the "dead session under a live +link" failure mode is unreachable from Phase 3's callers. Any *new* caller added before Phase 5 +must be checked against that by hand. + +--- + +## 10. Build guards — the eleven-env matrix + +`session_guard.cpp` compiles into every env. The rules: + +| Symbol | Guard | +|---|---| +| `commandQueue` / `responseQueue` / `pServer` | `#ifdef TARGET_ESP32`, and reached **only** through `session_guard.h`-declared functions defined in `main.cpp` — never named in `session_guard.cpp` (§3a) | +| Any LAN call (`wifiLanClientConnected`, `opendisplay_lan_*`) | `#ifdef OPENDISPLAY_HAS_WIFI` — **never** `TARGET_ESP32`; `esp32-N4` is ESP32 without WiFi | +| Any NimBLE type | must not appear in `session_guard.h` at all (nRF sets `lib_ignore = NimBLE-Arduino`) | +| FastEPD-only symbols | `#ifdef OPENDISPLAY_FASTEPD` | + +RAM cost: `commandDrainAbortPending` + 2 overflow flags + `g_commandInFlight` + +`g_linkDropPending` + `epdForceOffPending` + `epdStreamInProgress` + `sessionScrubPending` +(8 bytes) + `g_lastProgressMs` (4 bytes) = **12 bytes `.bss`**, minus the `pending`-field +removal (−43 bytes across both rings). Net negative. `esp32-N4` — the +DRAM-tight env that already needs `PIPE_SMALL_DRAM_WINDOW` ([structs.h:45-48](../src/structs.h)) +— is not at risk from Phase 3. (It *is* the gate for Phase 1's `replay_window[256]`; unrelated.) + +--- + +## 11. Implementation order (each step independently buildable) + +| # | Step | Files | Live on merge? | +|---|---|---|---| +| 1 | `resetChunkedWriteState()` + consolidate 4 clear sites | `communication.cpp`, `communication.h` | yes (fixes the partial reset at :574) | +| 2 | `touchForceResume()` / `touchForceResumeAll()` | `touch_input.*`, `display_service.*` | not yet | +| 3 | `buzzerForceStop()` / `ledForceStop()` wrappers | `buzzer_control.*`, `device_control.*` | not yet | +| 4 | Delete `pending` from both ring structs; fix the `main.h:365` capacity comment | `structs.h`, `main.h`, `main.cpp`, `communication.cpp`, `esp32_ble_callbacks.h` | yes | +| 5 | `g_commandInFlight` depth counter | `session_guard.*`, `communication.cpp` | yes — read by step 9 | +| 6 | `flushCommandQueue()` / `flushResponseQueue()` + nRF no-ops | `main.cpp`, `session_guard.h` | not yet | +| 7 | Drain-trap fix `[M5]` | `main.cpp` | **yes** | +| 8 | `session_guard.h/.cpp`: flags, `markSessionProgress`, `abortToKnownState`, `odLinkDropRequest` | new | not yet | +| 9 | `serviceLinkDrop()` + **(D6c) `linkIsUp()`** per-target | `main.cpp`, `session_guard.h` | not yet | +| 9a | **(D7) `epdStreamInProgress`** set/cleared at the two streaming choke points | `display_service.cpp/.h` | not yet | +| 9b | **(D7/§8.3.1) `epdForceOffPending` + `serviceDeferredPanelOff()` in `loop()`** | `session_guard.*`, `main.cpp` | not yet | +| 9c | **(D7) `clearEncryptionSession()` invalidate/scrub split** + `sessionScrubPending` service | `encryption.cpp`, `main.cpp` | **yes — changes existing behaviour** | +| 10 | **(D1b) Wire ESP32 `serviceBleDisconnectCleanup` → `abortToKnownState` (§9.1)** | `main.cpp` | **yes — steps 1-9 all go live here** | +| 11 | **(D1b) nRF: flag-only `disconnect_callback` + loop-serviced abort (§9.2)** | `device_control.cpp`, `main.cpp`, `session_guard.h` | **yes** | + +**Commit split.** Steps 1–9 as one commit (mechanically inert: new helpers, new file, one +correctness fix — nothing calls the teardown yet). Steps 10–11 as a second commit, which is the +one that changes runtime behaviour on every disconnect and is therefore the one to review +hardest and bisect to if a soak regresses. + +Step 11 is deliberately last: it is the only step that changes *which task* work runs on, and +it depends on step 5's depth counter existing. Do not merge 11 without 5. + +Per D2, the six `markSessionProgress()` stamp sites are **not** in this list — they land in +Phase 6 with the rest of `[C1]`. + +Build after each: + +```bash +pio run -e nrf52840custom -e esp32-s3-N16R8 -e esp32-c3-N16 -e esp32-c6-N4 -e esp32-N4 +``` + +CI builds all eleven on push — `esp32-N4` (no WiFi) and `nrf52840custom` (no NimBLE, no +rings) are the two that catch guard mistakes. + +--- + +## 12. Verification + +**Wire-protocol constraint (§2) — run first, before anything else is called done:** + +```bash +cd ../opendisplay-protocol && tools/sync_protocol_header.py --check --only Firmware # must pass, unchanged +cd ../Firmware && git diff main --stat -- include/opendisplay_protocol.h include/opendisplay_structs.h # must be empty +``` + +Phase 3 touches neither file, so both must be clean on the first run — if either reports a +diff, something in the implementation drifted out of scope. Two Phase-3-specific additions to +the check: + +```bash +git diff main -- src/ | grep -nE '^\+.*(RESP_|CMD_)[A-Z_]+ *=' # must be empty: no new opcode/response values +git diff main --stat -- docs/pipe-write-protocol.md # must be empty in Phase 3 (§5.1 note is Phase 5) +``` + +**Build:** all eleven envs. Specifically confirm `esp32-N4` compiles `session_guard.cpp` +with no LAN symbols and `nrf52840custom` with no NimBLE symbols. + +**Static:** `grep -n 'pending' src/` returns nothing in ring context after step 4. + +**Client compatibility (the constraint's actual purpose):** an unmodified `py-opendisplay` +and an unmodified HA integration must both drive a full transfer against Phase 3 firmware +with no change and no new warning. Since Phase 3 sends no new frame and removes no existing +one, a passing pre-Phase-3 transfer must pass identically — any behavioural difference +visible to the client is a constraint violation, not a Phase 3 feature. + +**Bench (no hardware needed):** +- Force `abortToKnownState("test", false)` from a debug command with no transfer active → + logs once, every subsystem no-ops, device still serves commands afterwards. +- Call it twice back to back → second call is a clean no-op (idempotence + re-entrancy guard). + +**D1b — the disconnect path is now the primary test surface.** Every one of these is a +connect/disconnect cycle, so they are cheap to run and must all pass on **both** targets: + +| Case | Expected | +|---|---| +| Connect, do nothing, disconnect | One `ABORT: disconnect` line, all state flags false, advertising resumes | +| Disconnect **mid-pipe-transfer** | Pipe + reorder queue cleared, panel rail down, touch responsive, next transfer clean | +| Disconnect **mid-chunked-config-write** | `chunkedWriteState.active` false (this is new — the old teardown left it set) | +| Disconnect **during a refresh** | Teardown **deferred**, refresh completes intact, teardown runs on a later pass. On nRF this is new behaviour (§9.2) | +| **Supervisor/idle abort during a refresh** (Phase 6/7 trigger, forced here with a debug opcode) | Refresh completes **untouched**; abort tears down queues/state immediately; `epdForceOffPending` logs, then `serviceDeferredPanelOff()` powers the panel down after the refresh ends. **This is the §8.3.1 invariant test — the panel must never be cut mid-waveform** | +| Abort during PIPE streaming (mid-`bbepWriteData`) | Rail stays up (`epdStreamInProgress`), no SPI/CS activity against a dead panel, force-off completes after the stream unwinds | +| Abort mid-decrypt on nRF (heap-pressure double-handler) | `isAuthenticated()` false immediately; in-flight decrypt completes with an intact key; scrub lands on a later loop pass. No `integrity_failures` bump from a half-zeroed key | +| Disconnect with buzzer/LED active | Both stop immediately (new) | +| Disconnect, then reconnect and re-auth | Succeeds — session cleared via `!linkIsUp()` (D6c), client gets a fresh challenge | +| Disconnect, reconnect, replay a captured pre-disconnect frame | **Rejected.** Session was cleared, so there is no key to validate against. Depends on Phase 1 — run it on the merged Phase 1+3 tree | +| **WiFi env only:** LAN client connected, BLE disconnects, then BLE reconnects | Session **not** cleared (documented D6 gap) → the reconnecting BLE client re-authenticates and gets a fresh session anyway. Confirm no `0xFE` bounce loop | +| **WiFi env only:** BLE client transferring, WiFi drops | BLE transfer **survives** — `ownerStillUp` guard held (§9.1). This is the regression test for the guard-ordering rule | +| **WiFi env only:** LAN client transferring, BLE client disconnects | LAN transfer survives | +| **`esp32-N4` only:** BLE disconnect | Teardown runs (no guard present, none needed — no LAN path raises the flag) | + +**nRF-specific, for §9.2:** confirm via log timestamps that the teardown runs on the loop task +and not the callback task — the `ABORT:` line must appear after a `loop()` boundary marker, not +interleaved with the disconnect callback's own logging. Then repeat the disconnect-mid-transfer +case under heap pressure (large transfer) to exercise the `g_commandInFlight` gate. + +**Bench — `[C6]` double-dispatch regression test.** Queue several commands, then trigger an +abort from *outside* the drain (a disconnect is the natural way, since D1b wires it). Confirm +from the log that after the flush **no command is dispatched twice** across the following two +loop passes. Without the top-of-block reset, the first command of the next drain runs on two +consecutive passes. Use a command with a visible side effect (LED or buzzer) rather than a +silent one. + +**Hardware — the drain-trap regression test (step 7's reason for existing):** start a +PIPE_WRITE with a deep in-flight window (W=32), trigger an abort from *inside* a dispatched +command (temporary debug opcode that calls `abortToKnownState`), and confirm via log that no +queued command is dispatched after the flush line. Without the fix, `drained` continues and +stale commands execute; with it, the drain breaks immediately. + +Note that D1b does **not** exercise this: the disconnect path calls `abortToKnownState` from +`serviceBleDisconnectCleanup`, which runs *outside* the drain loop, so the tail-store clobber +never arises there. The drain trap still needs its own deliberate trigger. + +**Hardware — teardown completeness:** mid-transfer abort, then verify by observation: +touch responds again (`s_epd_refresh_suspend == 0`), buzzer silent, LED off, panel rail +down, `transferActive()` false, a fresh transfer starts clean. + +**Hardware — refresh protection:** abort during a Spectra full refresh → log shows "panel +force-off deferred", refresh completes intact, panel powers down normally afterwards. This +depends on Phase 2's `[X2]` (`epdRefreshInProgress` set around the boot-refresh paths) being +landed first. + +--- + +## 13. Decisions + +**All decisions settled — no blockers remain.** + +| # | Decision | Where it lands | +|---|---|---| +| D1 | (b) wire both disconnect teardowns now | §9 (whole section exists for it) | +| D2 | (b) all of `[C1]` in one Phase 6 commit | §8.2 defines storage only | +| D3 | (c) send nothing — no client-facing abort frame | §2, §8.3 (step 3 deleted) | +| D4 | document only, no assert | §7 comment text | +| D5 | fix the `main.h:365` comment now; size bump stays Phase 7 | step 4 | +| D6 | (c) derive as `dropLink \|\| !linkIsUp()` | §8.3 step 7 | +| D7 | never gate `abortToKnownState` | §8.3 atomic guard, §9.2 caller-side check | + +### D1 — Land `abortToKnownState()` with no callers, or wire one now? `(SETTLED — (b))` + +**Decision: (b) — wire the two existing disconnect teardowns now.** Replace +[main.cpp:343-347](../src/main.cpp) and [device_control.cpp:237-239](../src/device_control.cpp) +with `abortToKnownState("disconnect", false)`. This is the same set of calls plus the new +chunked/touch/buzzer/LED/panel resets — a strict superset of today's behaviour — and it gets +the teardown exercised on every disconnect immediately rather than leaving it untested in the +tree until Phase 5. + +Rejected: **(a) land dead** (parent plan as written) — clean phase boundaries, but an +unexercised teardown is exactly the kind of code that is wrong on first use; **(c) debug-only +trigger** — tests a synthetic path and then deletes the only caller. + +**Full implementation consequences are in §9**, which exists because of this decision. In +brief: the ESP32 `ownerStillUp` guard must stay in front of the call (§9.1); the nRF site needs +a new loop-serviced deferral rather than a direct substitution, which pulls part of `[H4]` +forward from Phase 5 (§9.2); and combined with D6 this delivers the BLE-disconnect session +clear one phase early (§9.3). + +### D2 — Do the six `markSessionProgress()` stamp sites land in Phase 3 or Phase 6? `(SETTLED — (b))` + +**Decision: (b) — everything `[C1]` lands in one Phase 6 commit.** `[C1]` is the +highest-consequence finding in the review ("progress means the state machine advanced — never +'a command arrived' or 'a notify succeeded'"), and it reads and reviews far better as a single +self-contained change than as storage in one phase and semantics in another. + +Rejected: **(a) stamps in Phase 3** — mechanically harmless (unread stamps change no +behaviour), but it splits one finding across two phases and two reviews, and a reviewer looking +at the Phase 6 diff would not see the stamp placement that is the entire point of `[C1]`. + +**What Phase 3 still owes Phase 6**, so the split is clean: +- `g_lastProgressMs` storage and the `markSessionProgress()` setter (§8.2) — defined, callable, + unused. +- The six stamp sites are *enumerated* in §8.2 so Phase 6 does not re-derive them from `[C1]`. +- One live call: `abortToKnownState()` ends with `markSessionProgress()` (§8.3) so a completed + teardown leaves a clean slate and the Phase 6 supervisor cannot re-fire immediately on a + wedge it just cleared. + +**Consequence to accept:** Phase 3 as merged has `g_lastProgressMs` written by exactly one +caller and read by none. That is intentional dead storage, not an oversight — do not "clean it +up" before Phase 6, and do not let a linter strip it. + +### D3 — What does the "optional client NACK" actually send? `(SETTLED by the hard constraint — §2)` + +The parent plan says "optional client NACK (skip when dropping link)" without specifying the +frame. The wire-protocol constraint removes most of the option space: + +- ~~**(b) A generic `{RESP_NACK, 0x00, reason_code}`**~~ — **forbidden.** A new response shape + is a protocol change, and it would need a py-opendisplay change to interpret. Ruled out by + §2, not by preference. +- **(a) Reuse `sendPipeNack(err)`** when `pipeState.active`, nothing otherwise. Permitted in + principle — reusing an existing code is in bounds *"as long as the code's documented meaning + is unchanged"* — but only for a genuine pipe failure. It may **not** be sent for a + chunked-config or direct-write abort, which would repurpose `0x81` and change its documented + meaning. It also has side effects: it sets `pipeState.error` and calls + `cleanupDirectWriteState`/`cleanup_partial_write_state` itself + ([display_service.cpp:2564-2578](../src/display_service.cpp)), duplicating step 5. Would need + a payload-only variant. +- **(c) Send nothing.** The client already treats silence as a timeout + (`TIMEOUT_PIPE_DATA_COMPRESSED = 5.0` × `MAX_PTO = 3` ≈ 15 s) and every `0x81` NACK as + immediately fatal — it never re-reads the ACK position after one (established in `[L2]`). + +**Decision: (c). Step 3 of `abortToKnownState` sends nothing, and the `dropLink` parameter no +longer gates a notification.** The client derives no benefit it does not already get from its +own timeout, and (c) is the only option with zero protocol surface. This means **step 3 does +not exist** — the teardown is flush → flush → state reset, with no client-facing frame. + +If a fast-fail is later shown to matter on hardware, the follow-up is (a) narrowed to the pipe +case only: factor a `pipeBuildAckPayload`-only sender out of `sendPipeNack` so it has no side +effects, and call it **only** when `pipeState.active && !pipeState.error`. That stays inside +the constraint. It is explicitly not Phase 3 work. + +**Simplification this unlocks:** with no notification, `dropLink` now controls exactly two +things — the session clear (D6) and `odLinkDropRequest()`. Update the `abortToKnownState` +sketch in §8.3 to drop step 3 and its `flushResponseQueueToBle()`; the response-ring flush in +step 2 becomes unconditional and final. + +### D4 — Enforce "loop task only" with an assert, or document it? `(SETTLED — document only, no code)` + +**Decision: document the invariant, add no assert and no task-handle capture.** No +`configASSERT`, no `g_loopTaskHandle`, no `OD_DEBUG_ASSERTS` block — §7's sketch drops its +assert line entirely. + +Rationale: the invariant currently holds *structurally* rather than by enforcement. On ESP32 +every handler runs on the loop task (`onWrite` only enqueues, +[esp32_ble_callbacks.h:118-128](../src/esp32_ble_callbacks.h); the drain dispatches, +[main.cpp:408-421](../src/main.cpp); LAN dispatch is also in `loop()`), so there is no existing +caller that could violate it — an assert would guard against a caller that does not exist. On +nRF both flushes are empty stubs, so the invariant is vacuous there. Adding a task handle and a +debug-only branch to protect a structurally-guaranteed property is cost without a current +benefit. + +**What this costs, stated honestly:** the property becomes convention-enforced. D7 makes that +slightly sharper — `abortToKnownState` is now explicitly callable from any task, and it calls +`flushCommandQueue()`. Today that is still safe on ESP32 (every ESP32 abort caller is on the +loop task, because every ESP32 handler is), but the *function* no longer advertises a +task restriction that its *caller* does not. The doc comment has to carry that weight. + +**Required comment on `flushCommandQueue()`** — it must state the invariant, why it holds, and +what breaks if it stops holding, because nothing else will: + +```c +// LOOP TASK ONLY. Not asserted -- enforced by structure, not by code (plan D4). +// +// SPSC safety: commandQueueTail has exactly ONE writer (the consumer). Snapshotting +// head into tail is safe only from that consumer. Today every ESP32 caller is on the +// loop task because every ESP32 command handler is -- onWrite() only enqueues, the +// drain and LAN dispatch both run in loop(). abortToKnownState() is callable from any +// task by design (D7), but on ESP32 it is only ever REACHED from loop-task contexts. +// +// If a future change dispatches commands from the NimBLE host task, or calls +// abortToKnownState() from one, this becomes a genuine two-writer race on +// commandQueueTail -- silently resurrecting or double-dispatching queued commands. +// Add a task assert then; do not assume this comment still describes reality. +``` + +Mirror a shorter version on `flushResponseQueue()` (both head and tail are loop-task-only, so +its invariant is stronger and simpler). + +**Escalation trigger for a future phase:** if Phase 4 or later adds any cross-task +`abortToKnownState` caller on ESP32, reopen D4 and add the assert — that is the condition under +which the structural guarantee lapses. + +### D5 — Fix the `main.h:365` capacity comment in Phase 3, or leave it to Phase 7? `(SETTLED — fix now)` + +**Decision: fix the comment in Phase 3 (step 4); the `COMMAND_QUEUE_SIZE` → 34 bump stays in +Phase 7.** Phase 3 already edits `CommandQueueItem` two lines below to remove `pending`, so +leaving a false capacity claim in the block being touched is the worst of both options. + +The comment at [main.h:365-370](../src/main.h) currently claims 33 slots hold "a full W=32 +in-flight window + END". They do not: the producer refuses at `nextHead == tail` +([esp32_ble_callbacks.h:121-122](../src/esp32_ble_callbacks.h)), so usable capacity is +`COMMAND_QUEUE_SIZE - 1 = 32` — a full window with **no** room for END. Finding `[H1]`. + +Rewrite it to state the ring-capacity rule explicitly, the true usable depth, and that the +shortfall is deliberate-for-now with the fix scheduled: + +```c +// Usable capacity is COMMAND_QUEUE_SIZE - 1 = 32: the SPSC producer refuses at +// nextHead == tail, so one slot is always reserved to distinguish full from empty. +// 32 holds a full W=32 in-flight PIPE_WRITE window but leaves NO room for the END +// frame -- a sustained full window can drop END, which the client recovers from via +// SACK retransmit (pipe-write-protocol.md 5.2). Phase 7 bumps this to 34 on envs with +// DRAM to spare (NOT esp32-N4). Sized for a 60 s Spectra SPI stall (loop blocked in +// bbepWriteData). OD_BLE_MAX_FRAME (256) covers pipe <=244, legacy <=232, HA <=244. +``` + +**Do not change the value to 34 here.** That is a DRAM decision per env and `esp32-N4` — which +already needs `PIPE_SMALL_DRAM_WINDOW` ([structs.h:45-48](../src/structs.h)) — must be excluded. +It belongs with Phase 7's queue-full handling, where the overflow policy that makes the extra +slot meaningful also lands. + +**The duplicate definition is a trap here.** `COMMAND_QUEUE_SIZE 33` is defined in **two** +places — [main.h:371](../src/main.h) and [esp32_ble_callbacks.h:19](../src/esp32_ble_callbacks.h) +(under `#ifndef`). Phase 3 only touches the comment so no drift is possible now, but Phase 7 +must change both or the `#ifndef` will silently keep 33 depending on include order. Flag it in +the comment so Phase 7 cannot miss it. + +### D6 — Should `dropLink=false` ever clear the encryption session? `(SETTLED — (c) derive it)` + +**Decision: (c) — derive the clear from `dropLink || !linkIsUp()`.** It encodes the actual +invariant ("never leave a dead session under a live link") in one place instead of making every +caller re-derive it, and it composes with Phase 5's guard inside `clearEncryptionSession()` +itself rather than duplicating it. + +Rejected: **(a) a third `clearSession` parameter** — pushes a safety-critical judgement onto +every future caller, and the one thing D1b showed is that callers get added faster than the +plan expects; **(b) leave the clear out entirely** — same problem, plus it silently drops the +confirmed user decision on the disconnect path. + +Amend §8.3 step 7: + +```c + // 7. Crypto + link. The condition is the invariant, not the caller's opinion: + // a cleared session under a LIVE link is invisible to the client -- it keeps + // sending encrypted frames that all bounce 0xFE and never re-authenticates + // mid-stream. So clear only when the link is going away or already gone. + if (dropLink || !linkIsUp()) { + clearEncryptionSession(); + } + if (dropLink) { + odLinkDropRequest(); // deferred; serviced in loop() + } +``` + +#### `linkIsUp()` — scope, and the gap it leaves + +Portable predicate, declared in **`session_guard.h`** (§3a — *not* `main.h`), +`#ifdef`-implemented per target in `main.cpp` like `serviceLinkDrop()` (§8.4). The declaration +must not pull NimBLE or WiFi types into the shared header; both live in the `main.cpp` body. + +```c +// main.cpp, TARGET_ESP32 +bool linkIsUp(void) { + if (pServer != nullptr && pServer->getConnectedCount() > 0) return true; +#ifdef OPENDISPLAY_HAS_WIFI + if (wifiLanClientConnected()) return true; // wifi_service.cpp:355 +#endif + return false; +} +``` +```c +// main.cpp, TARGET_NRF +bool linkIsUp(void) { return Bluefruit.connected() > 0; } +``` + +**This is "any transport", not "the session's transport", and that is forced.** +`EncryptionSession` ([encryption_state.h:11-30](../src/encryption_state.h)) has **no origin +field** — there is nothing recording which transport a session belongs to. `g_commandOrigin` +([communication.cpp:37](../src/communication.cpp)) is per-dispatch, not per-session, and +`transferSessionOrigin()` ([display_service.cpp:2116](../src/display_service.cpp)) tracks the +*transfer*, not the session. So in Phase 3 the question "is the link that owns this session +still up?" is genuinely unanswerable. This is the parent plan's wedge mechanism #4 +(cross-transport session clobber), and Phase 4's owner token is its fix. + +**The resulting gap, stated plainly:** on a WiFi-enabled env, if BLE disconnects while a LAN +client is connected, `linkIsUp()` returns true and the session is **not** cleared — so the +confirmed user decision "clear encryption session on BLE disconnect" is missed in that one +case. This is the deliberate, safe direction to err: + +- Erring toward *not* clearing risks a stale session surviving a BLE disconnect. Bounded: a + reconnecting client resets its counter to 0 and is rejected as out-of-window, and Phase 1 + closes the `counter_diff == 0` replay hole that would otherwise make the surviving session + exploitable. **Phase 3 must land after Phase 1** — already the stated order — and this is now + a second reason why. +- Erring toward *clearing* would destroy a live LAN client's session from a BLE event, which is + wedge mechanism #4 firing in the opposite direction — the exact bug Phase 4 exists to fix, and + strictly worse than a stale session. + +**Phase 4 closes the gap** by scoping `linkIsUp()` to the session's owner +(`linkOwner() == OWNER_BLE ? bleUp() : lanUp()`). Record it there as a required follow-up, not +as an optional refinement. + +**Rejected shortcut:** adding an origin byte to `EncryptionSession` now. It is firmware-local +(`src/encryption_state.h`) so it is *not* a wire-protocol change and would be in bounds — but +it is Phase 4's owner token wearing a different hat, and building half of it here guarantees +two half-mechanisms to reconcile later. Wait for the token. + +### D7 — Does `abortToKnownState` respect `g_commandInFlight`, or is that Phase 6's job? `(RESEARCHED — ungating is safe, with two conditions)` + +**Desired behaviour: `abortToKnownState` is never gated — when called, it runs.** This section +is the adversarial check on that: what was searched for, what was found, and what it costs. + +Phase 6 says "abort only when the in-flight depth counter is 0". Taken literally as a +precondition *inside* `abortToKnownState`, that makes it un-callable from its most important +callers: Phase 5's `integrity_failures >= 3` and `reloadConfigAfterSave` triggers both fire from +*inside* `imageDataWritten`, where depth is ≥1 by construction. A recovery mechanism that +refuses to run precisely when a command is wedged is not a recovery mechanism. + +#### The key reframing + +**Depth is a proxy for the wrong thing.** The hazards below are all *cross-task concurrency* +hazards, not *stack depth* hazards. The distinction decides the question: + +- **ESP32: depth ≥ 1 always means same-task.** Handlers run only on the loop task — `onWrite` + merely enqueues ([esp32_ble_callbacks.h:118-128](../src/esp32_ble_callbacks.h)), the drain + dispatches ([main.cpp:408-421](../src/main.cpp)), and LAN dispatch is also in `loop()`. A + nested abort is therefore strictly sequential with the handler that called it. **No + concurrency exists, so there is nothing for a depth gate to protect.** +- **nRF: depth ≥ 1 may mean another task.** `imageDataWritten` runs on the Bluefruit Callback + task, and `[H4]`'s `rtos_malloc`-failure fallback can run a *second* copy inline on the BLE + task. Here concurrency is real — but it is real between **loop() and a handler**, which is a + property of the *caller's* context, not of the callee. + +#### Hazards found (reasons not to ungate), ranked + +**1. CRITICAL, nRF only — rail cut mid-SPI-write.** `pwrmgmLock` protects state *transitions* +only, not streaming: `epdSessionAcquire` releases at [display_service.cpp:488](../src/display_service.cpp) +before returning, and every `bbepWriteData` call ([:1990](../src/display_service.cpp), +[:2003](../src/display_service.cpp), [:2309](../src/display_service.cpp), +[:2624](../src/display_service.cpp), [:3223](../src/display_service.cpp)) runs unlocked. So +`abortToKnownState` → `cleanupDirectWriteState(true)` → `epdSessionForceOff()` acquires a *free* +lock and executes `bbepSleep` + `pwrmgm(false)` — dropping the rail while another task drives +the same SPI bus and CS. This is exactly the hazard ESP32's deferral comment cites +([esp32_ble_callbacks.h:62-68](../src/esp32_ble_callbacks.h)). **Real, not theoretical.** + +**2. HIGH, nRF only — session key zeroed mid-decrypt.** `[H4]` verbatim: `clearEncryptionSession()`'s +`memset(session_key, 0, 16)` ([encryption.cpp:205](../src/encryption.cpp)) landing inside a +concurrent `aes_ccm_decrypt`. Produces a spurious tag failure → `integrity_failures++` → possibly +a second clear. Incorrect, not memory-unsafe. + +**3. MEDIUM, both targets — the calling handler continues on reset state.** A nested abort +returns into its caller, which keeps running against zeroed state. Audited for the two Phase 5 +triggers and both are benign: `handleWriteConfigChunk`'s completion path +([communication.cpp:566-576](../src/communication.cpp)) calls `reloadConfigAfterSave()`, then +`sendResponse()` (queues into a just-flushed ring — the response still goes out, which is +correct) and re-clears three `chunkedWriteState` fields that abort already cleared (idempotent). +`decryptCommand`'s failure path returns straight out to a `sendResponseUnencrypted` + `return`. +**This must be re-audited per trigger in Phase 5/6, not assumed.** + +**4. LOW, nRF only — loop stall, not deadlock.** If the Callback task holds `pwrmgmLock` inside +`epdSessionAcquire`'s `bbepSendCMDSequence`, an abort from `loop()` spins in `pwrmgmLockTake` +with `delay(1)`. Bounded by Phase 2 `[C2]`'s 60 s deadline — and abort must then handle the +`false` return, which is a Phase 2 dependency, not a reason to gate. + +#### Hazards searched for and NOT found + +These are the ones that would have forced a gate. They are absent, and that is what makes +ungating defensible: + +- **No use-after-free anywhere in the teardown.** Every buffer it touches is static or + file-static: `pipeReorder` ([display_service.cpp:576](../src/display_service.cpp)), + `pipeState` ([:575](../src/display_service.cpp)), `partialCtx` ([:556](../src/display_service.cpp)), + `chunkedWriteState` ([main.h:283](../src/main.h)). `grep -n 'free(\|delete ' src/display_service.cpp` + returns nothing. **Worst case is stale or inconsistent state — never memory unsafety.** A + corrupted transfer is recoverable by the very mechanism doing the corrupting; a heap fault is + not. This is the single strongest argument for ungating. +- **No self-deadlock on `pwrmgmLock`.** All four take/give pairs + ([:439-488](../src/display_service.cpp), [:496-509](../src/display_service.cpp), + [:513-515](../src/display_service.cpp), [:520-526](../src/display_service.cpp)) are contained + within a single function; the lock is never held across a return into handler code. A nested + abort can always acquire it. Checked specifically because the lock is non-recursive. +- **No new corruption in the double-handler case.** Where two `imageDataWritten` copies run + concurrently on nRF, `plaintext[512]` and `decrypted_data[512]` are **`static`** + ([communication.cpp:680](../src/communication.cpp), [:704](../src/communication.cpp)) and + already race, independent of abort. That scenario is pre-existing and unsafe on its own terms; + abort neither creates nor meaningfully worsens it. **Out of Phase 3's scope — do not try to + fix it here, but do not let it be cited as a cost of ungating either.** + +#### Accommodating hazard 1 (rail cut mid-SPI-write) + +The caller-side depth check (condition 2 below) covers the *common* case, but it is not +sufficient on its own, and relying on it alone would be the weak point of this design. Two gaps: + +- A **handler-context** caller (`integrity_failures`, `reloadConfigAfterSave`) does not check + depth — correctly, since it is on the handler's own task. But in `[H4]`'s double-handler case + (two `imageDataWritten` copies on nRF after an `rtos_malloc` failure), handler A can abort + while handler B streams. Depth is 2 and nobody is checking. +- The check is a *caller* discipline. Phase 6 adds another caller, Phase 7 adds two more. One + omission reintroduces the hazard silently. + +**So do not rely on the gate. Close it structurally, at the panel.** + +`abortToKnownState` already refuses to force the panel off during a refresh (§8.3 step 6). The +gap is that `epdRefreshInProgress` covers only the *refresh* — it is set at +[display_service.cpp:2415](../src/display_service.cpp) and cleared at +[:2436](../src/display_service.cpp), around `bbepRefresh` + `waitforrefresh` — and **not** the +streaming that precedes it. Extend the same idea to streaming: + +```c +// display_service.cpp — new, next to epdRefreshInProgress +volatile bool epdStreamInProgress = false; +``` + +Set and clear it around the two choke points every controller write funnels through, so one flag +covers pipe, partial, compressed, legacy, FastEPD and E1004: + +| Choke point | Covers | +|---|---| +| `pipeConsumePayload()` ([display_service.cpp:2600](../src/display_service.cpp)) | all PIPE paths — partial (`partial_consume_bytes`), compressed (`handleDirectWriteCompressedData`), raw (`streamGray4Bytes` / `directWriteSinkBytes` / `fastepd_direct_write_chunk`) | +| the legacy `0x0071` data handler | legacy direct-write streaming | + +Then §8.3 step 6 becomes: + +```c + // 6. Panel power — NEVER interrupt a refresh OR an in-flight controller stream. + // Cutting the rail under bbepWriteData drives SPI/CS against a dead panel. + // pwrmgmLock does NOT protect this: epdSessionAcquire releases at :488 and all + // streaming runs unlocked, so the lock would be free and the force-off would + // succeed -- which is exactly the bug. + if (!epdRefreshInProgress && !epdStreamInProgress) { + epdSessionForceOff(); + } else { + epdForceOffPending = true; // completed later by serviceDeferredPanelOff() + od_log_warn("ABORT: %s in progress — panel force-off deferred, not skipped", + epdRefreshInProgress ? "refresh" : "stream"); + } +``` + +**What happens to the deferred force-off.** It is **completed, not abandoned** — +`serviceDeferredPanelOff()` (§8.3.1) retries from `loop()` every pass until the refresh or +stream ends. The rest of the teardown does not wait on it: queues, transfer state, touch, +buzzer/LED and the session are all torn down immediately, since none of them touch the panel. + +Three existing mechanisms remain as backstops if the pending flag were somehow lost: the +keep-alive deadline (`epdSessionTick`, [display_service.cpp:517-526](../src/display_service.cpp)), +the direct-write watchdog ([main.cpp:436-442](../src/main.cpp)), and the next transfer's +`epdSessionAcquire`. + +> ⚠️ **Updated 2026-07-26 — Phase 2 scope cut.** This previously said "reuse Phase 2 `[C2]`'s +> `panelStateUnknown` flag rather than adding a second one." **P2-1 was dropped, so no such flag +> exists.** `pwrmgmLockTake()` keeps its unbounded spin and its `void` signature, and produces no +> signal for `abortToKnownState` to report. Phase 3 must either define its own flag if it wants one, +> or — preferably — drop panel-state reporting from `abortToKnownState`'s remit entirely, since the +> condition it was to report can no longer be detected. The three backstops above are unaffected. + +**Cost:** one `volatile bool`, two set/clear pairs, one extra term in an existing condition. No +locking, no serialization, no change to the streaming hot path. + +#### Accommodating hazard 2 (session key zeroed mid-decrypt) + +Same principle: do not gate the abort, make the operation safe. + +`clearEncryptionSession()` ([encryption.cpp:201-219](../src/encryption.cpp)) does two different +jobs in one function, and only one of them is racy: + +| Job | Racy? | +|---|---| +| `authenticated = false`, `nonce_counter`/`last_seen_counter`/`integrity_failures`/`session_start_time`/`last_activity`/`auth_attempts`/`server_nonce_time` = 0 | **No.** Scalar stores. A concurrent reader sees old or new, both coherent. | +| `memset` of `session_key`, `client_nonce`, `server_nonce`, `pending_server_nonce`, `replay_window`; `ccm_session_free()` | **Yes.** A 16-byte buffer half-zeroed under `aes_ccm_decrypt` yields garbage plaintext; `ccm_session_free` frees an mbedTLS context that may be in use. | + +**Split it: invalidate now, scrub later.** + +```c +void clearEncryptionSession(void) { + // Invalidate IMMEDIATELY -- this is the security-relevant half and it is + // race-free (plain scalar stores). isAuthenticated() goes false at once, so + // every subsequent command is refused regardless of when the scrub lands. + encryptionSession.authenticated = false; + encryptionSession.session_start_time = 0; + encryptionSession.nonce_counter = 0; + encryptionSession.last_seen_counter = 0; + encryptionSession.integrity_failures = 0; + encryptionSession.last_activity = 0; + encryptionSession.auth_attempts = 0; + encryptionSession.server_nonce_time = 0; + + // Scrub the key material + CCM context. Safe inline whenever no command can be + // executing concurrently; deferred otherwise. See sessionScrubPending below. + if (sessionScrubIsSafeNow()) sessionScrubNow(); + else sessionScrubPending = true; + od_log_info("Encryption session cleared (scrub %s)", + sessionScrubPending ? "deferred" : "done"); +} +``` + +```c +// ESP32: always safe -- all handlers run on the loop task, so a caller of +// clearEncryptionSession() is never concurrent with a decrypt. +// nRF: safe only at depth 0. +static inline bool sessionScrubIsSafeNow(void) { +#ifdef TARGET_ESP32 + return true; +#else + return g_commandInFlight == 0; +#endif +} +``` + +`sessionScrubPending` is serviced from `loop()` when `g_commandInFlight == 0` — the same +condition and the same place as §9.2's nRF disconnect service, so it is one mechanism, not two. + +**Why deferring the scrub is not a security regression.** Three reasons, in order of weight: + +1. **`isAuthenticated()` is already false.** [communication.cpp:663-670](../src/communication.cpp) + gates on it *before* `decryptCommand` is ever reached, so no new command can use the key — + the window is closed the instant the invalidate half runs. +2. **The one in-flight decrypt completes correctly rather than incorrectly.** It already passed + the auth gate before the clear, so it was going to run either way. With an intact key it + produces valid plaintext and dispatches a slightly stale command; with a half-zeroed key it + produces garbage, fails the CCM tag, and increments `integrity_failures` — which under + Phase 5 triggers *another* clear. **The racing memset is the worse outcome, not the safer + one.** +3. **The scrub is anti-forensic hygiene, not access control.** It bounds how long key bytes sit + in `.bss`. Deferring it by one loop pass (microseconds to milliseconds) does not change the + threat model. Note also that deep sleep wipes `encryptionSession` outright — it is plain + `.bss` at [main.h:289](../src/main.h), not `RTC_DATA_ATTR`. + +**Phase 5 interaction:** Phase 5 adds a guard *inside* `clearEncryptionSession()` (raise a flag +when clearing under a live link). That guard goes in the **invalidate** half, which always runs +inline — it must not end up behind the deferred scrub. + +#### Decision + +**Ungate `abortToKnownState` — it always runs when called — subject to two conditions:** + +1. **The re-entrancy guard must be atomic, not a plain `static bool`.** §8.3 specifies + `static bool inAbort`; ungated cross-task entry means two tasks can both read `false` and + both proceed. Use `__atomic_exchange_n(&inAbort, 1, __ATOMIC_ACQ_REL)` and return if it was + already 1. **This is a required amendment to §8.3.** + + Plus the two structural accommodations above, which are what actually make ungating safe: + `epdStreamInProgress` gating the panel force-off (hazard 1), and the invalidate/scrub split + in `clearEncryptionSession()` (hazard 2). **Neither is optional** — with them, the + caller-side check below is defence in depth rather than the only defence. +2. **Every `loop()`-side caller on nRF carries the depth check itself**, as defence in depth. + The hazard is loop-aborting-under-a-live-handler, so the check belongs where that context is + known — exactly the shape §9.2 already specifies for the nRF disconnect service + (`nrfDisconnectCleanupPending && !epdRefreshInProgress && g_commandInFlight == 0`). Phase 6's + supervisor arm on nRF must carry the same term. ESP32 callers need no such check, because + ESP32 has no cross-task handler execution. + +Handler-context callers (Phase 5's `integrity_failures`, `reloadConfigAfterSave`) call +**unconditionally on both targets** — they are on the handler's own task, so there is no +concurrency for a gate to prevent. + +Record this in `abortToKnownState`'s header comment so Phase 6 does not "restore" the gate: + +```c +// NEVER gated. Callable from any context, at any in-flight depth, on either task. +// Re-entrancy is handled internally by an atomic guard. The g_commandInFlight +// check is the CALLER's responsibility and ONLY on nRF, ONLY from loop()-side +// callers (disconnect service, supervisor) -- because the hazard is cross-task +// concurrency, not stack depth. ESP32 runs all handlers on the loop task, so a +// nested abort there is strictly sequential and needs no check. Do not move the +// depth check in here: it would disable abort for the in-handler triggers +// (integrity_failures, reloadConfigAfterSave) that need it most. See plan D7. +``` + +--- + +## 14. Out of scope for Phase 3 (recorded so review does not re-litigate) + +- Owner token / `linkClaim()` / `linkRelease()` — Phase 4. `linkReleaseIfHeld()` is a no-op + stub here. +- The `ownerStillUp` guard move out of `#ifdef OPENDISPLAY_HAS_WIFI` `[C4]` — Phase 4. +- Command-ring overflow *policy* (`[H1]`: log-and-drop, escalate only via the supervisor) — + Phase 7. Phase 3 only declares the two overflow flags. +- The supervisor predicate and the wall-clock backstops `[H3]` — Phase 6. +- `COMMAND_QUEUE_SIZE` 33 → 34 — Phase 7. +- Any change to `sendPipeNack`'s latch semantics or the `error_since_ms` field — Phase 5. +- The `docs/pipe-write-protocol.md` §5.1 documentation note the parent plan permits — Phase 5; + it describes the pipe error-release deadline, which Phase 3 does not implement. +- The pipe-only abort NACK (D3's follow-up) — deferred indefinitely, pending hardware evidence + that the client's own timeout is insufficient. +- Anything that would edit `include/opendisplay_protocol.h`, `include/opendisplay_structs.h`, + or push through `../opendisplay-protocol` — out of bounds for **every** phase (§2), not just + this one. diff --git a/docs/TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md b/docs/TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md new file mode 100644 index 0000000..ab45de9 --- /dev/null +++ b/docs/TIMER_AND_WATCHDOG_INVENTORY_2026-07-26.md @@ -0,0 +1,1182 @@ +# Timer, Watchdog and Timeout Inventory — `Firmware` + +**Date:** 2026-07-26 +**Scope:** everything tracked in this repo — `src/`, `include/`, `platformio.ini`, `scripts/`. +**Explicitly out of scope:** `.pio/libdeps/**` (bb_epaper, NimBLE-Arduino, FastEPD, Adafruit +Bluefruit), `~/.platformio/packages/**`, the precompiled `sdkconfig.h`, mbedTLS, FreeRTOS/IDF, +and every sibling repo (`py-opendisplay`, …). Where firmware hands control to one of those, the +entry is recorded as **bounded by library — not analyzed (out of scope)** with the firmware-side +call site and the argument the firmware passes. + +Written as the pre-implementation map for +[PLAN_FREEZE_PROOFING_2026-07-26.md](PLAN_FREEZE_PROOFING_2026-07-26.md) and its review, +[FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md](FINDINGS_FREEZE_PROOFING_PLAN_REVIEW_2026-07-26.md). +Every claim below was verified against source; nothing is carried over from those documents +without re-checking. Corrections to them are in the final section. + +**Target shorthand.** `nRF` = `TARGET_NRF` (nRF52840, Bluefruit; commands run inline on the +Bluefruit Callback task, no queues). `ESP32` = `TARGET_ESP32` (S3/C6/C3/classic; SPSC command +ring drained by `loop()`). `OPENDISPLAY_HAS_WIFI` is `TARGET_ESP32 && OPENDISPLAY_ENABLE_WIFI` — +`esp32-N4` is ESP32 **without** WiFi, `nrf52840custom` has neither. + +--- + +## 1. Watchdog-related build flags and code in this repo + +### 1.1 `-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` + +- **Mechanism:** PlatformIO build flag, present in every ESP env. +- **file:line:** [platformio.ini:53](../platformio.ini), [:83](../platformio.ini), + [:112](../platformio.ini), [:140](../platformio.ini), [:189](../platformio.ini), + [:209](../platformio.ini), [:229](../platformio.ini), [:253](../platformio.ini), + [:295](../platformio.ini). `esp32-s3-E1004` ([:156](../platformio.ini)) and + `esp32-s3-N16R8-extuart-debug` ([:278](../platformio.ini)) inherit it through + `${env:.build_flags}`, so **all ten ESP envs carry it**; `nrf52840custom` does not. +- **Target:** ESP32 only. +- **Duration / bound:** nominally 120 s. +- **What it monitors:** nothing. **The flag is inert.** Two independent reasons, both verifiable + from inside this repo: (a) it is not an IDF 5.x symbol — the real one is + `CONFIG_ESP_TASK_WDT_TIMEOUT_S` — and (b) the firmware never calls any WDT API, so nothing in + this codebase reads it. Verified firmware-side: `grep -rn "esp_task_wdt\|rtc_wdt\|NRF_WDT" src/` + returns **zero** hits. +- **Action on expiry:** none. +- **Context:** n/a. +- **Can it fire while connected / transferring / mid-refresh?** It cannot fire at all. + +### 1.2 Firmware-armed watchdogs + +**There are none, on either target.** No `esp_task_wdt_add/init/reset`, no `NRF_WDT`, no +`rtc_wdt_*` anywhere in `src/`. Whatever hardware watchdog the platform enables by default is +outside this repo's control and out of scope. The only "watchdogs" this firmware owns are the +`millis()` deadlines in §2. + +### 1.3 Reset-reason handling (observability only) + +- **Mechanism:** `resetReasonName()` + `esp_reset_reason()` logging at boot. +- **file:line:** [main.cpp:29-44](../src/main.cpp) (name table), + [main.cpp:82-84](../src/main.cpp) (read + log). +- **Target:** ESP32 (`#ifdef TARGET_ESP32`). +- **Bound:** n/a — one-shot at boot. +- **What it monitors:** whether the previous boot ended in `PANIC` / `INT_WDT` / `TASK_WDT` / + `WDT` / `BROWNOUT`. Purely a log line; nothing branches on it. +- **Action:** logs. The adjacent comment at [main.cpp:95-100](../src/main.cpp) records the + hardware-proven consequence that a non-deep-sleep reset wipes RTC memory (so `deep_sleep_count` + reads 0 and the boot screen redraws). +- **Context:** `setup()`. + +--- + +## 2. `millis()`-based deadlines in `src/` + +Every one, grouped by subsystem. + +### 2.1 Direct-write 15-minute watchdog + +- **file:line:** [main.cpp:436-442](../src/main.cpp); stamp set at + [display_service.cpp:2077](../src/display_service.cpp) (`directWriteActivatePanel`), cleared at + [display_service.cpp:2022](../src/display_service.cpp) (`cleanupDirectWriteState`). +- **Target:** ESP32 only — the block sits inside the `#ifdef TARGET_ESP32` region of `loop()`. + **nRF has no equivalent**; its `loop()` body is the `#else` arm + ([main.cpp:518-530](../src/main.cpp)). +- **Duration:** `900000UL`, an inline literal at [main.cpp:438](../src/main.cpp). No named + constant. +- **Monitors:** wall-clock age of a direct-write session, measured from START. **Nothing + refreshes the stamp** — it is a hard cap on the whole upload + refresh window, not an + inactivity timer. +- **Action on expiry:** `od_log_error` + `cleanupDirectWriteState(true)` → clears all + `directWrite*` state and force-powers the panel off. +- **Context:** loop task, once per pass. +- **Fires while connected?** Yes. **During a transfer?** That is its only purpose. **Mid-EPD + refresh?** No — `directWriteFinishAndRefresh` blocks the loop task across + `bbepRefresh`/`waitforrefresh` ([display_service.cpp:2415-2436](../src/display_service.cpp)), + so the check simply does not run until the refresh returns. +- **Gap:** keys on `directWriteActive`. `sendPipeNack` clears that flag via + `cleanupDirectWriteState(true)` ([display_service.cpp:2564-2578](../src/display_service.cpp)) + while leaving `pipeState.active = true` — after a fatal pipe NACK this watchdog no longer + applies to the latched pipe. + +### 2.2 `checkPartialWriteTimeout()` — partial-write 15-minute watchdog + +- **file:line:** [display_service.cpp:578-587](../src/display_service.cpp); called from + [main.cpp:443](../src/main.cpp). Stamp set at + [display_service.cpp:2249](../src/display_service.cpp) (legacy `0x76` START) and + [display_service.cpp:2762](../src/display_service.cpp) (pipe-partial START). +- **Target:** ESP32 only (same `#ifdef` region as §2.1). +- **Duration:** `900000UL`, inline literal at [display_service.cpp:580](../src/display_service.cpp). +- **Monitors:** `partialCtx.active` age from START. Again a start-stamp, never refreshed. +- **Action:** `cleanup_partial_write_state()`, plus `resetPipeWriteState()` when + `pipeState.partial` — so a pipe-partial transfer *is* cleared here. +- **Context:** loop task, once per pass. +- **Fires while connected/transferring?** Yes. Mid-refresh: same answer as §2.1 — the loop task + is inside the refresh, so the check is deferred, not suppressed. + +### 2.3 `waitforrefresh()` — panel busy-wait bound + +- **file:line:** [display_service.cpp:747-775](../src/display_service.cpp); the loop bound is + [display_service.cpp:760](../src/display_service.cpp). +- **Target:** both. +- **Duration:** `timeout * 100` iterations of `delay(10)`. Every call site passes `60` → + **60 s**: [display_service.cpp:541](../src/display_service.cpp) (boot), + [:1592](../src/display_service.cpp) (FastEPD boot), [:2423](../src/display_service.cpp) + (FastEPD direct refresh), [:2432](../src/display_service.cpp) (bb_epaper direct refresh), + [:3241](../src/display_service.cpp) and [:3244](../src/display_service.cpp) (partial refresh). +- **Monitors:** the panel BUSY line via `bbepIsBusy()`. +- **Action:** `od_log_warn("Refresh timed out")`, returns `false`. Callers treat `false` as + refresh failure → `epdSessionRelease(false)` powers the panel down. +- **Context:** whichever task ran the refresh — loop task on ESP32, Bluefruit Callback task on + nRF for a client-driven refresh. +- **Two short-circuits that make the 60 s cap not apply:** + 1. **FastEPD (IT8951 / E1004 driver builds):** + [display_service.cpp:749](../src/display_service.cpp) returns `fastepd_wait_refresh(timeout)`, + which is a stub — [display_fastepd.cpp:228-231](../src/display_fastepd.cpp) does + `(void)timeout_sec; return !s_init_failed;`. **No wait, no bound.** The actual blocking + happens inside the library (`fastepd_full_update` / `fastepd_direct_refresh`) — + *bounded by library — not analyzed (out of scope)*, firmware call sites + [display_service.cpp:1592](../src/display_service.cpp) and + [:2422](../src/display_service.cpp), no timeout argument passed. + 2. **E1004 panel:** [display_service.cpp:752-756](../src/display_service.cpp) returns `true` + immediately when the panel is already idle, on the grounds that `bbepRefresh` already waited + — *bounded by library — not analyzed (out of scope)*. +- The 60 s loop itself yields (`delay(10)`), which is why a long refresh does not trip whatever + platform WDT exists. + +### 2.4 EPD keep-alive (`pwrmgmOffDeadlineMs` / `epdSessionTick`) + +- **file:line:** deadline armed [display_service.cpp:505](../src/display_service.cpp) + (`epdSessionRelease`); expiry checked [display_service.cpp:520-527](../src/display_service.cpp) + (`epdSessionTick`); window computed [display_service.cpp:383-395](../src/display_service.cpp). +- **Target:** both. +- **Duration — config-driven:** `power_option.screen_timeout_seconds` + ([opendisplay_structs.h:504](../include/opendisplay_structs.h), `uint8_t`, documented `@min 0 + @max 30`), clamped to `EPD_KEEPALIVE_MAX_S = 30` + ([display_service.h:16](../src/display_service.h)) at + [display_service.cpp:393](../src/display_service.cpp). **0 = power off immediately** (also the + factory/old-blob default). **Forced to 0 unconditionally when an AXP2101 PMIC is in the sensor + list** ([display_service.cpp:385-391](../src/display_service.cpp)), logging once per call when + it overrides a nonzero value. +- **Monitors:** how long a `PWR_WARM` (post-successful-refresh) panel keeps its rail up. +- **Action:** `epdSessionForceOffLocked()` — `bbepSleep` + `delay(50)` + rail cut, or + `fastepd_direct_sleep()` on FastEPD builds. +- **Context:** loop task via [main.cpp:353](../src/main.cpp), **and** inside `idleDelay()` at + [main.cpp:545](../src/main.cpp) so a long `idleDelay` still expires it. +- **Fires while connected?** Yes — WARM survives disconnect by design. **During a transfer?** No: + the tick early-returns unless `pwrmgmState == PWR_WARM` + ([display_service.cpp:521](../src/display_service.cpp)) and a transfer is `PWR_ACTIVE`, and it + additionally `TryTake`s the lock and skips the pass if held. **Mid-refresh?** No, same reasons. +- On battery ESP32 the effective window is `min(configured window, idle-hold)`, because + `enterDeepSleep()` calls `epdSessionForceOff()` unconditionally at + [main.cpp:609](../src/main.cpp). + +### 2.5 Encryption session timeout + +- **file:line:** [encryption.cpp:221-232](../src/encryption.cpp) + (`checkEncryptionSessionTimeout`), reached from `isAuthenticated()` + [encryption.cpp:194-198](../src/encryption.cpp). +- **Target:** both. +- **Duration — config-driven:** `securityConfig.session_timeout_seconds` + ([opendisplay_structs.h:916](../include/opendisplay_structs.h), `uint16_t` LE, documented + `0 = no timeout (persists until disconnect)`). **0 short-circuits to "still valid"** at + [encryption.cpp:223](../src/encryption.cpp) — there is no firmware fallback constant. +- **Monitors:** age since `session_start_time`, in whole seconds + ([encryption.cpp:224-225](../src/encryption.cpp)). +- **Action:** `clearEncryptionSession()` + return false → the command that triggered the check is + answered `RESP_AUTH_REQUIRED` ([communication.cpp:664-670](../src/communication.cpp)). +- **Context:** whichever task dispatches the command — loop task (ESP32) or Bluefruit Callback + task (nRF). It is a *query with side effects*: `isAuthenticated()` mutates session state. +- **Fires while connected?** Yes, by construction. **During a transfer?** **Yes** — it is + evaluated on every command dispatch, including every pipe DATA frame, so on a nonzero + configured value it deterministically fires mid-upload once the transfer outlives the window. + **Mid-refresh?** No — no commands dispatch while the loop task is inside the refresh. + +### 2.6 Encryption auth challenge validity — 30 s + +- **file:line:** stamped [encryption.cpp:588](../src/encryption.cpp) + (`server_nonce_time = currentTime`), checked + [encryption.cpp:604](../src/encryption.cpp). +- **Target:** both. +- **Duration:** `30000` ms, inline literal at [encryption.cpp:604](../src/encryption.cpp). +- **Monitors:** how long an issued `AUTH_STATUS_CHALLENGE` server nonce stays acceptable. +- **Action:** the 32-byte response arm rejects the stale challenge (client must re-request). +- **Context:** command dispatch task. +- **Fires mid-transfer?** Only during the authenticate handshake, which precedes any transfer. + +### 2.7 LAN read / idle timeout — 30 s + +- **file:line:** [wifi_service.cpp:957-960](../src/wifi_service.cpp). Stamps at + [:891](../src/wifi_service.cpp) (accept), [:915](../src/wifi_service.cpp) (TLS handshake + complete), [:951](../src/wifi_service.cpp) (bytes read), [:977](../src/wifi_service.cpp) + (valid frame about to dispatch). +- **Target:** ESP32 with `OPENDISPLAY_HAS_WIFI` only. +- **Duration:** `OD_LAN_READ_TIMEOUT_S = 30u`, from the canonical protocol header + [opendisplay_protocol.h:984](../include/opendisplay_protocol.h) — the **only** timeout in this + firmware that comes from the wire protocol rather than being firmware-local. +- **Monitors:** silence on an accepted LAN TCP/TLS session. Note the guard is + `else if (drainedBytes == 0)` — the check only runs on a tick where **nothing** was read. +- **Action:** `disconnectWiFiServer()`. +- **Context:** loop task, inside `handleWiFiServer()`. +- **Fires while a BLE client is connected?** Yes — the two transports are independent, and + `disconnectWiFiServer()` raises `bleDisconnectCleanupPending` + ([wifi_service.cpp:812](../src/wifi_service.cpp)), which is why the `ownerStillUp` guard in + [main.cpp:328-338](../src/main.cpp) exists. **During a LAN transfer?** Only if the client has + been silent 30 s. **Mid-refresh?** No — `handleWiFiServer()` runs on the loop task, which is + inside the refresh. + +### 2.8 `wifiClient.setTimeout(30000)` + +- **file:line:** [wifi_service.cpp:888](../src/wifi_service.cpp). +- **Target:** ESP32 + WiFi. +- **Bound:** 30 000 ms passed to the Arduino `WiFiClient` — *bounded by library — not analyzed + (out of scope)*. Recorded because the firmware chooses the value. + +### 2.9 WiFi link supervisor poll — 10 s + +- **file:line:** [main.cpp:448-464](../src/main.cpp). +- **Target:** ESP32 + WiFi. +- **Duration:** `10000` ms, inline literal at [main.cpp:449](../src/main.cpp). +- **Monitors:** `WiFi.status()` vs. the cached `wifiConnected` flag. +- **Action:** on loss → `disconnectWiFiServer()`; on regain → `restartWiFiLanAfterReconnect()`. +- **Context:** loop task. +- **Fires while a BLE client is connected / transferring?** Yes to both — and via + `disconnectWiFiServer()` it raises the shared BLE-disconnect-cleanup flag. + +### 2.10 Initial blocking WiFi connect — 3 × 10 s + 2 × 2 s + +- **file:line:** [wifi_service.cpp:762-787](../src/wifi_service.cpp). +- **Target:** ESP32 + WiFi, and only on the `waitForConnection == true` path (setup). +- **Duration:** `maxRetries = 3` ([:762](../src/wifi_service.cpp)) × + `timeoutPerRetry = 10000` ms ([:763](../src/wifi_service.cpp)), with `delay(2000)` between + attempts ([:786](../src/wifi_service.cpp)) → **worst case ~34 s of blocking**. Inner poll is + `delay(500)` ([:769](../src/wifi_service.cpp)); early-aborts on `WL_CONNECT_FAILED` / + `WL_NO_SSID_AVAIL`. +- **Action on expiry:** `wifiConnected = false`, log, continue booting. +- **Context:** whichever task called `initWiFi(true)`. + +### 2.11 mDNS TXT MSD update throttle — 400 ms + +- **file:line:** [wifi_service.cpp:377-383](../src/wifi_service.cpp). +- **Target:** ESP32 + WiFi. +- **Duration:** `400` ms, inline literal at [:378](../src/wifi_service.cpp). +- **Monitors:** re-publish rate of the `msd` TXT record when the payload is unchanged. +- **Action:** skip the update. + +### 2.12 Deep-sleep idle hold + +- **file:line:** [main.cpp:486-499](../src/main.cpp) (idle branch); + [main.cpp:374-390](../src/main.cpp) (post-wake advertising branch). `lastActivityMs` is + refreshed by `pollActivity()` [main.cpp:257](../src/main.cpp), at end of setup + [main.cpp:175](../src/main.cpp), and on an aborted sleep [main.cpp:590](../src/main.cpp). +- **Target:** ESP32 only. +- **Duration — config-driven:** `power_option.sleep_timeout_ms` + ([opendisplay_structs.h:490](../include/opendisplay_structs.h), `uint16_t` LE ms — so the + maximum expressible hold is 65 535 ms). **Fallback when 0: `DEFAULT_IDLE_HOLD_MS = 10000`** + ([main.h:322](../src/main.h)), applied at [main.cpp:488-490](../src/main.cpp) and + [main.cpp:375-377](../src/main.cpp). +- **Monitors:** quiet time since the last detected activity. +- **Action:** `enterDeepSleep()`. +- **Context:** loop task. Gated on `power_mode == 1` **and** `deep_sleep_time_seconds > 0`. +- **Fires while connected?** No — `pollActivity()` treats `connCount > 0` as activity in itself + ([main.cpp:254](../src/main.cpp)), so a connected client pins the device awake indefinitely. + `enterDeepSleep()` re-checks `getConnectedCount() > 0` at [main.cpp:588](../src/main.cpp). + **During a transfer with the link already dropped?** `workInFlight` + ([main.cpp:474-479](../src/main.cpp)) does **not** include `transferActive()`, so a latched + `pipeState.active` with `connCount == 0` does not block the idle branch. **Mid-refresh?** No — + `epdRefreshInProgress` is in `workInFlight` ([main.cpp:478](../src/main.cpp)). + +### 2.13 Minimum-wake hold + +- **file:line:** `minWakeTimeMs()` [main.cpp:197-200](../src/main.cpp); `minWakeHoldActive()` + [main.cpp:202-210](../src/main.cpp); armed at [main.cpp:162-163](../src/main.cpp) (button wake) + and [main.cpp:171-172](../src/main.cpp) (first boot / hidden reset). +- **Target:** ESP32 only. +- **Duration — config-driven:** `power_option.min_wake_time_seconds` + ([opendisplay_structs.h:503](../include/opendisplay_structs.h), `uint16_t` LE seconds). + **Fallback when 0: `DEFAULT_MIN_WAKE_TIME_SECONDS = 120`** ([main.h:312](../src/main.h)). +- **Monitors:** a floor on awake time after a button wake or a first boot, layered *under* the + idle hold — sleep requires both conditions. +- **Action on expiry:** clears `minWakeWindowActive`, logs, permits sleep. +- **Context:** loop task; also re-checked defensively inside `enterDeepSleep()` + ([main.cpp:598-601](../src/main.cpp)), which the comment notes must stay ahead of the + advertising stop. + +### 2.14 Post-wake advertising window + +- **file:line:** [main.cpp:374-390](../src/main.cpp); start stamp + [main.cpp:158](../src/main.cpp). +- **Target:** ESP32 only. +- **Duration:** same source as §2.12 (`sleep_timeout_ms`, fallback `DEFAULT_IDLE_HOLD_MS`). + Measured from `lastActivityMs`, not from `advertising_start_time` — a connect-then-drop re-arms + the whole window. `advertising_start_time` is used only for the log line + ([main.cpp:384](../src/main.cpp)). +- **Action:** `enterDeepSleep()`. +- **Context:** loop task, `woke_from_deep_sleep && advertising_timeout_active` branch; the branch + `return`s every pass after `idleDelay(50)` ([main.cpp:396](../src/main.cpp)), so the command + drain and everything below it are **not reached** until a client connects. + +### 2.15 MSD refresh cadence — 60 s + +- **file:line:** [main.cpp:509-513](../src/main.cpp). +- **Target:** ESP32 only. Note it sits in the **`else` (not-`workInFlight`) branch**, so it does + not run while a client is connected or a refresh is in flight. +- **Duration:** `60000` ms, inline literal at [main.cpp:510](../src/main.cpp). +- **Action:** `updatemsdata()` — re-polls sensors and re-pushes the BLE advertisement. +- **Interlock:** `MyBLEServerCallbacks::onConnect` sets `msdUpdatePending` + ([esp32_ble_callbacks.h:56](../src/esp32_ble_callbacks.h)) rather than calling `updatemsdata()` + inline, precisely because this 60 s path also drives it from the loop task. + +### 2.16 nRF advertising boost — 3 s + +- **file:line:** constant [ble_init.cpp:44](../src/ble_init.cpp) (`NRF_ADV_BOOST_MS = 3000`); + armed [:47](../src/ble_init.cpp); consulted [:51](../src/ble_init.cpp) and + [:61](../src/ble_init.cpp); serviced by `ble_nrf_advertising_tick()` + [:59-76](../src/ble_init.cpp). +- **Target:** nRF only. +- **Duration:** 3 000 ms. Boost interval 20–30 ms (`NRF_ADV_BOOST_MIN/MAX`, + [:42-43](../src/ble_init.cpp)); steady interval 160–1000 ms + (`NRF_ADV_INTERVAL_MIN/MAX`, [:40-41](../src/ble_init.cpp)). +- **Action on expiry:** restore the slow interval, `Advertising.stop()` + `start(0)`. +- **Context:** loop task via [main.cpp:526](../src/main.cpp), and inside `idleDelay()` at + [main.cpp:540](../src/main.cpp). + +### 2.17 nRF link-diagnostic one-shot + +- **file:line:** [ble_init.cpp:136-145](../src/ble_init.cpp). +- **Target:** nRF only. +- **Duration:** `s_link_diag_timer.begin(500, …, /*repeating=*/false)` + ([ble_init.cpp:141](../src/ble_init.cpp)); `reset()` restarts it from now + ([:144](../src/ble_init.cpp)). **The literal is 500; the adjacent comment says "fires ~2.5 s + later".** See §8. +- **Action:** logs negotiated PHY/MTU/DLE if still connected. Diagnostics only — no state change. +- **Context:** FreeRTOS timer task (not the Callback task). + +### 2.18 Buzzer sequencing + +- **file:line:** step deadline `s_buzzer.step_until_ms`, armed + [buzzer_control.cpp:203](../src/buzzer_control.cpp) and + [:209](../src/buzzer_control.cpp), consumed + [buzzer_control.cpp:243](../src/buzzer_control.cpp); global cap checked + [buzzer_control.cpp:168](../src/buzzer_control.cpp) against `play_start_ms` + ([:341](../src/buzzer_control.cpp)). +- **Target:** both. +- **Durations:** `kBuzzerMaxTotalMs = 30000u` + ([buzzer_control.cpp:17](../src/buzzer_control.cpp)) — a hard 30 s cap on any playback; + `kBuzzerDurationUnitMs = 5u` ([:15](../src/buzzer_control.cpp)) — note duration units; + `kBuzzerInterPatternGapMs = 20u` ([:16](../src/buzzer_control.cpp)). + Remaining budget is recomputed per step at [:182](../src/buzzer_control.cpp), so no single note + can overshoot the cap. +- **Action on expiry:** `buzzer_stop_internal()` — tone off, drive off, state zeroed. +- **Context:** `buzzerService()`, called from `loop()` at [main.cpp:354](../src/main.cpp), + [:483](../src/main.cpp), [:516](../src/main.cpp), [:529](../src/main.cpp), and from + `idleDelay()` [main.cpp:546](../src/main.cpp). +- **Fires while connected/transferring?** The cap is honest wall-clock and fires whenever + `buzzerService()` runs — but during a blocking refresh or SPI stream `buzzerService()` does not + run, so a tone can sound past 30 s until the loop task is released. Non-blocking design: the + handler ACKs immediately after starting ([:347-348](../src/buzzer_control.cpp)). +- **Blocking exception:** `passiveBuzzerPowerOffAlert()` uses three raw `delay(80)` + ([buzzer_control.cpp:369-373](../src/buzzer_control.cpp)) — ~240 ms of unconditional blocking, + reached from the power-off hold path. + +### 2.19 LED flash sequencing + +- **file:line:** `led_schedule_delay_ms()` [device_control.cpp:356-363](../src/device_control.cpp); + deadline consumed [device_control.cpp:529](../src/device_control.cpp) in `processLedFlash()`. +- **Target:** both. +- **Durations:** per-step, from the config's packed `LedFlashPattern`, scaled by + `LED_DELAY_FACTOR_MS = 100u` and floored at `LED_MIN_STEP_DELAY_MS = 1u` + ([device_control.cpp:279-280](../src/device_control.cpp)). +- **Bound:** **none.** There is no global cap equivalent to the buzzer's `kBuzzerMaxTotalMs`; a + long loop count in the pattern runs to completion. See §5.8. +- **Context:** `processLedFlash()` from `loop()` [main.cpp:352](../src/main.cpp) and `idleDelay()` + [main.cpp:544](../src/main.cpp). + +### 2.20 Touch poll interval / I²C backoff + +- **file:line:** global floor [touch_input.cpp:588-592](../src/touch_input.cpp); per-controller + interval [touch_input.cpp:600](../src/touch_input.cpp); timed-poll comparisons + [:612](../src/touch_input.cpp) and [:625](../src/touch_input.cpp); I²C-fail backoff + [:609-611](../src/touch_input.cpp). +- **Target:** both (the `transferActive()` skip at [:584-586](../src/touch_input.cpp) is + `#ifdef TARGET_ESP32` only). +- **Durations:** `TOUCH_PROCESS_MIN_INTERVAL_MS = 100` + ([touch_input.cpp:38](../src/touch_input.cpp)) — a hard floor on how often + `processTouchInput()` does any work at all; per-controller + `TouchController.poll_interval_ms` ([opendisplay_structs.h:951](../include/opendisplay_structs.h), + `uint8_t` ms) with **firmware fallback `TOUCH_PROCESS_MIN_INTERVAL_MS` (100)** when 0 — the + header documents the default as 25 ms (see §8); `TOUCH_I2C_FAIL_BACKOFF_MS = 100` + ([:37](../src/touch_input.cpp)) suppresses level-triggered INT-low reads after a failure. +- **Action:** skip this pass. +- **Context:** loop task and `idleDelay()`. +- **Fires mid-transfer?** On ESP32 the whole function early-returns while + `transferActive() || s_epd_refresh_suspend > 0` ([:584-586](../src/touch_input.cpp)). On nRF + there is no such gate, but the transfer runs on a different task. + +### 2.21 GT911 reset/settle delays + +- **file:line:** `GT911_PRE_RESET_DELAY_MS = 300` / `GT911_POST_RESET_SETTLE_MS = 200` + ([touch_input.cpp:29-30](../src/touch_input.cpp)), used at + [:317-321](../src/touch_input.cpp), [:331-333](../src/touch_input.cpp), + [:337-339](../src/touch_input.cpp), [:429](../src/touch_input.cpp). + The address-select pulse train at [:250-269](../src/touch_input.cpp) adds + `delay(10)+delay(60)+delay(1)+delay(11)+delayMicroseconds(110)+delay(6)+delay(51)`. +- **Target:** both. +- **Bound:** fixed, unconditional blocking — a full re-init can block ~1 s. +- **Context:** whichever task calls `touchResumeAfterEpdRefresh()` / init, i.e. the loop task. + +### 2.22 Power-off button hold — `power_latch` + +- **file:line:** `POWER_OFF_HOLD_MS = 3000` ([power_latch.cpp:21](../src/power_latch.cpp)); + stamped [:139](../src/power_latch.cpp); compared [:142](../src/power_latch.cpp). +- **Target:** ESP32 (the whole file's active body is ESP32; the nRF build gets the stubs at + [power_latch.cpp:196](../src/power_latch.cpp)). +- **Duration:** 3 000 ms, firmware constant. Not config-driven on this path. +- **Action:** `powerOff()` → latch release + `esp_deep_sleep_start()`. +- **Context:** `powerButtonPoll()`, called from `processButtonEvents()` + ([device_control.cpp:587](../src/device_control.cpp)). +- **Fires while connected / transferring / mid-refresh?** `processButtonEvents()` runs from the + loop task and `idleDelay()`, so it can fire while a client is connected and while a transfer is + latched — but not while the loop task is inside a blocking refresh or SPI stream. + +### 2.23 Power-off button hold — config-driven (`device_control`) + +- **file:line:** stamped [device_control.cpp:75](../src/device_control.cpp); compared + [device_control.cpp:78](../src/device_control.cpp). +- **Target:** both. +- **Duration — config-driven:** `BinaryInputs.power_off_hold_sec` + ([opendisplay_structs.h:862](../include/opendisplay_structs.h), `uint8_t` seconds). + **Fallback when 0: 3 000 ms**, computed at + [device_control.cpp:746](../src/device_control.cpp) into `ButtonState.power_off_hold_ms` + ([structs.h:181](../src/structs.h), `uint16_t` ms). +- **Action:** `passiveBuzzerPowerOffAlert()` (≈240 ms of blocking `delay`) then + `powerLatchTriggerOff()`. +- **Context:** `processButtonEvents()` on the loop task. +- **Note:** this is a *second, independent* power-off-hold mechanism from §2.22, with a different + default source. Both can be armed on the same device. See §6. + +### 2.24 ADC-ladder button poll and press-count window + +- **file:line:** [device_control.cpp:173-200](../src/device_control.cpp). +- **Target:** ESP32 only (`registerAdcLadder` is `#ifdef TARGET_ESP32`, + [device_control.cpp:738-742](../src/device_control.cpp)). +- **Durations:** `ADC_LADDER_POLL_MS = 5` ([:96](../src/device_control.cpp)) — poll floor; + `5000` ms inline at [:193](../src/device_control.cpp) — press-count reset window; + `ADC_LADDER_DEBOUNCE = 3` ([:97](../src/device_control.cpp)) — consecutive equal samples + required. +- **Context:** loop task via `processButtonEvents()`. + +### 2.25 Sensor / battery read caches (TTL, not timeouts) + +| Cache | file:line | TTL | Target | +|---|---|---|---| +| SHT40 MSD poll | [sensor_sht40.cpp:239](../src/sensor_sht40.cpp), used [:245](../src/sensor_sht40.cpp) | `kSht40MsdPollTtlMs = 30000u` | both | +| BQ27220 MSD poll | [sensor_bq27220.cpp:142](../src/sensor_bq27220.cpp), used [:151](../src/sensor_bq27220.cpp) | `kBq27220MsdPollTtlMs = 30000u` | both | +| Battery voltage | [display_service.cpp:1707](../src/display_service.cpp), used [:1712](../src/display_service.cpp) | `kBatteryVoltageTtlMs = 30000u` | both | + +These suppress I²C/ADC work rather than bounding it. Action on expiry is "do the read". The +uncached battery read itself blocks `delay(10) + 10 × delay(2)` ≈ 30 ms +([display_service.cpp:1690-1698](../src/display_service.cpp)). + +### 2.26 Transfer start stamps (inputs to §2.1/§2.2) + +| Stamp | Set | Cleared | Read by | +|---|---|---|---| +| `directWriteStartTime` | [display_service.cpp:2077](../src/display_service.cpp) | [:2022](../src/display_service.cpp), [:2375](../src/display_service.cpp) | [main.cpp:437](../src/main.cpp) | +| `partialCtx.start_time` | [display_service.cpp:2249](../src/display_service.cpp), [:2762](../src/display_service.cpp) | `cleanup_partial_write_state()` | [display_service.cpp:580](../src/display_service.cpp) | +| `imgLogStartMs` | [display_service.cpp:1862](../src/display_service.cpp) | — | [:1901](../src/display_service.cpp) — throughput logging only, no deadline | +| `partial_prepare_panel_ram` `t0` | [display_service.cpp:3249](../src/display_service.cpp) | — | debug logging only, no deadline | + +`pipeState` has **no timestamp field at all** ([structs.h:106-126](../src/structs.h)) — this is +the structural reason a latched pipe has no bound of its own. + +--- + +## 3. Retry / attempt bounds acting as implicit timeouts + +### 3.1 Authentication rate limit — 10 attempts per 60 s + +- **file:line:** [encryption.cpp:568-578](../src/encryption.cpp). +- **Target:** both. +- **Bound:** `auth_attempts >= 10` within `timeSinceLastAuth < 60` seconds (both inline literals, + [:570-571](../src/encryption.cpp)). The counter resets when a request arrives ≥60 s after the + previous one ([:576](../src/encryption.cpp)). +- **Action:** respond `AUTH_STATUS_RATE_LIMIT`, return false. The link is **not** dropped. +- **Context:** command dispatch task. +- **Note:** the counter increments on *every* authenticate attempt including the challenge + request ([:579](../src/encryption.cpp)), so a client that re-handshakes more than 10 times a + minute rate-limits itself. + +### 3.2 Integrity-failure threshold — 3 + +- **file:line:** [encryption.cpp:692-696](../src/encryption.cpp) (nonce/replay path) and + [:729-733](../src/encryption.cpp) (CCM tag path); reset on success + [:725](../src/encryption.cpp) and at session start [:657](../src/encryption.cpp). +- **Target:** both. +- **Bound:** 3 (inline literal, twice). +- **Action:** `clearEncryptionSession()`. The link stays up; every subsequent command is answered + `RESP_AUTH_REQUIRED`, unencrypted, forever. +- **Fires mid-transfer?** **Yes — this is the primary field wedge.** Ordinary packet loss trips + the nonce arm, which increments the same counter as genuine tamper evidence. + +### 3.3 Nonce replay window — ±32, 64-entry ring + +- **file:line:** window test [encryption.cpp:131](../src/encryption.cpp); ring scan + [:137-141](../src/encryption.cpp); ring store [:152-154](../src/encryption.cpp); ring + declaration `uint64_t replay_window[64]` + ([encryption_state.h:21](../src/encryption_state.h)). +- **Target:** both. +- **Bound:** `counter_diff < -32 || counter_diff > 32` → reject. The ring index is a **function + static** at [encryption.cpp:152](../src/encryption.cpp), so `clearEncryptionSession()` memsets + the ring ([:217](../src/encryption.cpp)) but leaves the index — a real bug today. +- **Action:** reject the frame; via §3.2 three rejections kill the session. +- **Ordering hazard:** `last_seen_counter` and the ring are committed at + [:149-154](../src/encryption.cpp) **before** `aes_ccm_decrypt` runs at + [:714](../src/encryption.cpp). + +### 3.4 nRF notify retry — 4 attempts × `delay(5)` + +- **file:line:** [communication.cpp:345-349](../src/communication.cpp). +- **Target:** nRF only. +- **Bound:** up to 4 retries, 5 ms apart → **≤20 ms of blocking** per response on backpressure. +- **Action:** give up silently (no error path if all 5 attempts fail). +- **Context:** Bluefruit Callback task (or, per the inline-fallback hazard, the BLE task). + +### 3.5 ESP32 BLE notify drain cap — 16 per call + +- **file:line:** [main.cpp:279](../src/main.cpp) (`bleDrain < 16`). +- **Target:** ESP32. +- **Bound:** at most 16 notifies per `flushResponseQueueToBle()` call; also breaks early when + `notify()` returns false (mbuf exhaustion), deliberately leaving the entry queued + ([main.cpp:288-290](../src/main.cpp)). +- **Action:** return; the remainder drains next call. Called once per loop pass **and between + every command in the drain** ([main.cpp:421](../src/main.cpp)) and between config-read chunks + ([communication.cpp:473](../src/communication.cpp)). + +### 3.6 Response ring — 10 slots, drop-newest + +- **file:line:** `RESPONSE_QUEUE_SIZE 10` ([structs.h:83](../src/structs.h)); full check + [communication.cpp:113-116](../src/communication.cpp). +- **Target:** ESP32. +- **Action on full:** `od_log_error("Response queue full, dropping response")` and drop **the + newest**. Backlog warning at depth ≥2 ([:126](../src/communication.cpp)). +- **Flush:** drained to empty every pass when no central is connected + ([main.cpp:307-312](../src/main.cpp)). + +### 3.7 Command ring — 33 slots, 32 usable, drop-newest + +- **file:line:** `COMMAND_QUEUE_SIZE 33` ([main.h:371](../src/main.h), mirrored + [esp32_ble_callbacks.h:19](../src/esp32_ble_callbacks.h)); producer refuses at + `nextHead == tail` ([esp32_ble_callbacks.h:122-129](../src/esp32_ble_callbacks.h)). +- **Target:** ESP32. +- **Bound:** usable capacity is `COMMAND_QUEUE_SIZE - 1 = 32`, not 33. `PIPE_MAX_W = 32` + ([structs.h:51](../src/structs.h); 16 under `PIPE_SMALL_DRAM_WINDOW`, + [structs.h:47](../src/structs.h) — `esp32-N4` only). +- **Action on full:** `od_log_error("Command queue full, dropping command")`, drop the newest. +- **Flush:** **never.** There is no code path anywhere that resets `commandQueueHead/Tail`; + stale commands survive a disconnect. + +### 3.8 Loop command drain cap — 33 iterations + +- **file:line:** [main.cpp:406-423](../src/main.cpp) (`while (drained < COMMAND_QUEUE_SIZE)`). +- **Target:** ESP32. +- **Bound:** a count cap only — **no wall-clock cap**. Each iteration can block for the full + duration of the command it dispatches (up to a 60 s refresh), so the drain's true worst case is + unbounded in time. +- **Drain-loop trap:** `tail` is cached at [main.cpp:409](../src/main.cpp) and the incremented + value stored at [:417](../src/main.cpp) *after* dispatch — a flush performed from handler + context would be clobbered by that store. + +### 3.9 Config chunked write + +- **file:line:** START [communication.cpp:495-517](../src/communication.cpp); chunks + [communication.cpp:541-581](../src/communication.cpp). +- **Target:** both. +- **Bounds:** `MAX_CONFIG_CHUNKS = 20u`, `CONFIG_CHUNK_SIZE = 200u`, + `CONFIG_CHUNK_SIZE_WITH_PREFIX = 202u` + ([opendisplay_protocol.h:882-884](../include/opendisplay_protocol.h)); enforced at + [communication.cpp:558](../src/communication.cpp). +- **Action on violation:** `chunkedWriteState.active = false` + NACK. +- **Gap:** `chunkedWriteState.active` is cleared **only** on completion + ([:574](../src/communication.cpp)), a malformed chunk ([:558](../src/communication.cpp)), or an + auth failure ([:550](../src/communication.cpp)). **No timer.** A client that sends `0x0040` + START and vanishes latches it forever — and it is not covered by §2.1 or §2.2. + +### 3.10 Config read chunk cap + +- **file:line:** [communication.cpp:447-448](../src/communication.cpp). +- **Bound:** `maxChunks = (MAX_CONFIG_SIZE + 93) / 94`, a derived compile-time bound guaranteeing + the loop terminates regardless of payload arithmetic. `MAX_RESPONSE_DATA_SIZE = 100u` + ([opendisplay_protocol.h:885](../include/opendisplay_protocol.h)). + +### 3.11 Pipe ACK cadence and reorder bounds + +- **file:line:** in-order cadence [display_service.cpp:2854](../src/display_service.cpp); + gap/duplicate rate limit [:2877-2881](../src/display_service.cpp) and + [:2888-2893](../src/display_service.cpp); reorder overflow guard + [:2874](../src/display_service.cpp); over-size frame guard + [:2817](../src/display_service.cpp); out-of-window NACK [:2896](../src/display_service.cpp). +- **Target:** both. +- **Bounds:** `pipeState.ack_every` (negotiated, `PIPE_MAX_N = 32` / 16 on `esp32-N4`, + [structs.h:52](../src/structs.h)/[:48](../src/structs.h)); `PIPE_REORDER_SLOTS = 33` / 17 + ([structs.h:50](../src/structs.h)/[:46](../src/structs.h)); `PIPE_REORDER_SLOT_SIZE = 248` + ([structs.h:54](../src/structs.h)); `PIPE_ACK_MASK_BITS` from the protocol header. +- **Action on violation:** `sendPipeNack()` → sets `pipeState.error = true` and calls + `cleanupDirectWriteState(true)` ([display_service.cpp:2564-2578](../src/display_service.cpp)). + **`pipeState.active` stays true**, so `transferActive()` latches and the §2.1 watchdog no + longer applies. This is the "pipe fatal-NACK latch". +- All of these are *count* bounds. There is no time bound anywhere in the pipe state machine. + +### 3.12 LAN drain byte budget + +- **file:line:** [wifi_service.cpp:936-992](../src/wifi_service.cpp), loop condition + [:992](../src/wifi_service.cpp). +- **Target:** ESP32 + WiFi. +- **Bound:** `drainedBytes < sizeof(tcpReceiveBuffer)` = **16 384 bytes** per `handleWiFiServer()` + tick ([wifi_service.cpp:40](../src/wifi_service.cpp)). Frame length is separately bounded by + `OD_LAN_MAX_PAYLOAD = 4094u` + ([opendisplay_protocol.h:982](../include/opendisplay_protocol.h)), enforced at + [wifi_service.cpp:966-970](../src/wifi_service.cpp). +- **Action:** return; resume next loop pass. Like §3.8 this is a byte cap, not a time cap — each + dispatched frame can block arbitrarily. + +### 3.13 GT911 I²C retries and disable threshold + +- **file:line:** `GT911_I2C_RETRIES = 3` ([touch_input.cpp:35](../src/touch_input.cpp)), used at + [:180](../src/touch_input.cpp) and [:202](../src/touch_input.cpp) with + `delayMicroseconds(GT911_I2C_RETRY_DELAY_US)` = 500 µs + ([:36](../src/touch_input.cpp), used [:196](../src/touch_input.cpp), [:207](../src/touch_input.cpp), + [:212](../src/touch_input.cpp)); `TOUCH_I2C_FAIL_DISABLE_THRESHOLD = 5` + ([:39](../src/touch_input.cpp)), enforced [:642](../src/touch_input.cpp) and + [:677](../src/touch_input.cpp). +- **Target:** both. +- **Action:** after 5 consecutive failures, `touch_disable_controller()` + ([touch_input.cpp:97-112](../src/touch_input.cpp)) permanently disables that controller for the + rest of the boot (cleared only by a re-init at [:378-379](../src/touch_input.cpp) / + [:519-520](../src/touch_input.cpp)). +- **Underlying `Wire` transaction bound:** *bounded by library — not analyzed (out of scope)*. + **The firmware never calls `Wire.setTimeOut()`** — verified: `grep -rn "setTimeOut" src/` + returns nothing. `wireBeginForOpenDisplay()` + ([display_service.cpp:785-805](../src/display_service.cpp)) sets only clock, with a 100 kHz + fallback on a failed `begin`. + +### 3.14 Boot-screen refresh retry — 1 retry, battery only + +- **file:line:** [display_service.cpp:1622-1636](../src/display_service.cpp). +- **Target:** both (`nrfVbusPresent()` gates it). +- **Bound:** exactly one retry, and only when `!nrfVbusPresent()`. Retry costs + `pwrmgm(false) + delay(200) + prepareEpdRailForBoot() + initBbepPanelSession()` plus a second + `refreshBootScreenFull()` → a second 60 s `waitforrefresh`. Worst case ≈ **2 × 60 s of + blocking in `setup()`**. +- **Action if both fail:** `od_log_warn("Boot screen refresh did not complete")` and continue. +- **Note:** neither boot-refresh path sets `epdRefreshInProgress` — see §4 and §5. + +### 3.15 Fixed-count hardware loops + +| Loop | file:line | Bound | +|---|---|---| +| External-flash bit-bang power-down (nRF) | [main.cpp:846-853](../src/main.cpp), [:872-882](../src/main.cpp) | 8 bits × `delayMicroseconds(1)`; JEDEC read 3 bytes | +| Battery ADC averaging | [display_service.cpp:1694-1698](../src/display_service.cpp) | `numSamples = 10`, `delay(2)` each | +| E1004 half-plane byte sink | [display_service.cpp:278-294](../src/display_service.cpp) | bounded by `e1004HalfPlaneBytes`; returns on `!e1004StreamOpen` | +| Buzzer note-index folding | [buzzer_control.cpp:87-91](../src/buzzer_control.cpp) | converges into `[kBuzzerMinNoteIdx, kBuzzerMaxNoteIdx]` = [117, 234] by ±1 octave steps, with a defensive clamp at [:93-95](../src/buzzer_control.cpp) | +| Boot-screen font autosizing | [boot_screen.cpp:424](../src/boot_screen.cpp), [:439](../src/boot_screen.cpp), [:448](../src/boot_screen.cpp) | monotonically decrement a scale bounded below by 1 | +| Secure-erase zero-fill | [encryption.cpp:817](../src/encryption.cpp), [:834](../src/encryption.cpp) | bounded by `fileSize` | +| Config block walk | [config_parser.cpp:314](../src/config_parser.cpp) | bounded by `configLen` | + +--- + +## 4. Periodic ticks + +Everything below runs from `loop()` and/or `idleDelay()`. **All of it starves whenever the loop +task blocks** — the two long blockers are `waitforrefresh` (up to 60 s) and the SPI/zlib streaming +inside a direct/pipe/partial write. On nRF the command handlers run on the Bluefruit Callback +task instead, so `loop()` keeps ticking during a client-driven transfer; the loop-blocking +analysis below is ESP32-specific except where noted. + +| Tick | file:line | Cadence | Target | Starves when loop blocks | +|---|---|---|---|---| +| `processLedFlash()` | [main.cpp:352](../src/main.cpp), [:544](../src/main.cpp) | every pass; internally gated by `delay_until_ms` | both | LED pattern freezes mid-sequence | +| `epdSessionTick()` | [main.cpp:353](../src/main.cpp), [:545](../src/main.cpp) | every pass; acts only in `PWR_WARM` past the deadline | both | keep-alive expiry deferred (benign — a transfer is `PWR_ACTIVE` anyway) | +| `buzzerService()` | [main.cpp:354](../src/main.cpp), [:483](../src/main.cpp), [:516](../src/main.cpp), [:529](../src/main.cpp), [:546](../src/main.cpp) | every pass; gated by `step_until_ms` | both | a tone sounds past its step, and past the 30 s cap | +| `pollActivity()` | [main.cpp:356](../src/main.cpp) → [main.cpp:218-265](../src/main.cpp) | every pass | ESP32 | `lastActivityMs` not refreshed — but sleep gates are downstream and also blocked | +| Command drain | [main.cpp:406-423](../src/main.cpp) | every pass, ≤33 commands | ESP32 | n/a (it *is* the blocker) | +| `flushResponseQueueToBle()` | [main.cpp:421](../src/main.cpp), [:424](../src/main.cpp) | between every drained command + once per pass | ESP32 | ACKs stall → the client's PTO fires. Explicitly pre-flushed before the refresh at [display_service.cpp:2412](../src/display_service.cpp) to mitigate | +| `serviceBleDisconnectCleanup()` | [main.cpp:370](../src/main.cpp), [:428](../src/main.cpp) | every pass; deferred while `epdRefreshInProgress` | ESP32 | disconnect teardown deferred | +| `esp32_restart_ble_advertising()` | [main.cpp:372](../src/main.cpp), [:434](../src/main.cpp), [display_service.cpp:2439](../src/display_service.cpp) | on `bleRestartAdvertisingPending` | ESP32 | radio stays dark. Contains a hard `delay(100)` at [ble_init.cpp:241](../src/ble_init.cpp) | +| §2.1 direct-write watchdog | [main.cpp:436](../src/main.cpp) | every pass | ESP32 | deferred | +| `checkPartialWriteTimeout()` | [main.cpp:443](../src/main.cpp) | every pass | ESP32 | deferred | +| `handleWiFiServer()` | [main.cpp:447](../src/main.cpp) | every pass | ESP32+WiFi | LAN idle timeout and TLS handshake progress both stall | +| `serviceLanRoam()` | [wifi_service.cpp:856](../src/wifi_service.cpp), inside `handleWiFiServer()` | every pass; self-gated on `!wifiClient.connected() && !transferActive()` | ESP32+WiFi | roam deferred (by design) | +| WiFi link supervisor | [main.cpp:448-464](../src/main.cpp) | 10 s | ESP32+WiFi | link-loss detection deferred | +| `processButtonEvents()` | [main.cpp:481](../src/main.cpp), [:514](../src/main.cpp), [:527](../src/main.cpp), [:542](../src/main.cpp) | every pass (workInFlight branch) / every `idleDelay` chunk | both | **power-off hold cannot complete** — the user's only manual recovery is dead during a wedged refresh | +| `processTouchInput()` | [main.cpp:482](../src/main.cpp), [:515](../src/main.cpp), [:528](../src/main.cpp), [:543](../src/main.cpp) | ≥100 ms floor | both | touch dead (already suppressed during transfers on ESP32 by design) | +| MSD refresh | [main.cpp:509-513](../src/main.cpp) | 60 s, **idle branch only** | ESP32 | deferred | +| `ble_nrf_advertising_tick()` | [main.cpp:526](../src/main.cpp), [:540](../src/main.cpp) | every pass / every `idleDelay` chunk | nRF | advertising stays at the boosted 20–30 ms interval | +| `idleDelay(ms)` | [main.cpp:535-551](../src/main.cpp) | services the above every `CHECK_INTERVAL_MS = 100` ([:536](../src/main.cpp)) | both | n/a | + +**`idleDelay` call sites and their arguments:** `idleDelay(50)` in the post-wake advertising +window ([main.cpp:396](../src/main.cpp)); `idleDelay(5)` on the battery idle-hold path +([:495](../src/main.cpp)) and the USB idle path ([:507](../src/main.cpp)); on nRF, +`idleDelay(globalConfig.power_option.sleep_timeout_ms)` or `idleDelay(500)` +([main.cpp:519-525](../src/main.cpp)) — i.e. **on nRF the loop cadence is itself config-driven**, +up to 65 535 ms, though the 100 ms internal chunking keeps every tick above serviced. + +--- + +## 5. Unbounded waits inside this project + +This section is the inventory's most important half: the absence of a bound. + +### 5.1 `pwrmgmLockTake()` — infinite spin + +- **file:line:** [display_service.cpp:401-408](../src/display_service.cpp); the spin is + [:407](../src/display_service.cpp). +- **Target:** both (the cross-task hazard it exists for is nRF-specific: Bluefruit Callback task + vs. loop task). +- **Bound:** **none.** `while (__atomic_exchange_n(&pwrmgmLock, 1, ACQUIRE)) { delay(1); }` — it + yields (deliberately, per the comment at [:402-406](../src/display_service.cpp), to avoid + priority-inversion livelock) but never gives up. +- **Callers:** `epdSessionAcquire` ([:438](../src/display_service.cpp)), `epdSessionRelease` + ([:496](../src/display_service.cpp)), `epdSessionForceOff` + ([:514](../src/display_service.cpp)). +- **Held across:** panel I/O including `bbepSleep`, `bbepWakeUp`, `bbepSendCMDSequence`, plus a + raw `delay(50)` at [:430](../src/display_service.cpp) — *the busy-wait inside those is bounded + by library — not analyzed (out of scope)*. +- **Note:** `pwrmgmLockTryTake()` ([:409-411](../src/display_service.cpp)) is the safe variant and + is used only by `epdSessionTick` ([:520](../src/display_service.cpp)). The lock is a bare 0/1 + flag with **no owner field**, so a hung holder is indistinguishable from a busy one. + +### 5.2 `powerOff()` stuck-button spin + +- **file:line:** [power_latch.cpp:87-90](../src/power_latch.cpp). +- **Target:** ESP32. +- **Bound:** **none.** `while (digitalRead(buttonPin()) == LOW) { delay(20); }` — a shorted or + stuck-low button pin holds the device here forever, with the latch still engaged. +- **Context:** called from `powerButtonPoll()` on the loop task, i.e. this hangs `loop()`. + +### 5.3 FastEPD refresh — no firmware-side bound at all + +- **file:line:** [display_fastepd.cpp:228-231](../src/display_fastepd.cpp). +- **Target:** ESP32 with `OPENDISPLAY_FASTEPD` (IT8951 / E1004 class). +- **Bound:** `fastepd_wait_refresh()` discards its `timeout_sec` argument entirely and returns + `!s_init_failed`. `waitforrefresh(60)` short-circuits to it at + [display_service.cpp:749](../src/display_service.cpp), so **the 60 s cap does not exist on + these builds**. The real blocking lives in `fastepd_full_update()` + ([display_fastepd.cpp:222-226](../src/display_fastepd.cpp)) and `fastepd_direct_refresh` — + *bounded by library — not analyzed (out of scope)*; the firmware passes **no** timeout to + either. +- **Firmware call sites of the blocking work:** [display_service.cpp:1592](../src/display_service.cpp) + (boot), [display_service.cpp:2422-2423](../src/display_service.cpp) (direct refresh — the path + a real transfer takes), [display_service.cpp:3288](../src/display_service.cpp) + (`fastepd_partial_refresh`). + +### 5.4 Boot refresh is invisible to every `epdRefreshInProgress` gate + +- **file:line:** `refreshBootScreenFull()` [display_service.cpp:533-542](../src/display_service.cpp); + FastEPD boot path [display_service.cpp:1588-1594](../src/display_service.cpp). +- **Target:** both. +- **Bound:** the refresh itself is bounded (60 s, §2.3) or not (§5.3) — but **neither path sets + `epdRefreshInProgress`**. Verified: the flag is written only at + [display_service.cpp:2415/2436](../src/display_service.cpp) and + [:3284/3294](../src/display_service.cpp). So every reader — + [main.cpp:322](../src/main.cpp), [main.cpp:478](../src/main.cpp), + [ble_init.cpp:236](../src/ble_init.cpp) — mis-reads a 30–60 s boot refresh as idle. With the + §3.14 retry that window can double. + +### 5.5 Blocking sequences with no escape + +| Sequence | file:line | Blocking cost | +|---|---|---| +| `pwrmgm(true)` rail bring-up | [main.cpp:717-753](../src/main.cpp) | `delay(800)` + `delay(100)` (or `delay(200)` on FastEPD) ≈ **900 ms**, unconditional | +| `initBbepPanelSession()` | [display_service.cpp:341-354](../src/display_service.cpp) | `delay(200)` plus library init | +| `prepareEpdRailForBoot()` (nRF, battery) | [display_service.cpp:172-183](../src/display_service.cpp) | a full extra `pwrmgm` off/on cycle ≈ 950 ms | +| GT911 address-select + reset | [touch_input.cpp:250-269](../src/touch_input.cpp), [:317-339](../src/touch_input.cpp) | ~150 ms + up to 3 × 500 ms reset cycles | +| `esp32_restart_ble_advertising()` | [ble_init.cpp:241](../src/ble_init.cpp) | `delay(100)` | +| `updatemsdata()` advertising re-push | [display_service.cpp:1811](../src/display_service.cpp) | `delay(50)` | +| `directWriteFinishAndRefresh` pre-refresh settle | [display_service.cpp:2414](../src/display_service.cpp) | `delay(20)` | +| `enterDeepSleep()` teardown | [main.cpp:618](../src/main.cpp), [:621](../src/main.cpp), [:637](../src/main.cpp) | `delay(200) + delay(100) + delay(100)` | +| `reboot()` | [device_control.cpp:259-269](../src/device_control.cpp), [communication.cpp:720](../src/communication.cpp) | `delay(200)`/`delay(100)` before reset — terminal, so harmless | +| `checkResetPin()` | [encryption.cpp:875](../src/encryption.cpp), [:881](../src/encryption.cpp) | `delay(100)` ×2 in `setup()` | +| Button init settle | [device_control.cpp:782](../src/device_control.cpp) | `delay(10)` **per configured pin** — up to 320 ms with 32 buttons | +| `od_log_flush()` | [od_log.cpp:64](../src/od_log.cpp) | `delay(5)` on ESP32, per call | + +### 5.6 Terminal `while (1) {}` + +- **file:line:** [device_control.cpp:866](../src/device_control.cpp). +- **Target:** nRF only, inside the DFU-bootloader jump. +- **Bound:** none by design — control never returns; `bootloader_util_app_start()` has already + transferred execution. Not a freeze vector. + +### 5.7 Latched state with no timer of its own + +These are not loops, but they are unbounded in exactly the way that matters: + +| Latch | Set | Cleared | Bounded by | +|---|---|---|---| +| `pipeState.active` after a fatal NACK | [display_service.cpp:2568](../src/display_service.cpp) | only `resetPipeWriteState()` — reachable from disconnect ([main.cpp:347](../src/main.cpp)) or a new `0x0080` | **nothing** for a non-partial pipe: §2.1 keys on `directWriteActive`, which `sendPipeNack`→`cleanupDirectWriteState(true)` just cleared | +| `chunkedWriteState.active` | [communication.cpp:496](../src/communication.cpp) | completion / malformed / auth-fail only | **nothing** | +| `encryptionSession` after a BLE disconnect | — | nothing on BLE; LAN clears it at [wifi_service.cpp:804](../src/wifi_service.cpp) and [:879](../src/wifi_service.cpp) | **nothing** (survives into the next connection) | +| `directWriteTouchSuspended` / `s_epd_refresh_suspend` | [display_service.cpp:2136](../src/display_service.cpp), [:2799](../src/display_service.cpp), [:539](../src/display_service.cpp), [:1590](../src/display_service.cpp) → `touchSuspendForEpdRefresh()` [touch_input.cpp:116-117](../src/touch_input.cpp) | paired `touchResumeAfterEpdRefresh()` [touch_input.cpp:418-421](../src/touch_input.cpp) | **nothing** — an unpaired suspend leaves touch dead for the rest of the boot | +| `roamPending` | [wifi_service.cpp:606](../src/wifi_service.cpp) | `serviceLanRoam()` when idle | **nothing** — a permanently busy device never roams (deliberate) | +| `touch` `rt->disabled` | [touch_input.cpp:100](../src/touch_input.cpp) | only a full re-init | **nothing** — permanent for the boot | + +### 5.8 LED pattern with no global cap + +`processLedFlash()` ([device_control.cpp:524-535](../src/device_control.cpp)) advances a state +machine whose loop counts come from config. Unlike the buzzer there is **no +`kBuzzerMaxTotalMs` equivalent**, so a pathological pattern runs indefinitely. It never blocks +the loop (each step schedules a deadline and returns), so it is a nuisance rather than a freeze — +but it is genuinely unbounded. + +--- + +## 6. Config-driven values — consolidated + +| Timeout | Struct field | Type / range | 0 means | Firmware constant backing it | Consumer | +|---|---|---|---|---|---| +| EPD keep-alive | `power_option.screen_timeout_seconds` [opendisplay_structs.h:504](../include/opendisplay_structs.h) | `uint8_t`, `@min 0 @max 30` | power off immediately | clamp `EPD_KEEPALIVE_MAX_S = 30` [display_service.h:16](../src/display_service.h) | [display_service.cpp:383-395](../src/display_service.cpp) | +| Idle hold / advertising window | `power_option.sleep_timeout_ms` [opendisplay_structs.h:490](../include/opendisplay_structs.h) | `uint16_t` LE ms (max 65 535) | fall back to default | `DEFAULT_IDLE_HOLD_MS = 10000` [main.h:322](../src/main.h) | [main.cpp:375-377](../src/main.cpp), [:487-490](../src/main.cpp); also the nRF `idleDelay` argument [main.cpp:519-520](../src/main.cpp) | +| Minimum wake hold | `power_option.min_wake_time_seconds` [opendisplay_structs.h:503](../include/opendisplay_structs.h) | `uint16_t` LE s | fall back to default | `DEFAULT_MIN_WAKE_TIME_SECONDS = 120` [main.h:312](../src/main.h) | [main.cpp:197-200](../src/main.cpp) | +| Deep-sleep duration | `power_option.deep_sleep_time_seconds` [opendisplay_structs.h:499](../include/opendisplay_structs.h) | `uint16_t` LE s | deep sleep disabled | none | [main.cpp:583-585](../src/main.cpp), [:625-628](../src/main.cpp); overridable for one cycle by command `0x0053` | +| Encryption session lifetime | `securityConfig.session_timeout_seconds` [opendisplay_structs.h:916](../include/opendisplay_structs.h) | `uint16_t` LE s | **no timeout** (persists until disconnect) | none — the 0 case returns `true` directly | [encryption.cpp:221-232](../src/encryption.cpp) | +| Power-off hold (binary inputs) | `BinaryInputs.power_off_hold_sec` [opendisplay_structs.h:862](../include/opendisplay_structs.h) | `uint8_t` s | default 3 s | literal `3000u` [device_control.cpp:746](../src/device_control.cpp) → `ButtonState.power_off_hold_ms` [structs.h:181](../src/structs.h) | [device_control.cpp:78](../src/device_control.cpp) | +| Touch poll interval | `TouchController.poll_interval_ms` [opendisplay_structs.h:951](../include/opendisplay_structs.h) | `uint8_t` ms | header says 25 ms; **firmware uses 100** | `TOUCH_PROCESS_MIN_INTERVAL_MS = 100` [touch_input.cpp:38](../src/touch_input.cpp) | [touch_input.cpp:600](../src/touch_input.cpp) | +| LAN listener idle drop | — (protocol constant, not config) | — | — | `OD_LAN_READ_TIMEOUT_S = 30u` [opendisplay_protocol.h:984](../include/opendisplay_protocol.h) | [wifi_service.cpp:957](../src/wifi_service.cpp) | + +Everything not in this table is a compile-time firmware constant. + +Note the **two independent power-off-hold mechanisms**: `power_latch`'s fixed +`POWER_OFF_HOLD_MS = 3000` ([power_latch.cpp:21](../src/power_latch.cpp)), keyed on +`SystemConfig.pwr_pin_3`, and `device_control`'s config-driven per-instance hold. They share the +3 s default but not the source, and both can be active on one device. + +--- + +## 7. Analysis + +### 7.1 Coverage map + +Rows are failure conditions; cells name the mechanism that actually bounds them today. + +| Failure | nRF | ESP32 | Covered? | +|---|---|---|---| +| **Stalled direct-write transfer** (client silent, `directWriteActive` set) | *nothing* | §2.1, 15 min | Partial — nRF has no watchdog at all | +| **Stalled pipe transfer, no fatal NACK** | *nothing* | §2.1 via `directWriteActive` (set by `directWriteActivatePanel` for both legacy and pipe), 15 min | Partial | +| **Stalled pipe transfer after a fatal NACK** | *nothing* | *nothing* — `directWriteActive` cleared, `pipeState.active` latched | **GAP** | +| **Stalled partial write** | *nothing* | §2.2, 15 min | Partial | +| **Stalled config chunked write** | *nothing* | *nothing* | **GAP** (§3.9) | +| **Idle authenticated connection** (client connected, sending nothing) | *nothing* | *nothing* — `pollActivity` treats a live link as activity ([main.cpp:254](../src/main.cpp)), pinning the device awake | **GAP** | +| **Client flooding undecryptable frames post-session-clear** | *nothing* | *nothing* | **GAP** — every frame is answered `RESP_AUTH_REQUIRED` ([communication.cpp:664-670](../src/communication.cpp)) indefinitely | +| **Wedged panel (bb_epaper BUSY stuck)** | §2.3, 60 s | §2.3, 60 s | Yes | +| **Wedged panel (FastEPD / IT8951)** | n/a | *nothing* (§5.3) | **GAP** | +| **Wedged panel during the boot refresh** | 60 s wait, but invisible to every gate (§5.4) | same | Partial | +| **Hung I²C (GT911 holding SDA low)** | §3.13 disables the controller after 5 failures — but only if the transaction *returns* | same | **GAP** — no `Wire.setTimeOut`, no bus recovery | +| **Hung I²C (AXP2101 / SHT40 / BQ27220)** | *nothing* | *nothing* | **GAP** | +| **Dead radio — advertising stopped, never restarted** | `ble_nrf_advertising_tick` only re-starts after a boost expiry | *nothing* — `esp32_restart_ble_advertising` **clears** the pending flag when `getConnectedCount() > 0` ([ble_init.cpp:232-235](../src/ble_init.cpp)) | **GAP** | +| **Lost WiFi link** | n/a | §2.9, 10 s poll | Yes | +| **Silent LAN client** | n/a | §2.7, 30 s | Yes | +| **Stuck `pwrmgmLock`** | *nothing* (§5.1) | *nothing* | **GAP** | +| **Stuck power button (latch path)** | n/a | *nothing* (§5.2) | **GAP** | +| **Command ring full** | n/a (no ring) | drop-newest + log (§3.7); never flushed | Partial — recoverable, but stale commands survive a disconnect | +| **Response ring full** | n/a | drop-newest + log (§3.6); drained when disconnected | Yes | +| **Dead encryption session with a live link** | *nothing* | *nothing* | **GAP** — invisible to the client | +| **Encryption session expiring mid-transfer** | fires (§2.5) | fires (§2.5) | *Inverted* — the timer is itself the wedge | +| **CPU/peripheral hard hang** | *nothing* (no WDT armed) | *nothing* firmware-side | **GAP** — accepted per the plan's software-only decision | + +The shape of the gap: this firmware bounds **wall-clock transfer age** and **panel BUSY**, and +nothing else. There is no inactivity timer, no link-liveness timer, no I²C bound, no lock bound, +and on nRF no transfer watchdog whatsoever. + +### 7.2 Conflicts and nesting + +1. **§2.1/§2.2 (900 s) nest inside the drain and the refresh, not the other way round.** Both are + evaluated once per `loop()` pass at [main.cpp:436](../src/main.cpp)/[:443](../src/main.cpp), + *after* the command drain at [:406-423](../src/main.cpp). A single dispatched command can block + for a 60 s refresh, so the effective resolution of both watchdogs is one refresh, not one loop + pass. They cannot fire mid-refresh. Correct ordering, but it means "15 minutes" is really + "15 minutes, rounded up to the next loop pass." + +2. **§2.5 (encryption expiry) nests inside §2.1 and can fire during it.** Session expiry runs on + every command dispatch, so on a nonzero `session_timeout_seconds` shorter than 900 s it fires + *inside* the direct-write watchdog's window — and its action (`clearEncryptionSession`) does + **not** clear `directWriteActive`. The result is a transfer that is simultaneously "active" per + §2.1 and unable to accept a single further frame, for up to 15 minutes. + +3. **§3.2 (integrity ≥ 3) and §2.1 fight.** Same shape as (2): the session dies, the transfer + flag lives. §2.1 is the only thing that eventually releases the panel. + +4. **§3.11 (`sendPipeNack`) actively *disables* §2.1.** `cleanupDirectWriteState(true)` clears the + very flag §2.1 keys on, while leaving `pipeState.active` set. A fatal NACK therefore trades a + bounded wedge for an unbounded one. This is the single worst ordering inversion in the + inventory. + +5. **§2.7 (LAN 30 s) and the BLE disconnect path share one flag.** `disconnectWiFiServer()` sets + `bleDisconnectCleanupPending` ([wifi_service.cpp:812](../src/wifi_service.cpp)), so a LAN idle + timeout enters `serviceBleDisconnectCleanup()`. The only thing preventing it from tearing down + a live BLE transfer is the `ownerStillUp` guard at [main.cpp:328-338](../src/main.cpp) — + which is inside `#ifdef OPENDISPLAY_HAS_WIFI`, so on `esp32-N4` the guard does not exist at + all (there is also no LAN on that env, so the flag has no second raiser there today; the + hazard is latent and becomes live the moment anything else raises the flag). + +6. **§2.4 (keep-alive) vs. §2.12 (idle hold).** On battery ESP32, `enterDeepSleep()` calls + `epdSessionForceOff()` unconditionally ([main.cpp:609](../src/main.cpp)), so the effective + keep-alive is `min(screen_timeout_seconds, sleep_timeout_ms)`. With the defaults (keep-alive up + to 30 s, idle hold 10 s) the idle hold usually wins. The code comments at + [main.cpp:602-609](../src/main.cpp) document this deliberately; noting it because "30 s + keep-alive" is not what a battery device actually does. + +7. **§2.13 (min-wake) is checked twice, and the second check has an ordering constraint.** + `minWakeHoldActive()` is evaluated in `loop()` ([main.cpp:383](../src/main.cpp), + [:494](../src/main.cpp)) **and** inside `enterDeepSleep()` ([main.cpp:598](../src/main.cpp)). + The second check must stay above the advertising stop at [main.cpp:611-616](../src/main.cpp), + because everything past that point commits to `esp_deep_sleep_start()` — a late abort would + leave the device awake with the radio dark. The comment at [:594-597](../src/main.cpp) says so; + it is a real constraint on any future reordering. + +8. **§2.13/§2.12 have a side effect: `minWakeHoldActive()` mutates.** It clears + `minWakeWindowActive` when the window elapses ([main.cpp:205](../src/main.cpp)). Because it is + called from three sites, whichever one runs first consumes the transition and logs it. Benign + today (all three are on the loop task), but it is a query with side effects, same class of + defect as `isAuthenticated()`. + +9. **Two power-off holds, one device.** §2.22 (fixed 3 s, `power_latch`) and §2.23 (config, + default 3 s, `device_control`) both run from `processButtonEvents()` + ([device_control.cpp:586-587](../src/device_control.cpp)). If a device configures a + `BinaryInputs` power-off pin that is also `SystemConfig.pwr_pin_3`, both arm on the same press + with independent thresholds. + +10. **§2.15 (60 s MSD) vs. `onConnect`'s `msdUpdatePending`.** Both drive `updatemsdata()`, which + mutates the shared advertisement vector and re-pushes it with a `delay(50)` + ([display_service.cpp:1811](../src/display_service.cpp)). They are serialised only because + both run on the loop task — the `onConnect` callback deliberately defers rather than calling + inline ([esp32_ble_callbacks.h:53-56](../src/esp32_ble_callbacks.h)). Any future producer on + another task breaks this. + +11. **§3.5 (16-notify drain cap) vs. §3.6 (10-slot ring).** The drain cap exceeds the ring size, + so it is never the binding constraint; the binding one is `notify()` returning false. Not a + conflict, but the 16 is dead weight. + +### 7.3 Impact of the freeze-proofing plan + +Per existing mechanism, with the plan phase that touches it. + +| Existing mechanism | Verdict | Plan reference | +|---|---|---| +| §1.1 `-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120` | **DELETES** (or renames) | Phase 2 `[L3]` | +| §1.3 reset-reason logging | LEAVES | — | +| §2.1 direct-write 15-min watchdog | **MODIFIES** — kept as a backstop, raised to 20 min | Phase 6 `[H3]` | +| §2.2 `checkPartialWriteTimeout` | **MODIFIES** — same, 20 min | Phase 6 `[H3]` | +| §2.3 `waitforrefresh(60)` (bb_epaper) | LEAVES | — | +| §2.3 `waitforrefresh` → FastEPD stub | **MODIFIES** — implement a real IT8951 LUT-busy poll honouring `timeout_sec`; wrap `fastepd_direct_refresh` | Phase 2 `[X3]` | +| §2.4 EPD keep-alive | LEAVES | — | +| §2.5 encryption session timeout | **DELETES** — age-based expiry removed; session lifetime := connection lifetime | Phase 5 | +| §2.6 auth challenge 30 s | LEAVES | — | +| §2.7 LAN 30 s idle | LEAVES — the BLE/LAN asymmetry is called out as deliberate | Phase 7 | +| §2.8 `wifiClient.setTimeout(30000)` | LEAVES | — | +| §2.9 WiFi 10 s supervisor | LEAVES (but its `disconnectWiFiServer` path gains the owner guard) | Phase 4/5 `[H2]` | +| §2.10 blocking WiFi connect | LEAVES | — | +| §2.11 mDNS 400 ms throttle | LEAVES | — | +| §2.12 deep-sleep idle hold | LEAVES — explicitly stated as requiring no change; the 5-min BLE idle drop makes `connCount` fall so the existing hold elapses naturally | Phase 7 | +| §2.13 min-wake hold | LEAVES | — | +| §2.14 advertising window | LEAVES, but Phase 4 must skip `esp32_set_ble_connectable` during the post-wake window | Phase 4 | +| §2.15 60 s MSD cadence | LEAVES | — | +| §2.16 nRF advertising boost | LEAVES | — | +| §2.17 nRF link-diag one-shot | LEAVES | — | +| §2.18 buzzer 30 s cap + sequencing | LEAVES; `abortToKnownState` adds a stop call | Phase 3 | +| §2.19 LED sequencing | LEAVES; `abortToKnownState` adds a stop call. **The plan does not add a global LED cap** — `[X6]` notes it and defers | Phase 3 / review `X6` | +| §2.20 touch poll interval | LEAVES; `touchForceResume()` added alongside | Phase 3 `[M3]` | +| §2.22 `power_latch` 3 s hold | LEAVES (the *spin* it can reach is bounded — see §5.2 row) | Phase 2 | +| §2.23 config power-off hold | LEAVES | — | +| §2.24 ADC ladder poll | LEAVES | — | +| §2.25 sensor TTLs | LEAVES | — | +| §3.1 auth rate limit 10/60 s | LEAVES | — | +| §3.2 integrity ≥ 3 | **MODIFIES** — nonce failures no longer increment it; CCM-tag trigger stays and must now drop the link | Phase 1, Phase 5 | +| §3.3 nonce window ±32 / 64-ring | **MODIFIES** — symmetric ±128 with a 256-entry ring (fallback ±64); `replay_window_index` moved into the session; `counter_diff == 0` exemption removed; check/commit split around CCM | Phase 1 `[M1]` | +| §3.4 nRF notify retry ×4 | LEAVES | — | +| §3.5 16-notify drain cap | LEAVES | — | +| §3.6 response ring 10 | **MODIFIES** — adds an overflow flag serviced in loop, gated on `transferActive()`; adds a redundant `flushResponseQueue()` | Phase 7, Phase 3 (`[L1]`: the flush is redundant on ESP32) | +| §3.7 command ring 33/32 | **MODIFIES** — adds `flushCommandQueue()`, fixes the `main.h:365-370` off-by-one comment, optionally bumps to 34 off `esp32-N4`. Overflow **keeps** drop-newest; it does not drop the link | Phase 7 `[H1]` | +| §3.8 drain count cap | **MODIFIES** — adds a 2 s wall-clock cap alongside; adds the drain-abort check between [main.cpp:415](../src/main.cpp) and [:416](../src/main.cpp) | Phase 2, Phase 3 `[M5]` | +| §3.9 config chunked write | **SUBSUMES** — no timer of its own; covered by the supervisor predicate, and `resetChunkedWriteState()` is added | Phase 6 `[X4]` | +| §3.11 pipe NACK latch | **MODIFIES** — `error_since_ms` added to `PipeWriteState` + a 10 s `pipeErrorTick()` hardware-release deadline | Phase 5 `[L2]` | +| §3.12 LAN 16 KB drain budget | LEAVES | — | +| §3.13 GT911 retries / disable | **MODIFIES** — `Wire.setTimeOut(25)` after every `Wire.begin()` plus a nine-clock SDA recovery keyed on `i2c_fail_streak` | Phase 2 `[X1]` | +| §3.14 boot-refresh retry | LEAVES, but §5.4's flag fix makes it visible | Phase 2 `[X2]` | +| §5.1 `pwrmgmLockTake` infinite spin | **MODIFIES** — returns `bool` with a **60 s** deadline, fail-closed, no steal | Phase 2 `[C2]` | +| §5.2 `powerOff` stuck-button spin | **MODIFIES** — 10 s bound, then drop the latch anyway | Phase 2 | +| §5.3 FastEPD unbounded refresh | **MODIFIES** — see §2.3 row | Phase 2 `[X3]` | +| §5.4 boot refresh invisible | **MODIFIES** — set/clear `epdRefreshInProgress` around both boot paths | Phase 2 `[X2]` | +| §5.7 `encryptionSession` surviving disconnect | **MODIFIES** — cleared on BLE disconnect (deferred on nRF behind the in-flight depth counter) | Phase 5 `[H4]` | +| §5.7 `directWriteTouchSuspended` unpaired | **MODIFIES** — `touchForceResume()` clears it and asserts the counter reached 0 | Phase 3 `[M3]` | +| §5.7 `roamPending`, `rt->disabled` | LEAVES | — | +| §5.8 LED unbounded pattern | LEAVES | review `X6` | +| §2.12 `workInFlight` gate | **MODIFIES** — `transferActive()` added to the disjunction | Phase 6 `[X5]` | +| `esp32_restart_ble_advertising` clearing the flag on a stale count | **MODIFIES** — `advertisingHealthTick()` at 30 s | Phase 4 `[X7]` | + +**New mechanisms the plan introduces** (for completeness, so a future reader can tell them from +the existing set): the 10-minute progress supervisor (`g_lastProgressMs`, 600 000 ms, Phase 6); +the 5-minute BLE idle disconnect (`OD_BLE_IDLE_DISCONNECT_MS = 300000`, Phase 7); the 10 s pipe +error-release deadline (Phase 5); the 60 s `pwrmgmLock` deadline and the 10 s `powerOff` bound +(Phase 2); the 2 s drain cap (Phase 2); the 30 s advertising health tick (Phase 4); the ~10 s +idle-incumbent eviction threshold (Phase 4 `[M2]`). + +**Silent conflicts to watch:** + +- **The plan's 20-minute backstop must sit above the supervisor's 10 minutes, and both above the + 60 s lock deadline.** As written the hierarchy is 10 s (pipe error) < 60 s (lock, refresh) < + 300 s (BLE idle) < 600 s (supervisor) < 1200 s (backstops). That is consistent — but note the + 20-minute backstops key on a *start* stamp while the supervisor keys on *progress*, so on a slow + legitimate transfer the backstop can fire first. A 20-minute upload is not achievable on any + current panel, so this is theoretical; it stops being theoretical if E1004 payload sizes grow. +- **The 5-minute BLE idle timer and the 60 s `pwrmgmLock` deadline can overlap.** A client + connected but silent for 5 minutes while another context holds `pwrmgmLock` will be dropped by + the idle timer; `abortToKnownState`'s `epdSessionForceOff()` then needs the lock it cannot get, + and must honour the new fail-closed return rather than spinning. +- **Phase 5 disables §2.5 but §2.6 (the 30 s challenge validity) stays.** They are different + timers on the same subsystem; the plan does not mention §2.6 at all. It is correct to leave it, + but the plan's "encryption is scoped to the life of the connection and nothing else" sentence + reads as if no encryption-side timer remains. One does. +- **The plan touches `esp32_restart_ble_advertising` (Phase 4 `[X7]`) but not the `delay(100)` at + [ble_init.cpp:241](../src/ble_init.cpp)**, which now runs on every health-tick-forced restart. +- **`abortToKnownState`'s buzzer/LED stop has no counterpart bound.** §5.8 stays unbounded; the + abort only stops a sequence that is already running when the abort fires. + +### 7.4 Numbers summary — shortest to longest + +Every firmware-controlled duration, sorted. Blocking `delay()`s under 10 ms are omitted. + +| Duration | Mechanism | Where | +|---|---|---| +| 500 µs | GT911 I²C retry gap | [touch_input.cpp:36](../src/touch_input.cpp) | +| 1 ms | `pwrmgmLockTake` spin yield | [display_service.cpp:407](../src/display_service.cpp) | +| 1 ms | `workInFlight` loop yield | [main.cpp:484](../src/main.cpp) | +| 5 ms | ADC ladder poll floor | [device_control.cpp:96](../src/device_control.cpp) | +| 5 ms | nRF notify retry gap | [communication.cpp:347](../src/communication.cpp) | +| 5 ms | `od_log_flush` settle (ESP32) | [od_log.cpp:64](../src/od_log.cpp) | +| 5 ms | buzzer duration unit | [buzzer_control.cpp:15](../src/buzzer_control.cpp) | +| 5 ms | `idleDelay` argument, idle paths | [main.cpp:495](../src/main.cpp), [:507](../src/main.cpp) | +| 10 ms | `waitforrefresh` poll interval | [display_service.cpp:761](../src/display_service.cpp) | +| 10 ms | per-button init settle | [device_control.cpp:782](../src/device_control.cpp) | +| 12 ms | SHT40 measurement wait | [sensor_sht40.cpp:16](../src/sensor_sht40.cpp) | +| 20 ms | buzzer inter-pattern gap | [buzzer_control.cpp:16](../src/buzzer_control.cpp) | +| 20 ms | pre-refresh settle | [display_service.cpp:2414](../src/display_service.cpp) | +| 20 ms | `powerOff` stuck-button poll | [power_latch.cpp:88](../src/power_latch.cpp) | +| 20–30 ms | nRF boosted advertising interval | [ble_init.cpp:42-43](../src/ble_init.cpp) | +| 50 ms | `idleDelay` argument, post-wake window | [main.cpp:396](../src/main.cpp) | +| 50 ms | advertising re-push settle | [display_service.cpp:1811](../src/display_service.cpp) | +| 50 ms | `epdSessionForceOffLocked` post-sleep | [display_service.cpp:430](../src/display_service.cpp) | +| 80 ms ×3 | power-off buzzer alert | [buzzer_control.cpp:369-373](../src/buzzer_control.cpp) | +| 100 ms | `idleDelay` internal chunk | [main.cpp:536](../src/main.cpp) | +| 100 ms | touch process floor / poll fallback | [touch_input.cpp:38](../src/touch_input.cpp) | +| 100 ms | touch I²C fail backoff | [touch_input.cpp:37](../src/touch_input.cpp) | +| 100 ms | LED delay factor | [device_control.cpp:279](../src/device_control.cpp) | +| 100 ms | advertising restart settle | [ble_init.cpp:241](../src/ble_init.cpp) | +| 160–1000 ms | nRF steady advertising interval | [ble_init.cpp:40-41](../src/ble_init.cpp) | +| 200 ms | GT911 post-reset settle | [touch_input.cpp:29](../src/touch_input.cpp) | +| 200 ms | panel init settle | [display_service.cpp:346](../src/display_service.cpp), [:354](../src/display_service.cpp) | +| 300 ms | GT911 pre-reset delay | [touch_input.cpp:30](../src/touch_input.cpp) | +| 400 ms | mDNS MSD TXT throttle | [wifi_service.cpp:378](../src/wifi_service.cpp) | +| 500 ms | nRF link-diag one-shot | [ble_init.cpp:141](../src/ble_init.cpp) | +| 500 ms | WiFi connect inner poll | [wifi_service.cpp:769](../src/wifi_service.cpp) | +| ~900 ms | `pwrmgm(true)` rail bring-up | [main.cpp:721](../src/main.cpp) + [:749](../src/main.cpp) | +| 2 000 ms | WiFi inter-retry delay | [wifi_service.cpp:786](../src/wifi_service.cpp) | +| 3 000 ms | nRF advertising boost | [ble_init.cpp:44](../src/ble_init.cpp) | +| 3 000 ms | power-off hold (latch, fixed) | [power_latch.cpp:21](../src/power_latch.cpp) | +| 3 000 ms | power-off hold default (config) | [device_control.cpp:746](../src/device_control.cpp) | +| 5 000 ms | ADC ladder press-count window | [device_control.cpp:193](../src/device_control.cpp) | +| 10 000 ms | WiFi link supervisor poll | [main.cpp:449](../src/main.cpp) | +| 10 000 ms | WiFi connect timeout per retry | [wifi_service.cpp:763](../src/wifi_service.cpp) | +| 10 000 ms | `DEFAULT_IDLE_HOLD_MS` | [main.h:322](../src/main.h) | +| ≤30 000 ms | EPD keep-alive (`EPD_KEEPALIVE_MAX_S`) | [display_service.h:16](../src/display_service.h) | +| 30 000 ms | buzzer total playback cap | [buzzer_control.cpp:17](../src/buzzer_control.cpp) | +| 30 000 ms | auth challenge validity | [encryption.cpp:604](../src/encryption.cpp) | +| 30 000 ms | LAN idle drop (`OD_LAN_READ_TIMEOUT_S`) | [opendisplay_protocol.h:984](../include/opendisplay_protocol.h) | +| 30 000 ms | `wifiClient.setTimeout` | [wifi_service.cpp:888](../src/wifi_service.cpp) | +| 30 000 ms | sensor / battery read TTLs | [sensor_sht40.cpp:239](../src/sensor_sht40.cpp), [sensor_bq27220.cpp:142](../src/sensor_bq27220.cpp), [display_service.cpp:1707](../src/display_service.cpp) | +| 60 000 ms | auth rate-limit window (10 attempts) | [encryption.cpp:570](../src/encryption.cpp) | +| 60 000 ms | MSD refresh cadence | [main.cpp:510](../src/main.cpp) | +| 60 000 ms | `waitforrefresh` cap (bb_epaper only) | [display_service.cpp:760](../src/display_service.cpp) with `timeout = 60` | +| ≤65 535 ms | `sleep_timeout_ms` idle hold / advertising window; nRF `idleDelay` argument | [opendisplay_structs.h:490](../include/opendisplay_structs.h) | +| 120 000 ms | `DEFAULT_MIN_WAKE_TIME_SECONDS` | [main.h:312](../src/main.h) | +| ≤65 535 s | `min_wake_time_seconds`, `deep_sleep_time_seconds`, `session_timeout_seconds` | config | +| 900 000 ms | direct-write watchdog | [main.cpp:438](../src/main.cpp) | +| 900 000 ms | partial-write watchdog | [display_service.cpp:580](../src/display_service.cpp) | +| ∞ | `pwrmgmLockTake`, `powerOff` button spin, FastEPD refresh, pipe NACK latch, chunked-write latch, encryption session across a BLE disconnect | §5 | + +The visible hierarchy: everything the firmware bounds today is either **under a minute** (panel, +LAN, radio, sensors) or **at fifteen minutes** (transfers). There is nothing in between — which +is precisely the band the plan's 5-minute idle and 10-minute supervisor are meant to fill. + +--- + +## 8. Corrections to existing docs + +Verified against source; each of these is wrong or imprecise in the plan or the review. + +1. **"Every ESP env passes `-DCONFIG_FREERTOS_WATCHDOG_TIMEOUT_S=120`" — correct, but only via + inheritance.** Review `L3` cites `platformio.ini:189`. Two envs + (`esp32-s3-E1004` [:156](../platformio.ini), `esp32-s3-N16R8-extuart-debug` + [:278](../platformio.ini)) do not list the flag; they inherit it through + `${env:.build_flags}`. The conclusion (all ten ESP envs, flag inert) holds — but a + `grep` for the flag returns nine lines, not ten, and anyone deleting it must check the + inheriting envs still compile. + +2. **The plan's `[L3]` says "the real TWDT is 5 s/panic on IDLE0."** That claim is sourced from + the precompiled `sdkconfig.h`, which is out of scope for this inventory. What is verifiable + from inside this repo: **the firmware arms nothing** (`grep -rn "esp_task_wdt" src/` → no + hits), so whatever the platform default is, this codebase neither sets it nor feeds it. State + it that way rather than quoting a framework value. + +3. **The buzzer's global cap is 30 s, not 5 s.** `kBuzzerMaxTotalMs = 30000u` + ([buzzer_control.cpp:17](../src/buzzer_control.cpp)), but the in-code comments at + [buzzer_control.cpp:167](../src/buzzer_control.cpp) ("Global 5 s cap") and + [:144](../src/buzzer_control.cpp) ("for the 5 s total cap") both say 5 s. Neither the plan nor + the review mentions the buzzer cap, so this is a source-comment defect rather than a doc + defect — flagged here because `[X6]` claims "nothing bounds a stuck `buzzer_control` + sequence," which is **wrong**: a 30 s cap exists. What is genuinely unbounded is the **LED** + sequence (§5.8), not the buzzer. + +4. **The nRF link-diag timer literal is 500, not 2500.** [ble_init.cpp:141](../src/ble_init.cpp) + passes `500`; the comment on the next line ([:144](../src/ble_init.cpp)) and the block comment + at [:82](../src/ble_init.cpp) both say "~2.5 s later". One of the two is wrong. Neither + plan nor review covers this; it is diagnostics-only, so the consequence is a misleading log + phase label, not a freeze. + +5. **`TouchController.poll_interval_ms`'s documented default is not the firmware's fallback.** + The header says `0 = default 25 ms` + ([opendisplay_structs.h:951](../include/opendisplay_structs.h)); the firmware falls back to + `TOUCH_PROCESS_MIN_INTERVAL_MS` = **100 ms** ([touch_input.cpp:600](../src/touch_input.cpp)). + Even an explicit 25 is floored to 100 by the global gate at + [touch_input.cpp:589-592](../src/touch_input.cpp), so **the config field cannot produce a poll + faster than 100 ms on this firmware**. Not mentioned anywhere; relevant to `[X1]`, which + assumes touch polling is frequent enough to detect an I²C wedge quickly. + +6. **Review `L4`'s line-drift note is itself slightly off.** It corrects + `communication.cpp:113-116` → `:117-121` for "the ring-full check". The ring-full *check* is + [communication.cpp:113-116](../src/communication.cpp) (`nextHead == responseQueueTail` at `:113`, the + `od_log_error` at `:114`); `:117-121` is the memcpy/enqueue that follows. Both the plan's + original and the review's correction point at roughly the right block; neither is exact. + +7. **Review `L4` says `display_fastepd.cpp:222-231` "lands on `fastepd_full_update` at + `:227-231`".** `fastepd_full_update` is [display_fastepd.cpp:222-226](../src/display_fastepd.cpp); + `fastepd_wait_refresh` is [:228-231](../src/display_fastepd.cpp). The two are transposed. The + substantive finding (`X3`) is correct. + +8. **Plan `[H3]` describes `checkPartialWriteTimeout` as living at + `display_service.cpp:578-587`** — correct — **but the plan's §Context item 2 implies it is the + only bound on a pipe transfer.** It bounds `partialCtx` only; for a *non-partial* pipe transfer + after a fatal NACK there is genuinely nothing, which the review states correctly under + "Verified CORRECT" item 3. The plan text should not be read as offering partial coverage there. + +9. **Neither document inventories the second power-off hold.** `power_latch`'s fixed + `POWER_OFF_HOLD_MS = 3000` ([power_latch.cpp:21](../src/power_latch.cpp)) is entirely separate + from `BinaryInputs.power_off_hold_sec`. Phase 2 bounds the `powerOff()` spin + ([power_latch.cpp:87-90](../src/power_latch.cpp)) but says nothing about the fact that the + `device_control` path reaches `powerLatchTriggerOff()` + ([device_control.cpp:80](../src/device_control.cpp)) rather than `powerOff()`, so the two + shutdown routes have different blocking profiles. + +10. **Neither document mentions that the *entire* recovery surface depends on `loop()`.** Every + bound in §2 and §3 except `waitforrefresh` and the nRF notify retry is evaluated from + `loop()` or `idleDelay()`. On ESP32 that includes `processButtonEvents()`, so during a wedged + blocking refresh **even the physical power-off hold does not work**. This matters for the + plan's "residual risk: recoverable by power cycle" claim — on a latching device, the power + button is itself software-mediated ([device_control.cpp:60-84](../src/device_control.cpp), + [power_latch.cpp:124-144](../src/power_latch.cpp)) and is not a recovery path while the loop + task is blocked. From d7308b8bc7f5df5307e4e68e33593bf1d759d30d Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:58:19 -0400 Subject: [PATCH 11/11] docs(nonce): record the Decision A reversal; restore the surviving citations d65696f brought the Phase 1 planning doc onto this branch. It had been absent from every branch, so aef3a6b stripped the citations pointing at it as dangling. Now that it exists they belong back -- but not all of them, because aef3a6b reversed one of the decisions it records. Doc: Decision A ("forward cap 128") is struck through and marked REVERSED, with a pointer to a new section at the end that carries the argument -- what the cap did (nothing commits, so last_seen never advances, and each retransmission's fresh higher counter is rejected further out than the last), why no number could have been safe (the ceiling is the client's retransmit budget scaled by a user-facing HA option, and it accumulates across aborted attempts), why removing it costs nothing (the counter is inside the CCM nonce; commit is post-tag; the session id is cleartext so the DoS argument never held), and what replaced it. A table records the effect on every other item: B, C, D and E stand; Step 6's "write the mechanism, not a number" is now literal; Step 5's hardware tests 1/2/2c are obsolete as written, since they existed to validate the 128. Worth noting the doc had already flagged OD_NONCE_FORWARD_CAP as "unvalidated against a real link" in its own unverified-on-hardware list. The flaw was found by analysis rather than by the bench it was waiting on, so that list is unchanged by this reversal. Code: restored the citations that still hold -- Decision D at nonce_window.h and the CI job and the host test, Decision B for the shifting-bitmap representation (unchanged; only the arithmetic over it moved), Step 1 at encryption.cpp and encryption_state.h. The two sites that used to cite Decision A now cite the reversal instead, so a reader lands on the argument that supersedes it rather than on the superseded one. Comments only; no behaviour change. 47445 host checks pass; nrf52840custom builds. --- .github/workflows/main.yaml | 3 +- ..._BLE_IDLE_SESSION_DISCONNECT_2026-07-29.md | 686 ++++++++++++++++++ docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md | 108 ++- src/communication.cpp | 4 + src/encryption.cpp | 2 +- src/encryption_state.h | 3 +- src/nonce_window.h | 10 +- tools/test_nonce_window.cpp | 3 +- 8 files changed, 812 insertions(+), 7 deletions(-) create mode 100644 docs/PLAN_BLE_IDLE_SESSION_DISCONNECT_2026-07-29.md diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index ab2d211..1376122 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -21,7 +21,8 @@ jobs: - name: Build and run the nonce-window host test # -fsanitize=undefined is not decoration: it is what catches the `x << 64` # UB in the bitmap shift automatically rather than relying on the test - # author to predict it. + # author to predict it. See Decision D in + # docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. run: | g++ -std=c++17 -Wall -Wextra -Werror -O1 -fsanitize=undefined,address \ tools/test_nonce_window.cpp -o /tmp/test_nonce_window diff --git a/docs/PLAN_BLE_IDLE_SESSION_DISCONNECT_2026-07-29.md b/docs/PLAN_BLE_IDLE_SESSION_DISCONNECT_2026-07-29.md new file mode 100644 index 0000000..72b1cc8 --- /dev/null +++ b/docs/PLAN_BLE_IDLE_SESSION_DISCONNECT_2026-07-29.md @@ -0,0 +1,686 @@ +# Plan — idle-session watchdog: disconnect a BLE client that holds the link doing nothing + +**Date:** 2026-07-29 +**Branch:** `fix/nrf-no-adv-while-connected` (or a fresh `feat/ble-idle-session-kick` off it) +**Scope:** one new transport method, one new loop()-serviced watchdog, one tunable +(`OD_BLE_IDLE_SESSION_TIMEOUT_MS`, default 120 s). Five files, ~46 inserted lines, +no existing line modified. No protocol change, no config-packet change, no +cross-repo work. + +### Revision history + +**Draft 1** proposed the watchdog with `pollActivity()`-style RX/TX queue-head +tracking. **Draft 2** removed that tracking and raised the timeout 60 s → 120 s, +arguing the extra margin made it unnecessary. **Draft 3** reinstated the tracking +after an adversarial review (Codex, 2026-07-29) showed Draft 2 would disconnect +working clients. **Draft 4** replaced queue-head diffing with a direct activity stamp at the BLE +callback and notify sites, and fixed the idle definition to exactly "no command +received, no response sent, queues empty" (§2) — dropping `transferActive()` and +`epdRefreshInProgress`, the latter unobservable from `loop()` in any case. +**Draft 5 — this one — stamps on RX only** (§3e): every response is generated inside +a command handler, so a TX stamp is redundant except for the delayed post-refresh +ACK, where firing early is defensible and now documented. Checking that surfaced a +hole present in every earlier draft — a connected client that never enables its CCCD +leaves a response queued forever and was permanently immune to the kick, fixed by +the `&& ble.notifyReady()` qualifier in §3b. + +The review findings that shaped Drafts 3–4, both verified against the code: + +- *Draft 2's watchdog would kick clients that are actively working.* Its stamp + condition only reads whether the queues are **still** non-empty at the end of a + pass, and by then they never are — `serviceBleRx()` flushes TX after every + command ([main.cpp:534](../src/main.cpp#L534)) and `serviceBleTx()` runs again at + [:671](../src/main.cpp#L671), both before the watchdog's position at + [:697](../src/main.cpp#L697). `bleRxQueuePending()`/`bleTxQueuePending()` are + plain head≠tail tests ([command_queue.cpp:151-153](../src/command_queue.cpp#L151-L153), + [:186-188](../src/command_queue.cpp#L186-L188)), so a command that arrives *and* + drains inside one pass leaves no trace. A client sending a command every 30 s + would have been disconnected 120 s after connecting. Head-change tracking is not + an optimisation; it is the only thing that observes BLE traffic at all. +- *The 60 s refresh bound the simplification rested on does not exist.* This repo's + own audit shows `waitforrefresh()`'s real wall-clock bound is **~126 s**, because + `bbepIsBusy()` adds `delay(10) + delay(1)` inside each of the 6000 iterations + ([FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md §B1](FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md), + loop at [display_service.cpp:811-825](../src/display_service.cpp#L811-L825)). + Draft 2 cited the `timeout = 60` argument and treated it as seconds of wall + clock. §B2 of the same audit adds up to ~54 s of notify-semaphore waiting in a + single drain pass, independently of any refresh. + +Three further review findings are adopted as amendments (§3a, §3d, §6.1); one is +recorded as an accepted breakage (§3c). Findings that did **not** survive checking +are listed in §7. + +--- + +## 1. Purpose + +**An idle client holding the connection locks every other caller out of the +device.** That is what this change exists to fix; everything else in this section +is either evidence for it or a side benefit. + +The device serves one BLE client at a time and stops advertising for the duration, +so a peer that connects and then does nothing is not merely wasting its own +session — it is denying the device to Home Assistant, to the CLI, and to any other +host, for as long as it chooses to stay attached. Nothing currently bounds that. +The three timers that look like they might, do not: + +| Existing timer | What it actually does | Why it does not cover this | +|---|---|---| +| `TRANSFER_WATCHDOG_MS` = 15 min ([display_service.cpp:582](../src/display_service.cpp#L582), run from [main.cpp:697](../src/main.cpp#L697)) | Tears down a stuck DIRECT/PIPE/PARTIAL transfer | Frees transfer state only. The link survives, and it never fires for a client that connected and sent nothing. | +| `securityConfig.session_timeout_seconds` ([encryption.cpp:221-232](../src/encryption.cpp#L221-L232)) | Clears the encryption session | Clears crypto state only; no disconnect. Also `0` = never. | +| `OD_LAN_READ_TIMEOUT_S` = 30 s ([opendisplay_protocol.h:984](../include/opendisplay_protocol.h#L984), enforced at [wifi_service.cpp:949-956](../src/wifi_service.cpp#L949-L956)) | Drops an idle **LAN** client | LAN transport only. BLE has no equivalent. | + +So a peer that connects and then goes quiet — a crashed host, a BlueZ connection +the supervising process abandoned, a scanner app left open on a phone, an +`animate.py` run someone Ctrl-Z'd — holds the device hostage until *its* side gives +up, which may be never. + +### 1a. The lockout, mechanically + +Advertising stops for the whole connection, on both targets, by design and now by +explicit guard: nRF at [ble_transport_nrf.cpp:267](../src/ble_transport_nrf.cpp#L267) +(one peripheral role slot from `Bluefruit.begin(1, 0)`, so `sd_ble_gap_adv_start()` +returns `NRF_ERROR_CONN_COUNT`), ESP32 at +[ble_transport_esp32.cpp:283](../src/ble_transport_esp32.cpp#L283). Both make +`setManufacturerData()` return false while connected. + +A second caller therefore cannot even find the device, let alone connect: it is not +advertising, so a scan does not list it and a connect-by-address has nothing to +answer it. From the other host's point of view the tag is simply gone. For HA that +surfaces as delivery failures against a device that is powered, in range and +healthy — and its per-operation `async with OpenDisplayDevice(...)` pattern +(`delivery.py:319-340`) gives it no way to queue behind the squatter; each attempt +just fails. + +Two further consequences of the same suppression, for the whole time the idle peer +stays attached: `updatemsdata()` cannot publish, so battery/temperature/button/touch +state in the advertisement is frozen at whatever it was when the session began, and +any advertising-interval boost armed by a button press cannot be restored (the +mechanism documented at length in +[PLAN_NRF_NO_ADV_WHILE_CONNECTED_2026-07-29.md](PLAN_NRF_NO_ADV_WHILE_CONNECTED_2026-07-29.md)). + +### 1b. Side benefit: on ESP32 battery targets it also pins the tag awake + +`pollActivity()` stamps `lastActivityMs` on **every pass while `connCount > 0`** +([main.cpp:366-368](../src/main.cpp#L366-L368)) — a live link is treated as +activity in itself, deliberately. `platformIdle()`'s deep-sleep branch requires a +quiet window since that stamp ([main.cpp:599-607](../src/main.cpp#L599-L607)), and +`workInFlight` includes `ble.isConnected()` ([main.cpp:742](../src/main.cpp#L742)), +so the loop takes the `delay(1)` arm forever. + +An idle connected client therefore prevents deep sleep for as long as it stays +connected. On a battery tag that is the difference between microamps and +milliamps — the most expensive thing an unauthenticated peer can currently do to +the device without sending a byte. + +This is a **secondary** motive, and it is worth being explicit about the ordering: +if the lockout in 1a did not exist, the power cost alone would not justify +disconnecting a client that might have a reason to be there. It is the exclusivity +that makes an idle session everyone else's problem. Consequences for the design: + +- The kick must **restore advertising**, not merely free the radio — which it does + via the existing `serviceBleAdvertisingRestart()` path (§3a), on the target where + the stack does not re-arm by itself. +- The timeout is best read as *"how long may one caller lock everyone else out?"*, + not *"how long may a client be lazy?"* — see §3c. +- A future alternative that fixes 1a without disconnecting anyone (advertising + while connected, so a second caller can queue) would supersede the whole + approach; §7.4 records why that is not available today. + +--- + +## 2. What "idle" must mean + +**Definition (decided 2026-07-29): a session is idle when no command has been +received, no response has been sent, and both queues are empty.** Nothing else. + +Activity is observed **directly at the BLE receive callback**, not inferred from +per-pass queue-head snapshots (§3e explains why that is both simpler and strictly +more accurate): + +| Clause | Observed at | Term | +|---|---|---| +| no command received | `bleRxQueuePush()` ([command_queue.cpp:50](../src/command_queue.cpp#L50)) — the single RX ingress, called only from the two stack callbacks ([nrf:155](../src/ble_transport_nrf.cpp#L155), [esp32:147](../src/ble_transport_esp32.cpp#L147)) | `millis() - bleLastRxMs()` | +| no response sent | **not stamped** — every response is generated inside a command handler, so it is already implied by the RX stamp. See §3e "why TX is not stamped" for the one exception and why it is accepted | — | +| queues empty | `bleRxQueuePending() \|\| (bleTxQueuePending() && ble.notifyReady())` | see §3e "the unsubscribed-client hole" | +| (not a session) | `ble.isConnected()` | as-is | + +**Transfer and refresh state are deliberately NOT terms.** + +- `epdRefreshInProgress` would be dead code. Both assignment pairs bracket + straight-line blocking work with no return to `loop()` + ([display_service.cpp:2452-2473](../src/display_service.cpp#L2452-L2473), + [:3346-3356](../src/display_service.cpp#L3346-L3356)), so a loop()-level check + can never observe it true. Its intended coverage — the blocking dead zone, now + known to reach ~126 s — is already handled: the command that triggered the + refresh advanced `rxHead` in the pass that blocks, and its response advanced + `txHead`, so the pass that resumes stamps regardless of duration. +- `transferActive()` is dropped as a *policy* choice, and it changes behaviour + versus earlier drafts: **a mid-transfer session whose client has gone silent for + the full window is now kicked**, rather than waiting out the 15-minute transfer + watchdog. That is the intent of an idle timer — a stalled transfer holds the + radio, the panel rail and (on ESP32) wakefulness, and it cannot progress without + the client. Teardown is not new code: the disconnect raises + `s_disconnectCleanupPending`, and `serviceBleDisconnectCleanup()` + ([main.cpp:388-423](../src/main.cpp#L388-L423)) already handles exactly this case + for a peer-initiated drop — `cleanupDirectWriteState(true)`, + `cleanupPartialWriteOnDisconnect()`, `resetPipeWriteState()`, behind the + `ownerStillUp` guard. The 15-minute watchdog remains as the backstop for a + transfer abandoned by a client that *stays* connected and chatty. + +What this buys: the kick still cannot interrupt a command mid-dispatch (it runs +between passes, never inside a handler), and it can no longer be indefinitely +suppressed by stuck state — which is what `transferActive()` as a busy term would +have done, since a wedged `pipeState.active` would have pinned the stamp forever. + +--- + +## 3. Design + +### 3a. `BleTransport::disconnect()` — new seam method + +Declared beside the other lifecycle calls in +[ble_transport.h](../src/ble_transport.h), documented as **asynchronous**: the +stack's disconnect callback fires later, so `takeDisconnectedEvent()` and the +existing `serviceBleDisconnectCleanup()` / `serviceBleAdvertisingRestart()` path +own teardown and the advertising re-arm. A caller must never pair this with +inline cleanup. + +**Signature: `bool disconnect()`**, not `void` (review amendment). Both stacks +report failure and the earlier `void` seam would have swallowed it: NimBLE returns +false when `ble_gap_terminate()` rejects the request (`NimBLEServer.h:66`, +`NimBLEServer.cpp:315-332`) and Bluefruit propagates the SoftDevice status +(`bluefruit.cpp:625-635` → `BLEConnection.cpp:206`). Contract: **true** = request +accepted *or* already disconnected (nothing to do), **false** = the stack refused +and the link is still up. On false the watchdog retries in ~1 s instead of logging a +disconnect that never happened and then sitting out another full window. + +- **nRF:** `Bluefruit.disconnect(s_connHandle)` — `bluefruit.h:171` → + `BLEConnection::disconnect()` → `sd_ble_gap_disconnect(hdl, + BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION)` (`BLEConnection.cpp:206`). + Guard on the file-static `s_connHandle` rather than `Bluefruit.connHandle()`: + the disconnect callback invalidates it ([ble_transport_nrf.cpp:134](../src/ble_transport_nrf.cpp#L134)), + so a stale handle already means "gone". No advertising calls here — + `restartOnDisconnect(true)` ([:210](../src/ble_transport_nrf.cpp#L210)) re-arms + the radio, which is what `restartsAdvertisingOnDisconnect()` reports. +- **ESP32:** `s_server->disconnect(s_connHandle)` — + `NimBLEServer.h:66`, default reason `BLE_ERR_REM_USER_CONN_TERM` (0x13), + the same "remote user terminated" code the nRF path sends. Null-check + `s_server` and `BLE_HS_CONN_HANDLE_NONE`. + +There is already one raw call of this shape — `enterDFUMode()` at +[device_control.cpp:855-859](../src/device_control.cpp#L855-L859) reaches past the +seam into Bluefruit. Deliberately **not** converted here: it first sets +`restartOnDisconnect(false)` and then tears the SoftDevice down, so it wants +different semantics. Noted as a follow-up, not scope. + +### 3b. `checkIdleSessionTimeout()` — the watchdog + +A file-static in [main.cpp](../src/main.cpp), placed with the other +loop()-serviced BLE helpers and called from `loop()` immediately after +`checkTransferTimeouts()` ([main.cpp:697](../src/main.cpp#L697)). Shape: + +```c +static void checkIdleSessionTimeout() { + if (OD_BLE_IDLE_SESSION_TIMEOUT_MS == 0) return; // compile-time disable + if (!ble.isConnected()) return; + // "queues empty" -- the second clause of the §2 definition, and NOT implied by + // the stamp: a frame queued and never drained leaves a stale stamp with work + // still outstanding. The notifyReady() qualifier is load-bearing -- see §3e. + if (bleRxQueuePending() || (bleTxQueuePending() && ble.notifyReady())) return; + + const uint32_t idleMs = millis() - bleLastRxMs(); + if (idleMs < OD_BLE_IDLE_SESSION_TIMEOUT_MS) return; + + static uint32_t nextAttemptMs = 0; + if (nextAttemptMs != 0 && (int32_t)(millis() - nextAttemptMs) < 0) return; + od_log_info("Idle BLE session %u ms (limit %u ms) - disconnecting client", + (unsigned)idleMs, (unsigned)OD_BLE_IDLE_SESSION_TIMEOUT_MS); + if (!ble.disconnect()) { + od_log_warn("Idle-session disconnect rejected by the stack - retrying in 1 s"); + nextAttemptMs = millis() + 1000u; + return; + } + nextAttemptMs = 0; + bleMarkRxActivity(); // arm a fresh window; the disconnect event lands shortly +} +``` + +**Why the stamp, and not a per-pass queue-head comparison** (which is what Draft 3 +used — see §3e for the full argument): by the time this runs at +[main.cpp:697](../src/main.cpp#L697), `serviceBleRx()` has drained every queued +command and flushed TX after each one ([:534](../src/main.cpp#L534)), and +`serviceBleTx()` has run again ([:671](../src/main.cpp#L671)). Both `*Pending()` +predicates are head≠tail tests ([command_queue.cpp:151-153](../src/command_queue.cpp#L151-L153), +[:186-188](../src/command_queue.cpp#L186-L188)), so on a pass that just serviced a +burst they read false. Anything that samples *state at a pass boundary* is therefore +blind to ordinary traffic; only observing the arrival and the notify themselves is +reliable. Draft 2 sampled residual queue depth and would have kicked working +clients; Draft 3's head-diffing fixed that but still reconstructed the event from +snapshots. This observes the event. + +Restamping on a successful kick gives the one-kick-per-window property, since +`Bluefruit.disconnect()` / `NimBLEServer::disconnect()` only *request* termination +and `isConnected()` can stay true for several passes. + +The stamp also makes the blocking dead zone a non-issue now that +its real bound is ~126 s rather than 60 s +([FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md §B1](FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md)): +the command that *triggered* the refresh was stamped on arrival, and its response +stamped again on notify, so the pass that resumes reads a fresh stamp regardless of +how long the dead zone lasted. The 120 s default therefore does not have to exceed +the blocking bound at all — see §3c — and §2 drops `epdRefreshInProgress` as +unobservable. + +Why that position in the pass: RX/TX have already been serviced and the +disconnect/advertising flags already consumed, so the queue tests read post-service +state and the kick cannot pre-empt a frame that arrived this pass. It is also +**before** `platformIdle()`, so on ESP32 the pass that kicks is followed by a pass +whose `pollActivity()` sees the connection-count edge, re-arms the full idle hold, +and then sleeps normally — no special-casing needed. + +**Not** added to `idleDelay()`'s early-return set: nothing here changes +asynchronously off the loop task, and the nRF park is 1000 ms +([main.h:335](../src/main.h#L335)), so a kick lands at most one park late. + +### 3c. The tunable + +`OD_BLE_IDLE_SESSION_TIMEOUT_MS`, `#ifndef`-guarded in +[main.h](../src/main.h) beside `OD_NRF_IDLE_WAIT_MS`, overridable per environment +from `platformio.ini`, `0` = disabled. + +**Default: 120000 (120 s).** Read it as the **lockout budget** — the longest a +single caller may keep every other host from reaching the device (§1) — bounded +below by what a legitimate client can need. With §3e's activity stamp the value no +longer has to cover the blocking-refresh dead zone at all (the triggering command +stamped on arrival, before the loop blocked), so the floor rests purely on host +behaviour: + +- **It must clear every host-side inter-command timeout, and now that is the whole + safety argument** — with transfer and refresh state out of the busy set (§2), + nothing else holds the stamp during device-side work. `TIMEOUT_ACK` 5 s, + `TIMEOUT_FIRST_CHUNK` 10 s (`py-opendisplay device.py:447-449`), + `TIMEOUT_PIPE_START` 30 s (`commands.py:92`), and the 90 s refresh/END-ack pair + (`device.py:458-460`) all sit inside 120 s. The 90 s pair is the binding one: a + host waiting out a slow refresh sends nothing, and only the queue-head advance + from its own END command keeps it safe. 120 s over a 90 s host timeout is 33% + margin — thin enough that this number should not be lowered without re-checking + those constants, and it is why `TIMEOUT_REFRESH` is worth watching if + `waitforrefresh()`'s real ~126 s bound is ever actually hit (§7.1). +- **The ceiling is the one worth arguing about, given the purpose.** 120 s is the + worst case a blocked second caller waits, and for HA that is one or two failed + `drawcustom` deliveries before the device reappears — recoverable, since the next + scheduled update succeeds. If field reports show real callers being starved, the + fix is to *lower* this (60 s is safe with §3e's stamp in place; the earlier + objection to 60 s was only ever about the dead zone), not to redesign. That + asymmetry — safe to lower, needs care to raise — is why the conservative value + ships as the default. +- 4× `OD_LAN_READ_TIMEOUT_S` (30 s). More than the other transport, deliberately: + BLE reconnect costs more — advertising + re-acquisition at a 160–1000 ms interval + ([ble_transport_nrf.cpp:48-49](../src/ble_transport_nrf.cpp#L48-L49)) plus a + fresh connection, versus a TCP handshake. +- The production consumers connect per-operation: the HA integration wraps each + delivery in `async with OpenDisplayDevice(...)` + (`custom_components/opendisplay/delivery.py:319-340`, `services.py:467-479`), the + `opendisplay` CLI has no long-lived subcommand, and `tools/od-device-cli.py` is + one-shot argparse with no REPL. +- **Correction (review):** Draft 2 generalised that into "no consumer holds a BLE + link idle", which is false. `py-opendisplay examples/animate.py` opens one + `OpenDisplayDevice` context and loops indefinitely + (`examples/animate.py:156-215`), sleeping `--interval` between frames with no + upper bound on that value (`:257-263`, default 1000 ms), and it pre-processes + every frame *inside* the open connection (`:156-168`). See §3c-note. + +**§3c-note — accepted breakage.** `animate.py --interval` above 120000, or a +pre-processing pass over a large frame set that takes longer than 120 s, will now +be disconnected mid-run, and the surrounding `async with` does not reconnect. This +is accepted rather than designed around: + +- At the default 1 s interval, and at any interval a person would use for an + animation, the head tracking in §3b stamps on every frame — the script is + unaffected. +- The pre-processing window is the sharper edge, since it happens after connect + with zero BLE traffic. It is bounded by host CPU, not by the device. +- The alternative — an application keepalive or lease opcode — is a protocol + change, which is out of proportion to one example script. + +Consequence for scope: the earlier "no cross-repo work" claim is now **"no +cross-repo work required to ship; one follow-up recommended"** — move +`animate.py`'s `prepare_image()` loop above the `async with`, and/or have it +reconnect on `BleakError`. Filed against `py-opendisplay`, not a blocker here. + +**Alternative considered and rejected for now:** driving it from a provisioned +config field (e.g. one of `PowerOption.reserved[4]`, +[opendisplay_structs.h:505](../include/opendisplay_structs.h#L505)). That is the +right long-term home if the value ever needs to differ per deployment, but it +costs a canonical `opendisplay-protocol` edit + `--push` to four firmware repos + +a `tools/od-device-cli.py` `BLOCKS` update, for a knob no field report has asked +to vary. Deferred, and cheap to add later precisely because the macro is the only +reader. + +### 3d. Chunked-config cleanup on disconnect (review amendment) + +A chunked config write keeps state across BLE commands in `chunkedWriteState` +([config_parser.h:33-47](../src/config_parser.h#L33-L47)), set active by +`CMD_CONFIG_WRITE` ([communication.cpp:405-438](../src/communication.cpp#L405-L438)) +and cleared only by the final chunk or an error +([:462-501](../src/communication.cpp#L462-L501)). `serviceBleDisconnectCleanup()` +does **not** reset it ([main.cpp:388-423](../src/main.cpp#L388-L423)), and +`handleWriteConfigChunk()`'s entry gate is `chunkedWriteState.active` alone with no +binding to the session that opened it ([:462-467](../src/communication.cpp#L462-L467)). + +So an abandoned config write leaves a live buffer that the *next* client can append +to. **This is pre-existing** — it is equally reachable from a peer-initiated +disconnect or a link-loss today, so the watchdog does not create it. But the +watchdog does create a new, automatic way to reach it, so this plan fixes it: + +```c +// in serviceBleDisconnectCleanup(), beside resetPipeWriteState() +chunkedWriteState.active = false; +chunkedWriteState.receivedSize = 0; +chunkedWriteState.receivedChunks = 0; +``` + +Deliberately **not** added to §3b's busy set, on review's own reasoning: an +abandoned chunked write would then suppress the watchdog forever, which is the +opposite of what it is for. The state is reset *by* the disconnect, not protected +*from* it. It sits behind the same `ownerStillUp` guard as the transfer cleanups, +so a LAN-owned session is unaffected. + +### 3e. The RX activity stamp — observing the receive callback directly + +One choke point, in `command_queue.cpp`, already shared by both targets: + +```c +// command_queue.cpp +static volatile uint32_t s_lastRxMs = 0; + +void bleMarkRxActivity(void) { s_lastRxMs = millis(); } +uint32_t bleLastRxMs(void) { return s_lastRxMs; } +``` + +- **RX:** `bleMarkRxActivity()` at the top of `bleRxQueuePush()` + ([:50](../src/command_queue.cpp#L50)), which both stack callbacks call and nothing + else does ([nrf:155](../src/ble_transport_nrf.cpp#L155), + [esp32:147](../src/ble_transport_esp32.cpp#L147)). +- **Connect:** in `serviceBleEvents()`'s existing `takeConnectedEvent()` branch + ([main.cpp:462-468](../src/main.cpp#L462-L468)), so the first window is measured + from the connection rather than from a previous session's last frame. + +**Why TX is not stamped.** Every response this firmware emits is produced inside a +command handler — `sendResponse()` has no caller outside the dispatch path, in +`communication.cpp`, `device_control.cpp`, `buzzer_control.cpp` or +`display_service.cpp`; there are no device-initiated notifications (button and touch +state reach the host through the advertisement, not GATT). So a TX stamp would +almost always be re-stamping microseconds after the RX stamp that caused it, and +"no response sent" is implied by "no command received". + +The exception is a **delayed** response: the post-refresh ACK/NACK at +[display_service.cpp:2489](../src/display_service.cpp#L2489) and +[:2494](../src/display_service.cpp#L2494) is sent when the refresh finishes, which +the ~126 s `waitforrefresh()` bound +([FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md §B1](FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md)) +allows to be long after the END command that triggered it. With RX-only stamping, a +refresh that outlasts the 120 s window means the client is kicked on the pass right +after it receives its ACK. + +**That is accepted, and on the stated purpose (§1) it is arguably correct.** The +host's own `TIMEOUT_REFRESH` is 90 s (`py-opendisplay device.py:460`), so a device +that took over 120 s has already blown the host's deadline — that operation is lost +either way, and the client is by then a stale holder of a device another caller is +locked out of. It reconnects if it is still interested. Normal refreshes (~16 s on +Spectra 6-colour) are nowhere near the bound; what changes is only that the window +is measured from the END command rather than from its ACK, i.e. the kick can come up +to one refresh-duration earlier than it otherwise would. + +**The unsubscribed-client hole** (found while checking this, and present in every +draft before it). `serviceBleTx()` holds responses queued when a client is connected +but has not enabled its CCCD ([command_queue.cpp:206-207](../src/command_queue.cpp#L206-L207)), +and `notifyReady()` is `connected && notifyEnabled()` +([ble_transport_nrf.cpp:241-243](../src/ble_transport_nrf.cpp#L241-L243), +[esp32:261](../src/ble_transport_esp32.cpp#L261)). So a client that writes one +command and never subscribes leaves `bleTxQueuePending()` true **forever** — an +unqualified "queues empty" clause would make exactly the squatter this feature +targets permanently immune to it. Hence the `&& ble.notifyReady()` qualifier in +§3b: a queued response counts as work in flight only when it can actually drain. + +**Why this beats the queue-head snapshot it replaces.** Three reasons, in order of +weight: + +1. **It observes the event, not a residue of it.** No dependency on where in the + loop pass the check sits, or on what has already drained. That coupling is + precisely what made Draft 2 wrong, and Draft 3's head-diffing only worked around. +2. **It catches traffic the head never records.** `bleRxQueuePush()` rejects empty, + oversized and ring-full frames — it owns every drop reason + ([nrf:151-155](../src/ble_transport_nrf.cpp#L151-L155)) — and a rejected frame + advances no head. Stamping *before* validation means a client hammering a full + ring reads as busy, which it is; the head comparison would have read it as idle + and kicked a client that was mid-burst. +3. **No aliasing and no shadow state.** The RX head is modulo `COMMAND_QUEUE_SIZE` + (34), so a full wrap inside one pass reads as "unchanged" — `pollActivity()` + documents that as an accepted risk + ([main.cpp:337-341](../src/main.cpp#L337-L341)). A monotonic timestamp has no + such failure mode, and drops the `prev*Head` statics. + +**Threading.** The store runs on the stack callback task (nRF SoftDevice event task, +NimBLE host task) — the only writer, now that TX is not stamped — and the load on +the loop task. `volatile uint32_t`, naturally +aligned, is a single load/store on both Cortex-M4 and the Xtensa/RISC-V ESP32 cores, +so there is no tearing and no lock is needed — and a plain flag store is exactly +what the copy-and-flag callback contract permits +([ble_transport_nrf.cpp:118-124](../src/ble_transport_nrf.cpp#L118-L124)). `millis()` +is callable from both contexts: each is a FreeRTOS task, not an ISR. The RX stamp +lands *before* `imageWriteLogQuietFrame()` and the rest of the push body, so it +costs one store on the hot path of a pipe burst. + +**Wrap:** `millis() - stamp` is unsigned subtraction, correct across the 49.7-day +rollover, matching every other `millis()` deadline in the firmware. The +`nextAttemptMs` retry uses a signed-difference compare for the same reason. + +--- + +## 4. Commits and total diff + +One code commit, plus this document. + +1. **`feat(ble): disconnect idle BLE sessions after OD_BLE_IDLE_SESSION_TIMEOUT_MS`** +2. **`docs(ble): idle-session watchdog plan`** — this file. + +Seven files: + +| File | Change | ~lines | +|---|---|---| +| [ble_transport.h](../src/ble_transport.h) | `bool disconnect();` + its contract comment | 8 | +| [ble_transport_nrf.cpp](../src/ble_transport_nrf.cpp) | `Bluefruit.disconnect(s_connHandle)` + handle guard, returns status | 9 | +| [ble_transport_esp32.cpp](../src/ble_transport_esp32.cpp) | `s_server->disconnect(s_connHandle)` + null/handle guards, returns status | 9 | +| [command_queue.h](../src/command_queue.h) | `bleMarkRxActivity()` / `bleLastRxMs()` declarations | 4 | +| [command_queue.cpp](../src/command_queue.cpp) | the stamp + one call site in `bleRxQueuePush()` (§3e) | 7 | +| [main.h](../src/main.h) | `OD_BLE_IDLE_SESSION_TIMEOUT_MS` `#ifndef` block | 6 | +| [main.cpp](../src/main.cpp) | `checkIdleSessionTimeout()` (§3b) + call site + connect-branch stamp + §3d reset | 26 | + +~69 inserted lines. Every hunk is an insertion except two one-line additions into +existing bodies (`bleRxQueuePush`, the connect branch) and the three-line §3d reset, +so nothing that avoids the new watchdog changes behaviour. `serviceBleTx()` is now +untouched — one fewer hot-path edit than Draft 4, since RX-only stamping removes the +per-notify store during a pipe burst. + +Draft 2 claimed ~46 lines by dropping activity tracking altogether; that saving was +not value, it was the defect. Draft 3 restored it as ~6 lines of queue-head diffing +in `main.cpp`; Draft 4 moves it to ~12 lines split across `command_queue.*`, buying +the three correctness properties in §3e for two files and ~8 lines. + +**Why the three transport files are not avoidable.** Calling +`Bluefruit.disconnect()` / `s_server->disconnect()` straight from `main.cpp` would +save two files, but `main.cpp` names no stack type today: keeping Bluefruit and +NimBLE types out of application code is the stated contract of +[ble_transport.h](../src/ble_transport.h) and of +[PLAN_BLE_TRANSPORT_ABSTRACTION_2026-07-27.md](PLAN_BLE_TRANSPORT_ABSTRACTION_2026-07-27.md). +It would also need a `#ifdef TARGET_NRF` / `#else` pair at the call site, so the +"saving" is ~4 lines of seam traded for ~6 lines of target guard plus a broken +invariant. + +--- + +## 5. Test plan + +Build: `pio run` (all eleven CI environments) — the change touches both transport +implementations and shared `main.cpp`, so a single-env build proves nothing. + +Bench, nRF (`nrf52840custom-debug`, RTT log): + +1. **Kick fires.** Connect with nRF Connect / `bleak`, subscribe, send nothing. + Expect the `Idle BLE session … disconnecting client` line at ~120 s, then + `=== BLE CLIENT DISCONNECTED (nRF) ===` with reason 0x16 (local host + terminated) on the peer side, `Disconnect reason:` logged by + `serviceBleEvents()`, and advertising visible again from a scanner within a + second or two. +2. **MSD unfreezes.** Confirm the post-disconnect `s_msdUpdatePending` republish + ([main.cpp:510](../src/main.cpp#L510)) lands — the advertisement's loop counter + advances again after the kick. +3. **A stalled upload IS kicked** (behaviour change from earlier drafts — §2). + Start a pipe upload, kill the host process mid-stream so the link stays up with + no further frames. Expect the kick ~120 s after the last frame, then the normal + deferred teardown: `resetPipeWriteState()` / + `cleanupDirectWriteState(true)` / `cleanupPartialWriteOnDisconnect()` via + `serviceBleDisconnectCleanup()`, and the panel rail released rather than held to + the 15-minute watchdog. Check the log shows cleanup *after* the disconnect + event, not inside the kick. + Then the inverse: a **healthy** upload of a large image over a slow link must + not be kicked — every accepted frame advances `rxHead` and every ACK advances + `txHead`, so a transfer that is progressing at all is never idle. +4. **No kick around a refresh.** Push a full-frame image to a Spectra panel + (~16 s blocking refresh) with the host idle before and after. Expect no kick + during the refresh, and the next kick **120 s after the END command arrived** — + i.e. ~104 s after the refresh returns, not 120 s after it. That is the documented + consequence of RX-only stamping (§3e), not a defect; what would be a defect is a + kick *during* the refresh or before the ACK reaches the host. +5. **The regression Draft 2 would have shipped** — the single most important test. + Connect and send one cheap command (e.g. `CMD_FIRMWARE_VERSION`) every 30 s for + 10 minutes, doing nothing else. Expect **zero** kicks. Draft 2's watchdog kicks + at ~120 s here, because each command drains inside its pass and leaves both + `*Pending()` predicates false. +6. **One kick per session**, not one per pass: exactly one log line per idle + session. Also check the `disconnect rejected by the stack` path is silent in + normal operation. +11. **§3e, the unsubscribed squatter — the case that was immune in every earlier + draft.** Connect *without* enabling notifications, write one command (so a + response is queued and cannot drain), then idle. Expect the kick at ~120 s. With + an unqualified `bleTxQueuePending()` term this client is never kicked at all, + which is the precise opposite of the purpose in §1. +10. **§3e, the case snapshots missed.** Saturate the RX ring (a burst deeper than + `COMMAND_QUEUE_SIZE`, or oversized frames) so `bleRxQueuePush()` starts + rejecting, and hold that for over 120 s. Expect **no** kick: rejected frames + still stamp. Draft 3's head comparison would have read this as idle — the heads + do not advance on a drop — and disconnected a client mid-burst. + +Bench, ESP32 battery target (`power_mode == 1`, `deep_sleep_time_seconds > 0`): + +7. **Deep sleep resumes, on the full timeline.** Connect, idle. Expect the kick at + ~120 s, then sleep after the *post-disconnect* quiet hold — the disconnect edge + re-stamps `lastActivityMs` via the connection-count change + ([main.cpp:342-368](../src/main.cpp#L342-L368)), so sleep is + `sleep_timeout_ms` (or the 10 s `DEFAULT_IDLE_HOLD_MS`, + [main.h:308](../src/main.h#L308)) later, and only once any `min_wake_time_seconds` + hold has expired ([main.cpp:308-320](../src/main.cpp#L308-L320)). Verify with a + non-default `sleep_timeout_ms` too, and record total awake time as + *120 s + hold*, not 120 s — that is the number the power budget needs. + +Negative tests: + +8. Build one env with `-DOD_BLE_IDLE_SESSION_TIMEOUT_MS=0`; confirm the watchdog + compiles out to a no-op and an idle session is never kicked. +9. **§3d:** start a chunked config write, send chunk 1 of 3, let the kick fire. + Reconnect and send a chunk — expect a NACK (state was reset), not silent + appending to the departed client's buffer. + +--- + +## 6. Residuals and known gaps (not fixed here) + +1. **The encryption session survives the kick — deliberately deferred, with the + reason stated.** Nothing clears it on BLE disconnect: + `clearEncryptionSession()` is called only on config reload + ([communication.cpp:66](../src/communication.cpp#L66)), re-auth, integrity + failure, and its own age timeout. That contradicts the wire documentation for + `session_timeout_seconds` — *"0 = no timeout (persists until disconnect)"* + ([opendisplay_structs.h:916](../include/opendisplay_structs.h#L916)) — and the + host clears its side on any disconnect (`py-opendisplay device.py:688-740`), so + after a kick the two disagree about session lifetime until the next + authentication replaces the state. + + Review argued this belongs in this change because the watchdog manufactures + disconnects. It is still deferred, for a reason the review itself identified: + `encryptionSession` is a **single global shared with the LAN transport** + (`wifi_service.cpp:798`, `:874` clear it), so clearing it on a BLE disconnect + needs a transport-ownership decision — the same class of decision that + `serviceBleDisconnectCleanup()`'s `ownerStillUp` guard exists to make for + transfers. That is a security-relevant change with its own test matrix and it + should not ride along inside a power/discoverability fix. It is a **named + follow-up, not an unknown**: clear the session when the disconnect event is + consumed *if* no LAN session is live. +2. **Reason code asymmetry.** nRF sends 0x13 via + `BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION`, ESP32 sends NimBLE's default + `BLE_ERR_REM_USER_CONN_TERM` (also 0x13). Same code, reached two different + ways; if a future NimBLE release changes its default this diverges silently. +3. **The event-flag coalescing weakness** documented at + [ble_transport.h:74-77](../src/ble_transport.h#L74-L77) applies to the + disconnect this raises exactly as it does to a peer-initiated one. Neither + worsened nor fixed. +4. **No host-visible warning before the kick.** The client learns only from the + disconnect. A pre-kick notification would need a protocol opcode; out of scope. +5. **`enterDFUMode()` still bypasses the transport seam** + ([device_control.cpp:857](../src/device_control.cpp#L857)) — see 3a. + +--- + +## 7. Review findings not adopted, and why + +From the adversarial review (Codex, 2026-07-29). Recorded so they are not +re-litigated, and so a reader can see what was checked rather than assumed. + +1. **"Fix `waitforrefresh()` to use a `millis()` deadline, and reconcile + py-opendisplay's 90 s refresh timeout with the real ~126 s bound."** Both are + real defects and both are already tracked elsewhere — + [FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md §B1](FINDINGS_NRF_BLOCKING_CALLS_2026-07-29.md) + notes an unmerged `waitForPanelIdle()` on `debug/freeze-fix-phase2`. Neither is a + prerequisite here: with §3b's head tracking the watchdog is insensitive to how + long the dead zone is, so this change neither depends on nor worsens them. +2. **"Verdict: redesign."** Downgraded to *implement with amendments*. Of the seven + findings, one was fatal to Draft 2's watchdog (adopted, §3b), one corrected a + load-bearing number (adopted, §3b/§3c), three are small additive amendments + (§3a `bool`, §3d chunked config, §5 test 7), one is an accepted breakage in an + example script (§3c-note), and one — encryption ownership — is a named follow-up + with a stated reason (§6.1). The transport seam, the watchdog's position in the + pass, and the busy set as amended all survived. That is a revision, not a + redesign. +3. **Claims the review checked and *confirmed*, so they stay as written:** calling + either stack's disconnect from the loop task is safe (link tuning already runs + there, [main.cpp:461-468](../src/main.cpp#L461-L468), and both disconnect + callbacks are copy-and-flag only); MSD publication is suppressed while connected + on both targets; ESP32 needs the application to restart advertising and nRF does + not; an idle connected client does prevent ESP32 deep sleep indefinitely; every + `waitforrefresh()` call site passes `60` (the error was reading that as seconds); + nothing clears the encryption session on BLE disconnect. +4. **"Is there a cheaper mechanism — advertise while connected, or lean on the GAP + supervision timeout, instead of disconnecting?"** Both were considered and + neither is available: + - *Advertising while connected* splits into two things. Keeping the + advertisement **fresh** (non-connectable) would unfreeze the MSD but does + nothing for the lockout in §1a — a second caller still cannot connect, which is + the actual purpose. Letting a second caller **connect** needs more than a + radio-config change: the transfer and session state is global and + single-client throughout — `pipeState` / `pipeReorder` + ([display_service.cpp:576-577](../src/display_service.cpp#L576-L577)), + `partialCtx`, `directWriteActive`, `chunkedWriteState` + ([config_parser.h:33-47](../src/config_parser.h#L33-L47)), one + `encryptionSession`, and a single `s_connHandle` per transport. Multi-link + support is a re-architecture, not an alternative to a 20-line watchdog. + - *Supervision timeout* is the wrong instrument: it detects a link that has + **failed**, not one that is alive and idle. A healthy peer answers every + connection event, so the supervision timer never expires no matter how long it + squats. Nothing in this firmware even sets connection parameters. +5. **Non-issues it ruled out for me:** NFC has no dispatcher entry in this firmware + (`communication.cpp:632-636`, falls through as unknown), and buzzer playback is + capped at 30 s (`buzzer_control.cpp:15-17`) — so neither can span the window with + the busy set false. Draft 2 had listed both as open questions. diff --git a/docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md b/docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md index cda8129..f06ecff 100644 --- a/docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md +++ b/docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md @@ -379,7 +379,14 @@ loss, not as an unrecoverable condition. **All five are settled.** Nothing blocks implementation. -### Decision A — RESOLVED: forward cap **128** +### 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)). @@ -975,3 +982,102 @@ Everything below is still open: 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/src/communication.cpp b/src/communication.cpp index 50aff87..c6e4a8b 100644 --- a/src/communication.cpp +++ b/src/communication.cpp @@ -900,6 +900,10 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin // advance, and each retransmission's still-higher counter would be // rejected further out than the last, until re-authentication. That is // a transient link fault promoted to a permanent session fault. + // + // This reverses Decision A of + // docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md, which specified a cap of + // 128; see "Reversal of Decision A" at the end of that file. handlePipeWriteData(data + 2, len - 2); break; case CMD_PIPE_WRITE_END: // 0x0082 diff --git a/src/encryption.cpp b/src/encryption.cpp index 63f7372..c940504 100644 --- a/src/encryption.cpp +++ b/src/encryption.cpp @@ -119,7 +119,7 @@ void deriveSessionId(const uint8_t* session_key, const uint8_t* client_nonce, // populated). In particular nonce_counter — the device's OWN outbound counter — // must keep being zeroed here: a device that carried it across a re-auth while // the client restarts at 0 would reproduce the [H2] keystream reuse against -// itself. +// itself. See docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md Step 1. static void resetNonceState(void) { encryptionSession.nonce_counter = 0; /* device's OWN outbound counter */ encryptionSession.last_seen_counter = 0; diff --git a/src/encryption_state.h b/src/encryption_state.h index 4bbfa23..ad90c59 100644 --- a/src/encryption_state.h +++ b/src/encryption_state.h @@ -25,7 +25,8 @@ struct EncryptionSession { // in step, and no insertion index to reset. Replaces a uint64_t[64] value // ring (512 B -> 32 B). last_seen_counter only ever moves UP; see // src/nonce_window.h for the decision rule and why that is the whole - // anti-replay argument. + // anti-replay argument, and docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md + // Step 1 / Decision B. uint64_t replay_bitmap[OD_NONCE_BITMAP_WORDS]; uint32_t last_activity; uint8_t integrity_failures; diff --git a/src/nonce_window.h b/src/nonce_window.h index 157f3ff..164f435 100644 --- a/src/nonce_window.h +++ b/src/nonce_window.h @@ -7,10 +7,12 @@ // firmware logging layer, and it touches no global state. Everything here // operates on plain values passed in by the caller, so the whole state machine // can be compiled and exercised on a host under UBSan/ASan by -// tools/test_nonce_window.cpp. +// tools/test_nonce_window.cpp. See Decision D in +// docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. // // Representation ("shifting" style, as opposed to the circular RFC 6479 / -// WireGuard style): +// WireGuard style -- Decision B in docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md, +// which still stands; only the arithmetic over it moved from modular to numeric): // // bit i of the bitmap == "counter (last_seen - i) has been consumed". // bit 0 is last_seen itself. @@ -101,6 +103,10 @@ static inline void od_nonce_bit_set(uint64_t* bm, uint64_t bit) { // every subsequent frame — each carrying a still higher counter — would be // rejected further out than the last, until re-authentication. // +// This reverses Decision A of docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md, which +// specified a cap of 128; that file's "Reversal of Decision A" section carries the +// full argument and the arithmetic showing no cap could have been sized safely. +// // The security invariant is "a consumed counter is never returned NONCE_OK // again", and it rests on last_seen being monotonically non-decreasing: only the // forward branch of od_nonce_commit() assigns it, and only upward. Every consumed diff --git a/tools/test_nonce_window.cpp b/tools/test_nonce_window.cpp index 552acac..b67c572 100644 --- a/tools/test_nonce_window.cpp +++ b/tools/test_nonce_window.cpp @@ -7,7 +7,8 @@ // /tmp/test_nonce_window // // This file is as much a written-down statement of the intended semantics of the -// anti-replay window as it is a test. +// anti-replay window as it is a test. It discharges Decision D of +// docs/PLAN_PHASE1_NONCE_REPLAY_2026-07-26.md. // // The representation under test (see the header): // bit i of the bitmap == "counter (last_seen - i) has been consumed"