diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 75449af1..02e1f69b 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -1,8 +1,10 @@
-# Checks the adopter docs (Story 10.2): every relative link and referenced repository path
-# resolves, and every command block of the build guide (docs/build/*.md) either runs against the
-# firmware crates (``, builds only) or, when it needs a board
-# (``), names only real espflash subcommands. Nothing is ever flashed, and no
-# external link is fetched. Not a required status check.
+# Checks the adopter docs (Stories 10.2 and 10.4): every relative link and referenced repository
+# path resolves; every command block of the build guide and the firmware reference
+# (docs/build/*.md, docs/firmware/*.md) either runs against the firmware crates
+# (``, builds only) or, when it needs a board (``), names
+# only real espflash subcommands; and the API docs of the packages/rs crates build without a
+# rustdoc warning, a public item without docs included. Nothing is ever flashed, and no external
+# link is fetched. Not a required status check.
#
# Actions are pinned to a commit; the comment names the release. The firmware setup of the
# `commands` job is copied from the `firmware` job of ci.yml: keep the two in step.
@@ -16,6 +18,9 @@ on:
- "hardware/**"
- "README.md"
- "LICENSE"
+ - "Cargo.toml"
+ - "Cargo.lock"
+ - "rust-toolchain.toml"
- ".gitignore"
- "apps/**"
- "packages/**"
@@ -31,6 +36,9 @@ on:
- "hardware/**"
- "README.md"
- "LICENSE"
+ - "Cargo.toml"
+ - "Cargo.lock"
+ - "rust-toolchain.toml"
- ".gitignore"
- "apps/**"
- "packages/**"
@@ -135,3 +143,32 @@ jobs:
run: |
docs/check-commands.sh --list
docs/check-commands.sh
+
+ # Story 10.4: the firmware crates are a learning reference, so their API docs build without a
+ # single warning (a missing doc, a broken or ambiguous intra-doc link, a bare URL). It lives here
+ # rather than in ci.yml, whose required `rust` job already runs clippy with `missing_docs`,
+ # because any ci.yml edit makes every ci.yml job run.
+ rustdoc:
+ name: Rust docs
+ runs-on: ubuntu-24.04
+ timeout-minutes: 20
+ steps:
+ - name: Check out
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ # Installs the toolchain named in rust-toolchain.toml.
+ - name: Set up Rust
+ run: rustup toolchain install
+
+ - name: Cache
+ uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+
+ - name: Build the API docs
+ env:
+ RUSTDOCFLAGS: -D warnings
+ run: >-
+ cargo doc --no-deps --locked --features coldframe-hal/mock
+ -p coldframe-hal -p coldframe-crypto -p coldframe-protocol -p coldframe-setup
+ -p coldframe-uplink -p coldframe-sensing -p coldframe-transport
diff --git a/README.md b/README.md
index 5c9725e8..385b7a10 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,8 @@ and deployment all live in this repository.
- [Build guide](docs/build/README.md): build, flash and add your own Node and Hub.
- [Deploy guide](docs/deploy/README.md): run Coldframe on your home server, then
[build your own apps](docs/build/apps.md).
+- [Firmware reference](docs/firmware/README.md): learn ESP-NOW, BLE provisioning, eFuse keys and
+ deep sleep from the Node and Hub firmware.
- [Hardware design](hardware/README.md): schematics, BOM, wiring, enclosure and probe sealing.
- [Hub radio coexistence spike](docs/spikes/hub-radio-coexistence.md): what the Hub's radio can do at once.
diff --git a/_bmad-output/implementation-artifacts/spec-10-4-firmware-learning-reference.md b/_bmad-output/implementation-artifacts/spec-10-4-firmware-learning-reference.md
new file mode 100644
index 00000000..3c555516
--- /dev/null
+++ b/_bmad-output/implementation-artifacts/spec-10-4-firmware-learning-reference.md
@@ -0,0 +1,214 @@
+---
+title: 'Firmware learning reference'
+type: 'feature'
+created: '2026-10-20'
+status: 'done'
+baseline_revision: 'a5bae8573c50692249adf99c9ce494e96ff36dd0'
+review_loop_iteration: 0
+followup_review_recommended: true
+context:
+ - '{project-root}/_bmad-output/implementation-artifacts/epic-10-context.md'
+warnings: [oversized]
+deferred:
+ - summary: >-
+ Hub comments still claim the eFuse adapter holds the Hub's only unsafe code, but board/rtc.rs has an unsafe block for the mbedTLS clock hooks.
+ evidence: |-
+ apps/rs/hub/src/board/efuse.rs module doc line 7 and apps/rs/hub/Cargo.toml:60 say only efuse.rs may use unsafe; apps/rs/hub/src/board/rtc.rs:40-41 has #[allow(unsafe_code)] unsafe { ... }. Pre-existing, not caused by story 10-4; docs/firmware/identity.md already states the code's truth.
+ location: >-
+ apps/rs/hub/src/board/efuse.rs:7
+ severity: low
+---
+
+
+
+## Intent
+
+**Problem:** A maker who wants to learn ESP-NOW, BLE provisioning and deep sleep from the Coldframe firmware has no entry point. The knowledge is spread over crate docs, `apps/rs/*/README.md`, ARCHITECTURE-SPINE AD texts and the spike report, and `docs/firmware/` does not exist. `cargo doc` over `packages/rs/*` is not run in CI. Today it emits 2 rustdoc warnings (a bare URL in the generated `packages/rs/crypto/src/spec.rs`, an ambiguous `[`env`]` link in `packages/rs/sensing/src/lib.rs:10`). The re-exported micropb wire types in `coldframe-protocol` have no docs at all, because `missing_docs` is allowed on the generated module.
+
+**Approach:** Add `docs/firmware/`: an index page plus one page per AC topic. Each page explains the mechanism in prose and points to the code by repo path and symbol. Make every public item of `packages/rs/*` documented, including the generated wire types, by carrying the proto comments into the generated code. Fix the 2 rustdoc warnings at their source. Add a `cargo doc` job with `RUSTDOCFLAGS=-D warnings` to the Docs workflow.
+
+## Boundaries & Constraints
+
+**Always:**
+- Take facts from the code, not from the planning docs. Where the two disagree, document the code. Examples: the Hub only rescans after the link drops, and there is no RSSI-degrade re-evaluation; the burned fuse purpose is `HMAC_UP` and the peripheral mode is `ToUser`.
+- Point to code as backticked repo-root paths (`check-links.sh` verifies they exist) plus symbol names. Link to READMEs, AD texts and checklists instead of copying their tables.
+- State these facts: eFuse burns are irreversible, and any non-`dev-mode` first boot burns, with a `> [!WARNING]`. The ~100 µA average sleep-current budget is not yet measured. WPA3-only Wi-Fi is unsupported. A short press means report now, a long press (≥3 s) means BLE setup. Never erase `cf_frame` on a Node that has sent frames.
+- Match the existing page style: one `#` title, an intro that links neighbouring pages, `##`/`###` sections, GitHub alerts, no front matter.
+- Every bash/sh block in `docs/firmware/` carries a `ci:` marker.
+- New CI actions are pinned by SHA with a release comment, copied from `ci.yml`.
+
+**Never:**
+- Edit `.github/workflows/ci.yml`. That forces run-all, including the slow Images job (precedent: specs 10-2 and 10-3).
+- Make the Docs workflow a required check, or fetch external links.
+- Edit `sprint-status.yaml`.
+- Change firmware behaviour, wire bytes, crypto-spec values or proto field numbers. Comment-only proto changes are fine.
+- Hand-edit the generated `packages/rs/crypto/src/spec.rs`. Change the generator input and regenerate.
+- Add a docs site generator.
+- Add `#[allow(missing_docs)]` or `#[allow(rustdoc::…)]` to silence a gap.
+
+
+
+## Code Map
+
+- `_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md:175-390` -- AD-9/10/11/12/17/24/25 texts; Deferred section at line 577. Link target; read-only.
+- `_bmad-output/specs/spec-coldframe/device-hardware.md:12-21,57-64` -- H-1..3, N-1..3, power budget. Read-only.
+- **ESP-NOW and channel following:**
+ - `packages/rs/transport/src/transport.rs`: module doc 1-42; constants `ACK_WINDOW_MS`/`PROBE_WAIT_MS`/`RESCAN_AFTER_MISSES`/`SCAN_BACKOFF_WAKES`; `probe`, `find_hub`, `listen`, `run`
+ - `packages/rs/transport/src/link.rs` (`LinkState`, `CFLK`)
+ - `packages/rs/protocol/src/radio.rs`
+ - `packages/rs/hal/src/datagram.rs`
+ - `apps/rs/node/src/board/espnow.rs`; `apps/rs/hub/src/board/espnow.rs` (`channel: None`)
+ - `packages/rs/uplink/src/relay.rs`
+- **BLE setup and PoP:**
+ - `packages/rs/crypto/src/setup.rs` (`derive_keys`, `SetupSession`)
+ - `packages/rs/setup/src/{lib,code,framing,session,store,service,node,app}.rs`
+ - `packages/rs/crypto/src/hpke.rs`
+ - `apps/rs/node/src/main.rs::setup_mode`; `apps/rs/{hub,node}/src/board/ble.rs`
+ - `packages/proto/coldframe/setup/v1/setup.proto`
+- **Identity:**
+ - `packages/rs/crypto/src/{keys,identity}.rs` (`provision_efuse`, `provision_dev`, `CFID`)
+ - `packages/rs/hal/src/{efuse,hmac}.rs`
+ - `apps/rs/{hub,node}/src/board/{efuse,hmac}.rs`, which are the only `unsafe` code
+ - `compile_error!` dev-mode guard in `apps/rs/node/src/main.rs:62-65`
+- **Sealing:**
+ - `packages/rs/crypto/src/frame.rs` (`nonce`, `aad`, `seal`, `open_checked`, `ReplayWindow`)
+ - `packages/rs/transport/src/frame.rs` (`seal_uplink`, `open_downlink`)
+ - `packages/rs/sensing/src/counter.rs` (`ReservedCounter`)
+ - `packages/rs/transport/src/buffer.rs`
+ - `packages/proto/coldframe/device/v1/envelope.proto`
+- **Deep sleep:**
+ - `apps/rs/node/src/main.rs` (`WATCHDOG_MS`, `sleep`, `fail`, 80 MHz)
+ - `apps/rs/node/src/board/{sleep,rtc,button}.rs`
+ - `packages/rs/sensing/src/wake.rs` (`WAKE_PERIOD_MS`, `sleep_ms`, `run_wake`)
+ - `docs/build/power.md` (link)
+- **Buttons:** `packages/rs/sensing/src/button.rs` (`DEBOUNCE_MS` 50, `LONG_PRESS_MS` 3000, `classify_press`, `wake_plan`, `sleep_after`); `apps/rs/node/src/board/pins.rs` (GPIO7).
+- **Coexistence:** `docs/spikes/hub-radio-coexistence.md`, with the verdict at about line 400 and findings F-3..F-7. Code: `packages/rs/uplink/src/bssid.rs::select_bssid`, `apps/rs/hub/src/board/wifi.rs::security`, `apps/rs/hub/src/main.rs` (heap, BLE before Wi-Fi). Read-only.
+- `apps/rs/node/README.md`, `apps/rs/hub/README.md`, `apps/rs/README.md`, `packages/rs/README.md` -- existing crate and board docs; link to them rather than duplicating.
+- **Rustdoc fixes:**
+ - `packages/crypto-spec/scripts/lib/spec.ts:209` -- `sensorIdNamespace` doc string embeds the bare URL. Regenerate with `pnpm --filter @coldframe/crypto-spec run generate`, which rewrites the Rust, C# and Kotlin outputs. `run check` is the CI drift gate.
+ - `packages/rs/sensing/src/lib.rs:10` -- the `[`env`]` link is ambiguous.
+ - `packages/rs/protocol/build.rs` -- has `compiler.include_source_info(false)`. Setting it to `true` makes micropb-gen 0.6.0 emit proto comments as docs (verified).
+ - `packages/rs/protocol/src/lib.rs:26-45` -- `missing_docs` is allowed on `mod generated`. `pub use …::v1_ as setup_v1`/`device_v1` makes the undocumented `v1_` package modules public.
+ - With source info on and the allow removed, exactly 10 gaps remain: the 2 `v1_` modules, plus 8 enum values without a comment (`DEVICE_KIND_*`, `WIFI_SECURITY_*` in setup.proto:79-104, `WIFI_STATUS_UNSPECIFIED` 143, `SETUP_ERROR_CODE_UNSPECIFIED` 199).
+- `.github/workflows/docs.yml` -- add the job here. The `Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2` pin is at `ci.yml:183`. The host toolchain comes from `rustup toolchain install` reading `rust-toolchain.toml`.
+- `docs/check-links.sh` -- already scans `docs/**`, so new pages are covered. `docs/check-commands.sh:45-49` -- default glob is `docs/build/*.md` only.
+- `README.md:10-17` ("Start here") -- the docs index.
+
+## Tasks & Acceptance
+
+**Execution:**
+- `packages/proto/coldframe/setup/v1/setup.proto` -- add a one-line comment to each undocumented enum value. Comment-only change; buf lint and breaking must stay green.
+- `packages/rs/protocol/build.rs` -- `include_source_info(true)`, and update the module doc to say proto comments become rustdoc.
+- `packages/rs/protocol/src/lib.rs` -- drop `missing_docs` from the generated module's allow list. Expose `setup_v1`/`device_v1` as documented modules that glob re-export the generated package modules (`pub mod setup_v1 { pub use super::generated::…::v1_::*; }`), keeping every existing path such as `setup_v1::SetupMessage_::Body` valid. Update the crate doc lines 6-10 to match.
+- `packages/crypto-spec/scripts/lib/spec.ts` -- put the namespace name in a code span so no bare URL reaches the docs. Then regenerate the Rust, C# and Kotlin outputs.
+- `packages/rs/sensing/src/lib.rs` -- link the module unambiguously (`mod@env`).
+- `docs/firmware/README.md` -- the index. It gives:
+ - a reading order
+ - a crate map (the 7 `packages/rs` crates and the 2 firmware crates, one line each, linking the existing READMEs)
+ - the two toolchains: host 1.97 versus esp, with `rust-version` 1.95
+ - how to open the API docs (`cargo doc --no-deps -p coldframe-… --open`, in a `` block)
+ - links to the 7 topic pages
+- `docs/firmware/espnow.md`, `ble-setup.md`, `identity.md`, `sealing.md`, `deep-sleep.md`, `buttons.md`, `radio-coexistence.md` -- one page per AC topic. Each page has "How it works", the key constants and timings, and a "Where to read the code" list (path plus symbol), and links the governing AD/N/H items.
+- `docs/check-commands.sh` -- add `docs/firmware/*.md` to the default files, and update the header comment. Then any shell block there must carry a marker; check `docs/test.sh` still passes.
+- `.github/workflows/docs.yml` -- add a `rustdoc` job ("Rust docs"). It checks out, runs `rustup toolchain install` and rust-cache, then runs `cargo doc --no-deps --locked -p coldframe-hal -p coldframe-crypto -p coldframe-protocol -p coldframe-setup -p coldframe-uplink -p coldframe-sensing -p coldframe-transport` with `RUSTDOCFLAGS: -D warnings`. Give it a reason comment citing Story 10.4, and update the header comment.
+- `README.md` -- add a "Firmware reference" bullet under "Start here". Also add a link line in `apps/rs/README.md` and `packages/rs/README.md`.
+
+**Acceptance Criteria:**
+- Given `docs/firmware/`, when a maker reads it, then each of the 7 AC topics has a page that explains the mechanism with backticked repo paths and symbols, and every one of those paths passes `docs/check-links.sh`.
+- Given the 7 firmware crates, when `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --locked -p …` runs, then it succeeds, and the generated `setup_v1`/`device_v1` items carry docs (no `missing_docs` allow remains in `packages/rs`).
+- Given a PR touching `packages/**` or `docs/**`, when the Docs workflow runs, then the new job runs that `cargo doc` command and fails on any rustdoc warning.
+- Given the crypto-spec change, when `pnpm --filter @coldframe/crypto-spec run check` runs, then nothing is stale.
+
+## Spec Change Log
+
+## Review Triage Log
+
+### 2026-10-20 — Review pass
+- verdicts: 18 findings — high 0, medium 4, low 13, false 1, maybe-false 0
+- findings:
+ - `[low]` `[patch]` identity.md calls the Hub `board/efuse.rs` "the same file" as the Node's, but the module comments differ — fixed: it now says same code, only the module comment differs. The Hub file's "only `unsafe` code of the Hub" comment and the `apps/rs/hub/Cargo.toml:60` lint comment are stale since `board/rtc.rs:41` has an `unsafe` block; that is pre-existing, so it is deferred.
+ - `[medium]` `[patch]` ble-setup.md constants table: `SERVICE_UUID` shown as the text UUID, but it is a `u128` (`SERVICE_UUID_STR` is the text, `packages/rs/setup/src/lib.rs:45,54`), and `MAX_PAYLOAD` lists only the Hub copy (`apps/rs/node/src/board/ble.rs:35` also has one) — fixed: the row now names `SERVICE_UUID_STR` and the u128 form, and lists both ble.rs files.
+ - `[low]` `[reject]` the rustdoc job lists the 7 crates by hand, so an 8th crate could miss the gate — `ci.yml`'s required `rust` job lists the same crates the same way. A new crate is rare, and the fix (`--workspace` plus excludes, or cargo metadata) is just another list or adds complexity.
+ - `[low]` `[reject]` the firmware crates `apps/rs/{node,hub}` have no `cargo doc` in CI — the story AC scopes the gate to "public items in `packages/rs/*`". Building esp-toolchain docs in CI is new CI surface beyond the AC.
+ - `[low]` `[reject]` the README's `cargo doc --open` block is `ci:manual` and widens `ci:manual` to "a browser" — this is a documentation choice. A `ci:run` would run the host toolchain inside the esp-only `commands` job, which adds risk for a cosmetic gain.
+ - `[low]` `[patch]` the `docs.yml` header and the `check-commands.sh` line 2 header only described the build guide — fixed: both headers were reworded to cover `docs/build` and `docs/firmware`, and the `--help` line range was adjusted.
+ - `[low]` `[reject]` the cited symbols and constant values are not machine-checked — a new checker adds complexity, and the 10-2 review already rejected this ("no automated check of documented facts").
+ - `[low]` `[reject]` there is no page on the Hub→Server uplink (heartbeats, `hub-auth/v1` signing) — the story AC enumerates 7 topics and this is not one of them. A reader meets the gap only beyond the story's promise.
+ - `[low]` `[reject]` there is no page on the Node measurement path (probe, BME680, battery) — this is outside the 7 AC topics, for the same reason as the row above.
+ - `[low]` `[patch]` constants tables use different column layouts — fixed: `buttons.md` was reordered to `Constant | Value | Where`. The descriptive table in `radio-coexistence.md` was left as is, because its rows are findings, not symbols.
+ - `[low]` `[patch]` the `packages/rs/protocol/build.rs` doc says an uncommented proto item "fails `cargo doc`", but `missing_docs` is only `warn` — fixed: it now says the item warns, and fails under CI's `-D warnings` (clippy, rustdoc).
+ - `[low]` `[reject]` every page links the dated `_bmad-output` spine, and there is no ID glossary — the links resolve and `check-links` guards them. Adding a glossary is new content with little everyday harm.
+ - `[medium]` `[patch]` the `docs.yml` path filters omit the root `Cargo.toml`, `Cargo.lock` and `rust-toolchain.toml`, so the Rust docs job skips lint, dependency or toolchain-only PRs — fixed: the three paths were added to both the `pull_request` and the `push` lists.
+ - `[low]` `[patch]` the `coldframe-hal` `mock` feature items are not covered by the rustdoc gate — fixed: `--features coldframe-hal/mock` was added to the job. It was verified locally with `-D warnings`.
+ - `[medium]` `[patch]` the new default `docs/firmware/*.md` glob in `check-commands.sh` had no test — fixed: a fixture case was added to `docs/test.sh` (50/50 pass). A mutation run that reverted the glob made the test fail (49/1).
+ - `[medium]` `[patch]` (same root cause as the path-filter row) the rustdoc job does not run when only root Cargo files change — fixed by the path-filter patch above.
+ - `[low]` `[reject]` intent audit: the spec reads "the firmware crates" as the 7 `packages/rs` crates, which is narrower than `apps/rs` — this is the same as the "no `cargo doc` for `apps/rs/{node,hub}`" row above, and the AC text itself says `packages/rs/*`.
+ - `[false]` `[reject]` intent audit: the work is uncommitted — this is the review stage. The commit happens at finalization through gzp-pipeline (the On Complete hook owns commit, push and PR).
+
+## Design Notes
+
+**Why the gate lives in `docs.yml`:** the `rust` job in `ci.yml` is the required check. But any `ci.yml` edit forces run-all, which includes the Images job that is known to be slow and flaky, and specs 10-2 and 10-3 both forbid editing it. `docs.yml` is already path-filtered on `packages/**`. The AC only asks that `cargo doc` runs in CI. Clippy `-D warnings` in the required `rust` job already enforces `missing_docs` on hand-written items.
+
+**Why glob re-export modules:** a `#[doc]` on `pub use … as setup_v1` cannot document the original `v1_` module. A wrapper module is documented, makes `v1_` unreachable publicly (so the lint no longer fires on it), and keeps every item path.
+
+**Line numbers drift:** docs name symbols, and use `path:line` only where a symbol does not exist. `check-links.sh` strips `:N` anyway.
+
+## Verification
+
+**Commands:**
+- `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --locked -p coldframe-hal -p coldframe-crypto -p coldframe-protocol -p coldframe-setup -p coldframe-uplink -p coldframe-sensing -p coldframe-transport` -- expected: success, no warnings
+- `cargo clippy --workspace --all-targets --locked -- -D warnings && cargo test --workspace --locked` -- expected: green
+- `pnpm --filter @coldframe/crypto-spec run check` -- expected: nothing stale
+- `docs/test.sh && docs/check-links.sh && docs/check-commands.sh --list` -- expected: pass, with the new pages listed
+- `buf lint` / `packages/proto/check-compat.sh` if `buf` is installed -- expected: pass
+- `actionlint .github/workflows/docs.yml` if available -- expected: clean
+- If the esp toolchain is available: `cargo build --release` in `apps/rs/node` and `apps/rs/hub` -- expected: still builds after the protocol module change
+
+## Auto Run Result
+
+Status: done
+
+**Summary.** Added the firmware learning reference `docs/firmware/`: an index plus 7 topic pages (identity, sealing, ESP-NOW, deep sleep, buttons, BLE setup, radio coexistence). Each page explains its mechanism, lists the key constants and points to the code by path and symbol. It states the required facts (irreversible eFuse burn, unmeasured ~100 µA budget, no WPA3-only, short vs long press, never erase `cf_frame`) and flags where the code differs from the planning docs. Made every public item of `packages/rs/*` documented, including the generated wire types: proto comments now become rustdoc, and the `missing_docs` allow is gone. Fixed the 2 existing rustdoc warnings at their source. Added a `Rust docs` job (`cargo doc` with `RUSTDOCFLAGS=-D warnings`) to the Docs workflow. No human-only acceptance criteria, so no operator actions.
+
+**Files changed.**
+- `docs/firmware/{README,identity,sealing,espnow,deep-sleep,buttons,ble-setup,radio-coexistence}.md` -- the new reference.
+- `packages/rs/protocol/build.rs` -- keeps proto source info, so comments become rustdoc.
+- `packages/rs/protocol/src/lib.rs` -- `setup_v1`/`device_v1` are now documented glob re-export modules, and no `missing_docs` allow remains.
+- `packages/proto/coldframe/setup/v1/setup.proto` -- comments on 8 enum values (comment-only).
+- `packages/crypto-spec/scripts/lib/spec.ts` -- the namespace URL is now in a code span. The generated outputs are regenerated: `packages/rs/crypto/src/spec.rs`, `packages/cs/crypto/Generated/CryptoSpec.g.cs` and the Kotlin `CryptoSpec.kt`.
+- `packages/rs/sensing/src/lib.rs` -- unambiguous `mod@env` link.
+- `.github/workflows/docs.yml` -- new `rustdoc` job, root Cargo files added to the path filters, header updated.
+- `docs/check-commands.sh` -- `docs/firmware/*.md` added to the default files, header updated.
+- `docs/test.sh` -- fixture test for the firmware glob.
+- `README.md`, `apps/rs/README.md`, `packages/rs/README.md` -- links to the reference.
+
+**Review findings.** 18 findings: high 0, medium 4, low 13, false 1.
+- **Patches applied (8 entries):** medium ×3 (ble-setup table facts; Docs path filters for root Cargo files, grouped with the verification-gap row; test for the `docs/firmware` glob) and low ×5 (identity "same file" wording, headers, buttons table layout, `build.rs` comment, `mock` feature in the rustdoc gate).
+- **Deferred (1):** stale Hub "only unsafe code" comments, which are pre-existing.
+- **Rejected:**
+ - Hand-listed crates: they match `ci.yml`, and the alternative is the same list or more complexity.
+ - No CI `cargo doc` for `apps/rs` (raised twice, once by the intent audit): the AC scopes the gate to `packages/rs/*`.
+ - The `ci:manual` browser block: a cosmetic choice, and `ci:run` would put the host toolchain in the esp job.
+ - Symbol and constant checker: adds complexity, and the 10-2 review rejected the same idea.
+ - Uplink page and measurement page: outside the 7 AC topics.
+ - ID glossary and dated links: the links are guarded and the harm is low.
+ - "Uncommitted": false, because the commit happens at finalization.
+
+**Follow-up review recommended: true.** Patched: high 0, medium 3, low 5. The named unverified risk: the 7 topic pages were drafted from the code and only spot-checked. The review itself found two wrong constants in `ble-setup.md`, so other cited constants, timings or symbol names may still be inaccurate. A second pass should check every "Key constants" table and "Where to read the code" symbol against the source.
+
+**Verification.** All of the following passed:
+- `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --locked --features coldframe-hal/mock -p` over the 7 crates: 0 warnings.
+- `cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -D warnings` and `cargo test --workspace --locked`: no failures.
+- `pnpm --filter @coldframe/crypto-spec run check`: all outputs fresh.
+- `docs/test.sh`: 50/50 pass. A mutation run that reverted the glob makes the new test fail.
+- `docs/check-links.sh`: 819 links and paths resolve.
+- `docs/check-commands.sh --list`: OK.
+- `cargo build --release` of `apps/rs/node` and `apps/rs/hub` on the esp toolchain: builds.
+
+`buf` and `actionlint` are not installed locally, so buf lint/breaking and actionlint did not run. The proto change is comment-only.
+
+**Residual risks.**
+- The `Rust docs` job is not a required check.
+- Any future proto item without a comment now fails clippy in the required `rust` job too, not only the Docs job. This is intended, but stricter than before.
+- `cargo doc` inside `apps/rs/node` still prints one warning of its own. That is outside the AC scope.
+
diff --git a/apps/rs/README.md b/apps/rs/README.md
index 255a1b7e..aadf7ab8 100644
--- a/apps/rs/README.md
+++ b/apps/rs/README.md
@@ -23,4 +23,7 @@ cargo build --features dev-mode
./check-image.sh
```
+To learn how the firmware works (ESP-NOW, BLE setup, eFuse keys, sealing, deep sleep), read the
+[firmware reference](../../docs/firmware/README.md).
+
On-device behaviour is checked by hand against the checklists in [`docs/bench`](../../docs/bench).
diff --git a/docs/check-commands.sh b/docs/check-commands.sh
index ae7570fc..8215856a 100755
--- a/docs/check-commands.sh
+++ b/docs/check-commands.sh
@@ -1,18 +1,21 @@
#!/usr/bin/env bash
-# Runs the commands the build guide documents (Story 10.2). Firmware builds only, never flashing.
+# Runs the commands the build guide (Story 10.2) and the firmware reference (Story 10.4) document.
+# Firmware builds only, never flashing.
#
# docs/check-commands.sh [--list] [file...]
#
-# The default files are docs/build/*.md, relative to the repository root unless absolute. Every
-# fenced bash/sh block must be preceded by a marker (blank lines between them are allowed):
+# The default files are docs/build/*.md and docs/firmware/*.md (Story 10.4), relative to the
+# repository root unless absolute. Every fenced bash/sh block must be preceded by a marker (blank
+# lines between them are allowed):
#
# the block runs in that directory, after
# `source ~/export-esp.sh` when it exists (the guide's
# install block creates it), with errexit and pipefail;
# the block needs a board: only `espflash --help`
# runs, for every espflash subcommand in it;
-# the block needs a phone, Xcode, the Android SDK or a cluster
-# (Story 10.3): nothing runs; --list shows it as manual.
+# the block needs a phone, Xcode, the Android SDK, a cluster
+# (Story 10.3) or a browser (Story 10.4): nothing runs;
+# --list shows it as manual.
#
# An unmarked block, a marker that no bash/sh block follows, an unknown ci: marker or a ci:run
# directory that does not exist fails. --list prints every block with its marker and runs nothing.
@@ -31,7 +34,7 @@ for arg in "$@"; do
case ${arg} in
--list) list=true ;;
-h | --help)
- sed -n '2,21p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
+ sed -n '2,24p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 0
;;
-*)
@@ -43,7 +46,7 @@ for arg in "$@"; do
esac
done
if [[ ${#files[@]} -eq 0 ]]; then
- for file in "${repo}"/docs/build/*.md; do
+ for file in "${repo}"/docs/build/*.md "${repo}"/docs/firmware/*.md; do
[[ -f ${file} ]] && files+=("${file}")
done
fi
@@ -199,7 +202,7 @@ for file in "${files[@]}"; do
echo "${where}: ci:manual"
indent "${block}"
else
- echo "==> ${where}: ci:manual (not run: needs a phone, Xcode, the Android SDK or a cluster)"
+ echo "==> ${where}: ci:manual (not run: needs a phone, Xcode, the Android SDK, a cluster or a browser)"
fi
;;
esac
diff --git a/docs/firmware/README.md b/docs/firmware/README.md
new file mode 100644
index 00000000..79877efc
--- /dev/null
+++ b/docs/firmware/README.md
@@ -0,0 +1,74 @@
+# Firmware reference
+
+The Coldframe firmware is written in Rust for the ESP32-S3, and it is meant to be read. This
+reference explains how the Node and the Hub use ESP-NOW, BLE provisioning, eFuse keys, frame
+sealing and deep sleep, and points to the code that does it. To build and flash the firmware, use
+the [build guide](../build/README.md) instead; this reference assumes you have the repository open
+next to it.
+
+Each page explains one mechanism in prose, lists its key constants and timings, and ends with
+**Where to read the code**: repository paths plus the symbol to look for. The architecture
+decisions (AD-n) are in the
+[architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md),
+and the Node and Hub requirements (N-n, H-n) in the
+[device hardware spec](../../_bmad-output/specs/spec-coldframe/device-hardware.md). Where those
+planning documents and the code disagree, the pages describe the code and say so.
+
+## Reading order
+
+1. [Identity](identity.md): the eFuse root key, the HKDF purpose keys and the Device ID.
+2. [Sealing](sealing.md): ChaCha20-Poly1305 frames, the frame counter and the replay window.
+3. [ESP-NOW](espnow.md): how a Node finds its Hub, sends a burst and follows the Hub's channel.
+4. [Deep sleep](deep-sleep.md): the Node's wake cycle and the power budget.
+5. [Buttons](buttons.md): the setup button, short press and long press.
+6. [BLE setup](ble-setup.md): the custom setup protocol and its proof-of-possession code.
+7. [Radio coexistence](radio-coexistence.md): Wi-Fi, ESP-NOW and BLE on one Hub radio.
+
+Identity and sealing come first because every later page sends or stores something sealed with
+those keys.
+
+## Crate map
+
+The logic lives in host-testable crates under `packages/rs`, behind the hardware traits of
+`coldframe-hal` (AD-24). Only the `src/board` module of a firmware crate names esp-hal types.
+[`packages/rs/README.md`](../../packages/rs/README.md) and
+[`apps/rs/README.md`](../../apps/rs/README.md) describe each crate in full.
+
+| Crate | Folder | What to learn from it |
+| --- | --- | --- |
+| `coldframe-hal` | `packages/rs/hal` | The hardware traits (radio, eFuse, HMAC, flash, BLE, Wi-Fi) and their host mocks |
+| `coldframe-crypto` | `packages/rs/crypto` | The key hierarchy, frame sealing, the replay window, HPKE enrolment, the setup session keys |
+| `coldframe-protocol` | `packages/rs/protocol` | The wire messages generated from `packages/proto`, and the ESP-NOW message codec |
+| `coldframe-setup` | `packages/rs/setup` | The BLE setup service: setup code, framing, session state machine, stored records |
+| `coldframe-uplink` | `packages/rs/uplink` | The Hub's Wi-Fi uplink: access-point choice, heartbeats, the relay of Node frames |
+| `coldframe-sensing` | `packages/rs/sensing` | The Node's wake cycle, the button, the flash-reserved counter |
+| `coldframe-transport` | `packages/rs/transport` | The Node's ESP-NOW transport, report buffer and channel following |
+| `coldframe-hub` | `apps/rs/hub` | The Hub firmware: boot order, board drivers. See [its README](../../apps/rs/hub/README.md) |
+| `coldframe-node` | `apps/rs/node` | The Node firmware: one wake per boot, board drivers. See [its README](../../apps/rs/node/README.md) |
+
+## Two toolchains
+
+- **Host, Rust 1.97.** The Cargo workspace in the repository root holds the seven `packages/rs`
+ crates and their tests in `tests/rs`. `rust-toolchain.toml` pins the host toolchain, and the
+ workspace runs clippy, the tests and the API docs with it.
+- **esp, Rust 1.95.** The firmware crates in `apps/rs/hub` and `apps/rs/node` build for
+ `xtensa-esp32s3-none-elf` with the `esp` toolchain from `espup`, so they stay outside the
+ workspace. Because the firmware links the `packages/rs` crates, those crates declare
+ `rust-version = "1.95"` and must not use anything newer. [flash.md](../build/flash.md#toolchain)
+ has the versions CI pins.
+
+## Open the API docs
+
+Every public item of the `packages/rs` crates is documented, including the wire types generated
+from the `.proto` comments. CI builds the docs with warnings as errors. To browse one crate's docs
+from the repository root:
+
+
+
+```sh
+cargo doc --no-deps -p coldframe-transport --open
+```
+
+Replace `coldframe-transport` with any crate of the map above that lives in `packages/rs`. The
+firmware crates themselves need the esp toolchain: run `cargo doc --no-deps --open` in `apps/rs/node` or
+`apps/rs/hub` after `source ~/export-esp.sh`.
diff --git a/docs/firmware/ble-setup.md b/docs/firmware/ble-setup.md
new file mode 100644
index 00000000..bcdb2382
--- /dev/null
+++ b/docs/firmware/ble-setup.md
@@ -0,0 +1,215 @@
+# BLE setup and the proof-of-possession code
+
+A new Hub or Node knows nothing about your Wi-Fi, your Site or your Server. The app tells it over
+Bluetooth Low Energy, through a small custom GATT service. Before any setting crosses the air, the
+two sides agree on session keys, and the Device's setup code, the proof-of-possession (PoP) code, is
+mixed into those keys. The code itself is never sent. This page follows one setup session from the
+first BLE write to the stored record. It is part of the [firmware reference](README.md); the
+previous page, [buttons.md](buttons.md), shows how a long press opens the Node's setup window, and
+the next, [radio-coexistence.md](radio-coexistence.md), covers BLE, Wi-Fi and ESP-NOW on one radio.
+
+The rules come from AD-25 (the setup protocol) and AD-12 (the key hierarchy and enrolment) in the
+[architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md).
+To try it on a bench, use the [Hub setup checklist](../bench/hub-setup-checklist.md) and the
+[Node setup checklist](../bench/node-setup-checklist.md).
+
+## How it works
+
+### The setup code
+
+On first boot the Device draws 40 bits from its TRNG and writes them as 8 characters of the
+Crockford base32 alphabet (digits and uppercase letters without I, L, O and U). See
+`packages/rs/setup/src/code.rs` (`SetupCode::generate`). The code goes into the `cf_setup` flash
+partition once and is then read back on every boot. It is shown in one place only: a plain serial
+line `setup code=XXXXXXXX`, printed with `esp_println`, never as a log record.
+
+- The Hub prints it on every boot while it is unprovisioned, and never once it is provisioned.
+- The Node draws it on its first long press and prints it each time it enters setup mode.
+
+`SetupCode` deliberately implements neither `Debug` nor `Display`, so it cannot leak into a log by
+accident. A corrupt code record is never regenerated, because the old code may already be on a
+sticker: the Hub halts, and the Node logs an error and skips setup mode.
+
+### The GATT service and framing
+
+The service UUID is `c01d0001-5e70-4c0d-8f00-00000000c0de`. The app writes to the `…0002`
+characteristic and the Device answers with notifications on `…0003` (`SERVICE_UUID` and friends in
+`packages/rs/setup/src/lib.rs`). The Device advertises the service UUID, with the name
+`Coldframe Hub XXXX` or `Coldframe Node XXXX` (the first four Device ID hex digits) in the scan
+response. The BLE address is a static random address derived from the Device ID.
+
+A protocol frame can be larger than one BLE write, so each write or notification is
+`header(1) ‖ fragment`. Header `0x01` marks the last fragment of a frame, `0x00` says more follow.
+`packages/rs/setup/src/framing.rs` splits frames (`fragments`) and joins them again
+(`Reassembler`). A frame that grows past 1152 bytes is dropped, together with its remaining
+fragments. The board adapter sizes payloads from the connection's ATT MTU minus 3, between 20 and 248
+bytes (`max_payload` in `apps/rs/hub/src/board/ble.rs`).
+
+### Opening the session
+
+The first frame on a connection is a plaintext `SessionHello`: protocol version 1 and the app's
+ephemeral X25519 public key. The Device draws its own ephemeral private key from the TRNG, answers
+with a plaintext `SessionHelloReply` carrying its public key, and both ends now compute the same
+X25519 shared secret. A malformed hello, a wrong version or a key that is not 32 bytes closes the
+connection without a reply.
+
+### Mixing in the PoP code
+
+`packages/rs/crypto/src/setup.rs` (`derive_keys`) turns the shared secret into two keys:
+
+```rust
+// salt = the setup code, uppercased; info = label ‖ app_pub ‖ device_pub
+let mut extract = HkdfExtract::::new(Some(salt));
+extract.input_ikm(&shared);
+hkdf.expand_multi_info(&[SETUP_LABEL.as_bytes(), app_public, device_public], &mut okm)
+```
+
+The 64 output bytes split in two: bytes 0..32 seal app → Device, bytes 32..64 seal Device → app.
+The X25519 secret alone would give an eavesdropper-proof channel, but not a proof that the app is
+talking to *your* Device. With the code as HKDF salt, only someone who read the code from that
+Device's serial line (or sticker) derives the right keys. Both public keys go into `info`, so the
+keys belong to this one exchange.
+
+### Sealed messages and counters
+
+Every later frame is a `SealedSetupMessage`: protocol version, a `u64` counter and a
+ChaCha20-Poly1305 ciphertext of one `SetupMessage`. `SetupSession` in the same file keeps one
+counter per direction, both starting at 0. The nonce is four zero bytes followed by the counter in
+big-endian; the associated data is the one-byte protocol major. Because each direction has its
+own key, the two counter sequences never share a nonce. `SetupSession::open` refuses a counter
+that is not above every counter it opened before, so a replayed message does not open twice.
+
+### A wrong code
+
+If the user types the wrong code, the two sides derive different keys and the Device cannot open
+the app's first sealed message. `SetupSession::open` reports that first failure as
+`WrongSetupCode`. The Device then still sends one `SetupError` (code `MALFORMED_MESSAGE`), sealed
+under its own keys, and disconnects. The app cannot open that reply either, and that is how it
+knows the code was wrong (`AppError::WrongSetupCode` in `packages/rs/setup/src/app.rs`). Neither
+side learns the other's code. A failure on any *later* message means tampering or replay: the
+Device closes the connection without a word.
+
+> [!NOTE]
+> There is no retry counter or lock-out in the firmware. A wrong code costs one connection; the
+> app reconnects and starts a fresh exchange with fresh keys.
+
+### The message flow
+
+`packages/rs/setup/src/session.rs` (`Session`) is the Device side as a state machine without I/O.
+It takes whole frames and answers with an `Action`: send, close, scan, join, check the Server,
+leave, or persist. `packages/rs/setup/src/service.rs` (`run_setup`) carries those actions out on
+real hardware. The message set lives in `packages/proto/coldframe/setup/v1/setup.proto`.
+
+For a **Hub**, a typical session runs:
+
+1. `IdentityRequest` → `Identity`: the Device ID, kind `HUB` and the firmware version.
+2. `WifiScanRequest` → `WifiScanList`: one entry per SSID (its strongest access point), strongest
+ first, at most 16 networks, each with its security.
+3. `SiteBinding`: a Site ID and the Server URL (`https://` plus a host). No reply on success.
+4. `EnrolmentRequest` → `EnrolmentResponse` (see below).
+5. `WifiConfig` (SSID and passphrase) → `WifiResult`. Accepted only after a binding and an
+ enrolment in the same session. The Hub scans again and joins the strongest *supported* access
+ point for that SSID (`select_bssid` in `packages/rs/uplink/src/bssid.rs`).
+
+After a successful join the Hub checks the Server before it stores anything: DHCP, SNTP and one
+signed heartbeat, all within 40 s (`check_server`). Only an accepted heartbeat stores the
+provisioning record and answers `CONNECTED`. Anything else makes the Hub leave the network, store
+nothing and answer `NO_SERVER`; the session stays open so the user can retry. Once the record is
+stored, a second binding, enrolment or Wi-Fi config is `UNEXPECTED_MESSAGE`.
+
+> [!WARNING]
+> WPA3-only Wi-Fi networks are unsupported. They appear in the scan list as
+> `WIFI_SECURITY_WPA3_ONLY`, but they are not selectable: joining one answers
+> `UNSUPPORTED_SECURITY`. Open, WPA2-Personal and
+> WPA2/WPA3 transition networks work; a transition network is joined with WPA2.
+
+A **Node** (`Session::node`) never touches Wi-Fi: `WifiScanRequest` and `WifiConfig` are
+`UNEXPECTED_MESSAGE`. Its `SiteBinding` must carry a Site ID and a Lot ID and no Server URL, and
+it is only validated: the Server, not the Node, holds the Lot assignment (AD-18). What matters for
+a Node is the enrolment.
+
+### Enrolment: K_dev to the Server
+
+The app fetches the Server's X25519 enrolment key over TLS and sends it in `EnrolmentRequest`,
+with its SHA-256 fingerprint in hex. The Device refuses a mismatch (`FINGERPRINT_MISMATCH`). This
+is an integrity check only, since the fingerprint travels with the key; authenticity comes from
+the app's TLS fetch.
+
+The Device then seals its device key `K_dev` to that key with HPKE (RFC 9180 base mode,
+DHKEM(X25519, HKDF-SHA256), ChaCha20-Poly1305), `info = "coldframe/enrolment/v1"` and the Device
+ID as associated data. See `packages/rs/crypto/src/hpke.rs` (`seal_enrolment`). The ephemeral
+HPKE key comes from 32 fresh TRNG bytes. The app relays the `EnrolmentResponse` to the Server
+unread: it cannot open it, and neither can anyone listening on BLE.
+
+### What is stored
+
+The `cf_setup` partition (`packages/rs/setup/src/store.rs`) has two records, each in its own
+4 KiB sector, each with a magic, a version and a 4-byte SHA-256 check:
+
+- `CFPC` at `0x0000`: the setup code. Written once, on first boot.
+- `CFWP` at `0x1000`: the Hub's provisioning record, version 2: SSID, passphrase, Site ID and
+ Server URL. Written only after a join and a passed Server check. An erased or corrupt sector
+ means unprovisioned. An older version-1 record (no Server address) also counts as corrupt, so
+ such a Hub is set up again.
+
+The Node stores nothing during setup except its setup code: no Site, no Lot and no Wi-Fi settings.
+
+### The Node's setup window
+
+A long press starts setup mode on the Node (`setup_mode` in `apps/rs/node/src/main.rs`). It
+starts the BLE controller, raises the RTC watchdog to the window plus 30 s, and runs
+`run_node_setup` (`packages/rs/setup/src/node.rs`). The window lasts 180 s from the moment it
+starts advertising, measured on the RTC uptime. Each connection idles out after 60 s without a
+write, and every wait is bounded by what is left of the window. After any connection that handed
+out the sealed `K_dev`, the window ends as `enrolled`; otherwise the Node advertises again until
+the deadline. Then the BLE controller is dropped and the Node finishes its normal wake.
+
+The Hub has no window: it advertises for as long as it is unprovisioned, and a connection idles
+out after 300 s.
+
+## Key constants and timings
+
+| Constant | Value | Where |
+| --- | --- | --- |
+| `CODE_LENGTH` | 8 Crockford base32 characters (40 bits) | `packages/rs/setup/src/code.rs` |
+| `SERVICE_UUID_STR` | `c01d0001-5e70-4c0d-8f00-00000000c0de` (`SERVICE_UUID` is the `u128` form) | `packages/rs/setup/src/lib.rs` |
+| `MAX_FRAME` | 1152 bytes per reassembled frame | `packages/rs/setup/src/lib.rs` |
+| `HEADER_LAST` / `HEADER_MORE` | `0x01` / `0x00` | `packages/rs/setup/src/framing.rs` |
+| `MAX_PAYLOAD` | 248 bytes, the largest BLE payload | `apps/rs/hub/src/board/ble.rs`, `apps/rs/node/src/board/ble.rs` |
+| `SETUP_LABEL` | `coldframe/setup/v1` (HKDF info prefix) | `packages/rs/crypto/src/spec.rs` |
+| `SETUP_OKM_LENGTH` | 64 bytes, two 32-byte direction keys | `packages/rs/crypto/src/spec.rs` |
+| `SETUP_FIRST_COUNTER` | 0, per direction | `packages/rs/crypto/src/spec.rs` |
+| `ENROLMENT_INFO` | `coldframe/enrolment/v1` (HPKE info) | `packages/rs/crypto/src/spec.rs` |
+| `SESSION_IDLE_TIMEOUT_MS` | 300 000 ms (Hub) | `packages/rs/setup/src/lib.rs` |
+| `SETUP_WINDOW_MS` | 180 000 ms (Node) | `packages/rs/setup/src/node.rs` |
+| `NODE_SESSION_IDLE_TIMEOUT_MS` | 60 000 ms (Node) | `packages/rs/setup/src/node.rs` |
+| `SERVER_CHECK_TIMEOUT_MS` | 40 000 ms (Hub Server check) | `packages/rs/uplink/src/lib.rs` |
+| `MAX_NETWORKS` | 16 networks per scan list | `packages/rs/setup/src/lib.rs` |
+| `PROVISIONING_VERSION` | 2 (`CFWP` record) | `packages/rs/setup/src/store.rs` |
+
+## Where to read the code
+
+- `packages/rs/crypto/src/setup.rs` (`derive_keys`, `SetupSession`): the key derivation with the
+ PoP code as salt, the per-direction counters and the wrong-code signal.
+- `packages/rs/crypto/src/hpke.rs` (`seal_enrolment`, `fingerprint`): HPKE sealing of `K_dev` to
+ the Server's enrolment key.
+- `packages/rs/setup/src/lib.rs`: the crate overview and the transport contract.
+- `packages/rs/setup/src/code.rs` (`SetupCode`): drawing, parsing and hiding the setup code.
+- `packages/rs/setup/src/framing.rs` (`fragments`, `Reassembler`): frames over BLE payloads.
+- `packages/rs/setup/src/session.rs` (`Session`): the Device-side state machine; its module docs
+ list every Hub and Node rule.
+- `packages/rs/setup/src/service.rs` (`run_setup`): the Hub's loop that drives link, Wi-Fi, Server
+ check and flash.
+- `packages/rs/setup/src/node.rs` (`run_node_setup`): the Node's time-bounded window.
+- `packages/rs/setup/src/store.rs` (`load_or_create_code`, `store_provisioning`): the `cf_setup`
+ records.
+- `packages/rs/setup/src/app.rs` (`AppClient`): the app side in Rust, used by host tests and the
+ desktop bench client in `tests/rs/setup-client`.
+- `apps/rs/node/src/main.rs` (`setup_mode`): how a long press brings up BLE and the window.
+- `apps/rs/hub/src/board/ble.rs` and `apps/rs/node/src/board/ble.rs` (`BoardSetupLink`): the
+ TrouBLE GATT server and advertising behind the `SetupLink` trait.
+- `packages/proto/coldframe/setup/v1/setup.proto`: the message set, with the wrong-code rule in
+ its header comment.
+- Hub and Node overviews: [apps/rs/hub/README.md](../../apps/rs/hub/README.md),
+ [apps/rs/node/README.md](../../apps/rs/node/README.md),
+ [packages/rs/README.md](../../packages/rs/README.md).
diff --git a/docs/firmware/buttons.md b/docs/firmware/buttons.md
new file mode 100644
index 00000000..16106e32
--- /dev/null
+++ b/docs/firmware/buttons.md
@@ -0,0 +1,130 @@
+# The setup button
+
+The Node has one button. A short press asks for a report now; a long press opens the BLE setup
+window. This page shows how a sleeping chip notices the press, how the firmware tells a bounce, a
+short press and a long press apart, and how the Node goes back to sleep afterwards. It is part of
+the [firmware reference](README.md). Read [deep-sleep.md](deep-sleep.md) first, because every press
+is a wake from deep sleep. The setup window itself is on the next page, [ble-setup.md](ble-setup.md).
+
+> [!NOTE]
+> Only the Node has a button. The Hub firmware (`apps/rs/hub/src/main.rs`) reads no button: it
+> advertises the setup service from boot until it is provisioned, and never after.
+
+## How it works
+
+### Wiring: one switch to ground
+
+The button is a momentary switch from GPIO7 to ground. It is active low: the internal pull-up holds
+the pin high while it is released, and pressing it pulls the pin low. GPIO7 is one of the ESP32-S3's
+low-power (RTC) pads, so it can wake the chip from deep sleep. The full pin table is in
+`apps/rs/node/src/board/pins.rs` and in the [device hardware spec](../../_bmad-output/specs/spec-coldframe/device-hardware.md).
+
+### Waking on the button
+
+The Node spends almost all its time in deep sleep, with the CPU and RAM off. Just before it sleeps,
+it arms two wake sources: the RTC timer for the next scheduled measurement, and a low level on the
+button. `BoardButton::arm_wake` asks esp-hal for a wakeup with the low-power path and listens for
+`Event::LowLevel`. esp-hal then assigns the pad to the `ext0` or `ext1` wake source at sleep entry,
+holds the pad and keeps its pull-up on through the sleep.
+
+Every wake from deep sleep is a reset into `main`. `wake_cause` reads why the chip woke: `ext0` or
+`ext1` means the button, the timer means a scheduled wake, and anything else is a cold boot.
+
+There is one guard. `arm_button_wake` arms the button only while it reads released. A stuck or
+still-held button would otherwise wake the Node again at once, in a loop that drains the cell. In
+that case the Node sleeps with the timer wake only and logs `setup button still held; timer wake
+only`.
+
+> [!NOTE]
+> The button is only looked at on a button wake. A press while the Node is already awake (during a
+> scheduled measurement) does nothing, and a button held at power-on is not timed either.
+
+### Timing the press
+
+A press can only be timed while the finger is still on the button. So on a button wake, `main`
+calls `classify_press` before any flash or identity work. Only the bootloader delay eats into the
+press.
+
+`classify_press` polls the pin every 10 ms and remembers the time of the last poll that saw the
+button held. Then:
+
+- **Released before 50 ms**: a bounce, or a tap already over by the first poll. That is
+ `Press::Spurious`.
+- **Released before 3 s**: `Press::Short`, "report now".
+- **Still held at 3 s**: `Press::Long`. It returns at once, without waiting for the release, so the
+ Node enters setup mode while the finger is still on the button.
+- **A pin read error**: `Press::Spurious`.
+
+The debounce is a floor, not a filter: the first poll that reads the button released ends the
+timing. A contact that opens for a moment and is caught by a poll ends the press there, so anything
+shorter than 50 ms counts as nothing rather than as a short press.
+
+### What each press does
+
+`wake_plan` turns the wake cause and the press into one of four plans, logged as
+`wake plan=…`:
+
+| Wake | Plan | What happens |
+| --- | --- | --- |
+| Timer or cold boot | `measure` | The scheduled measurement and send. |
+| Button, short press | `report-now` | Measure and send at once, outside the 15-minute schedule (N-3). |
+| Button, long press (>= 3 s) | `setup` | The BLE setup window, then a measurement that is buffered, not sent. |
+| Button, bounce | `sleep-again` | No measurement; straight back to sleep. |
+
+A short press is how you get a fresh value during calibration in seconds instead of waiting up to
+15 minutes. It does not start BLE.
+
+A long press is the only path that ever starts BLE. `setup_mode` loads the setup code from
+`cf_setup` (drawing it on the first long press), prints it as `setup code=…`, raises the RTC
+watchdog to the window plus 30 s, and runs the setup window for at most 180 s. The window starts
+when advertising starts, not at the press. It ends early once an enrolment session's connection has
+ended. After the window the Node measures as usual, but the report only goes into the flash buffer:
+it is sent at the next wake. [ble-setup.md](ble-setup.md) covers the window and the protocol (AD-25).
+
+### Sleeping again
+
+`sleep_after` decides how long the next sleep is. A scheduled `measure` wake sleeps for the rest of
+its period, so the schedule keeps its rhythm. Every press (`report-now`, `setup` and
+`sleep-again`) restarts the schedule: the Node sleeps a full 15-minute period from the end of the
+wake. The log line `sleep ms=900000 button_armed=true` shows it.
+
+Before sleeping, `arm_button_wake` checks the button again. After a long press the finger is
+normally off the button by the time the window has run, so the button wake is armed again.
+
+## Key constants and timings
+
+| Constant | Value | Where |
+| --- | --- | --- |
+| `PRESS_POLL_MS` | Poll interval 10 ms | `packages/rs/sensing/src/button.rs` |
+| `DEBOUNCE_MS` | Debounce floor 50 ms | `packages/rs/sensing/src/button.rs` |
+| `LONG_PRESS_MS` | Long press 3 000 ms | `packages/rs/sensing/src/button.rs` |
+| `SETUP_WINDOW_MS` | Setup window 180 000 ms (3 min) | `packages/rs/setup/src/node.rs` |
+| `NODE_SESSION_IDLE_TIMEOUT_MS` | Idle connection dropped after 60 000 ms | `packages/rs/setup/src/node.rs` |
+| `SETUP_WATCHDOG_MS` | Watchdog in setup mode: window + 30 s | `apps/rs/node/src/main.rs` |
+| `WAKE_PERIOD_MS` | Full sleep after a press 900 000 ms (15 min) | `packages/rs/sensing/src/wake.rs` |
+| `take_pins` | Button pin GPIO7, active low | `apps/rs/node/src/board/pins.rs` |
+
+## Where to read the code
+
+- `packages/rs/sensing/src/button.rs` (`classify_press`): the press timing, host-tested against a
+ fake pin and timer in `tests/rs/sensing/tests/button.rs`.
+- `packages/rs/sensing/src/button.rs` (`wake_plan`, `sleep_after`): the four plans and the sleep
+ each one takes; BLE is reachable only from `WakePlan::Setup`.
+- `packages/rs/sensing/src/button.rs` (`arm_button_wake`): the stuck-button guard.
+- `apps/rs/node/src/board/button.rs` (`BoardButton`): the pull-up and the deep-sleep wake on a low
+ level.
+- `apps/rs/node/src/board/sleep.rs` (`wake_cause`, `deep_sleep`): how `ext0`/`ext1` becomes a
+ button wake, and where the wake is armed.
+- `apps/rs/node/src/main.rs` (`main`, `setup_mode`): the press timed first, then the plan, the
+ setup window and the final sleep.
+- `packages/rs/setup/src/node.rs` (`run_node_setup`): the window's deadline and how it ends.
+
+Related reading:
+
+- [Node setup bench checklist](../bench/node-setup-checklist.md): the hand test of each press,
+ including the shortest press that still counts.
+- [Node README](../../apps/rs/node/README.md): the wake sequence as a whole.
+- [Device hardware spec](../../_bmad-output/specs/spec-coldframe/device-hardware.md): N-3 (report
+ now) and the Story 4.2 button and setup-mode notes.
+- [Architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md):
+ AD-25, the BLE setup protocol the long press opens.
diff --git a/docs/firmware/deep-sleep.md b/docs/firmware/deep-sleep.md
new file mode 100644
index 00000000..36472adb
--- /dev/null
+++ b/docs/firmware/deep-sleep.md
@@ -0,0 +1,172 @@
+# Deep sleep and the wake cycle
+
+A Coldframe Node spends almost all of its life in deep sleep. It wakes every 15 minutes, measures,
+sends, and sleeps again. This page explains how that cycle is built on the ESP32-S3: why every wake
+is a reboot, where the Node keeps its state between wakes, what bounds a wake, and how the sleep
+time is computed. It follows [ESP-NOW transport](espnow.md) and comes before
+[the setup button](buttons.md); the index is [README.md](README.md). For panel placement and the
+power budget in numbers, read [Power and placement](../build/power.md).
+
+## How it works
+
+### Every wake is a reboot
+
+Deep sleep on the ESP32-S3 powers down the CPU and RAM. Only the RTC domain keeps running: the RTC
+timer, the wake sources and the held pads. When the timer fires, the chip resets and the bootloader
+starts the firmware again from the top. There is no "resume" after the sleep call.
+
+So `apps/rs/node/src/main.rs` (`main`) is written as one straight run from top to bottom, ending in
+a call that never returns (`-> !`). The module comment at the top of that file lists the ten steps
+of a wake in order. The deep-sleep call itself is in `apps/rs/node/src/board/sleep.rs`
+(`deep_sleep`): it sets an RTC timer deadline and calls esp-hal's `sleep_deep`.
+
+Because the wake is a reset, the firmware first has to find out why it is running.
+`apps/rs/node/src/board/sleep.rs` (`wake_cause`) reads the chip's wakeup cause:
+
+- `ext0`/`ext1` (the setup button) gives `WakeCause::Button`;
+- the RTC timer gives `WakeCause::Timer`;
+- anything else, such as power-on, a watchdog reset or a flash, is `WakeCause::ColdBoot`.
+
+A cold boot raises the boot ID; a timer or button wake reuses it
+(`packages/rs/sensing/src/wake.rs`, `boot_id`). The boot ID and the RTC uptime together let the
+Server place Readings in time before the Node's clock has been set (AD-11).
+
+### Where the state lives
+
+Nothing in RAM survives the sleep. The Node keeps its state in two places:
+
+- **Flash partitions.** The boot counter (`cf_boot`), the `reading_seq` counter (`cf_seq`), the
+ frame counter (`cf_frame`), the link state (`cf_link`) and the report buffer (`cf_buf`) are all
+ in flash. Their names are in `apps/rs/node/src/board/flash.rs`.
+- **The RTC timer.** It keeps counting through deep sleep, so the uptime read in
+ `apps/rs/node/src/board/rtc.rs` (`BoardRtc::uptime_millis`) grows across the wakes of one boot.
+
+The firmware does not use RTC RAM. The planning notes in
+[device-hardware.md](../../_bmad-output/specs/spec-coldframe/device-hardware.md) allow either RTC
+RAM or flash for the buffer; the code uses flash, which also survives a flat battery.
+
+The wall clock is a good example. `BoardRtc` holds the clock as an offset from the uptime, but that
+offset is lost at every reset. The transport stores it in `cf_link` together with the boot ID it
+belongs to. At the start of each wake, `packages/rs/transport/src/transport.rs` (`begin`) hands it
+back, so the Readings can be stamped `Synced`. A cold boot has a new boot ID, so the old offset no longer
+applies, and the Readings are `Unsynced` until a sealed downlink sets the clock again (AD-11).
+
+### A short, cheap wake
+
+Two choices keep the active current of a wake low:
+
+- **80 MHz CPU clock.** `main` starts esp-hal with `CpuClock::_80MHz`. The comment there gives the
+ reason: it cuts the active current, and 80 MHz is also esp-radio's minimum, so the radio can
+ still start when needed.
+- **Switched loads.** The soil probe and the battery divider sit behind high-side switches.
+ `packages/rs/sensing/src/wake.rs` (`run_wake`) turns each switch on only for its own
+ measurement: probe on, settle 100 ms, eight samples, probe off; divider on, settle 10 ms, four
+ samples, divider off. A switch is turned off on every path out, errors included. The BME680 is
+ not switched: one forced measurement returns it to sleep by itself.
+
+The radio is dropped before the sleep, so it is off while the Node sleeps.
+
+### The watchdog bounds every wake
+
+A Node that hangs awake would drain its cell in hours. So the first thing `main` does after
+`esp_hal::init` is arm the RTC watchdog: `apps/rs/node/src/board/rtc.rs` (`arm_watchdog`). Stage 0
+resets the whole chip if the wake has not reached deep sleep within `WATCHDOG_MS` (30 s).
+
+The watchdog is never fed during a wake, and it pauses in sleep. It is fed once when armed, so the
+count starts from zero rather than from what the previous boot left behind. It also catches a
+panic: `esp-backtrace` halts instead of resetting, and the watchdog turns that halt into a reset.
+A watchdog reset is not a timer or button wake, so the next run is a cold boot with a new boot ID.
+
+Setup mode is the one long wake. While the BLE window is open, `setup_mode` raises the watchdog to
+`SETUP_WATCHDOG_MS` (the window plus 30 s) and restores 30 s afterwards. A normal wake is far
+inside 30 s; the module comment in `main.rs` works out the transport's worst case at about 6.2 s.
+
+### The `fail` path: sleep, do not park
+
+If the Node cannot get a boot ID, an identity or the `reading_seq` partition, it cannot produce a
+valid report. Staying awake to retry would only cost battery. Instead `fail` in
+`apps/rs/node/src/main.rs` logs `wake failed stage=… error=…` and deep-sleeps one full period
+(`WAKE_PERIOD_MS`). The next wake tries again from the top. A failed Sensor or a failed transport
+is not a `fail`: the wake goes on, and the report stays buffered for the next wake.
+
+### How long to sleep
+
+The Node aims for one wake every `WAKE_PERIOD_MS`, measured from the start of one wake to the
+start of the next. `main` records the RTC uptime right after the watchdog is armed
+(`wake_start_ms`). After the transport it takes the elapsed time and calls `sleep_ms`:
+
+```rust
+pub fn sleep_ms(elapsed_ms: u64) -> u32 {
+ let rest = u64::from(WAKE_PERIOD_MS).saturating_sub(elapsed_ms);
+ u32::try_from(rest.max(u64::from(MIN_SLEEP_MS))).unwrap_or(WAKE_PERIOD_MS)
+}
+```
+
+So a wake that took 1.2 s sleeps 898.8 s. A wake that somehow ran a whole period still sleeps at
+least `MIN_SLEEP_MS`. The time before `wake_start_ms` (the bootloader and `esp_hal::init`) is not
+counted, so the real period is slightly longer than 15 minutes.
+
+`run_wake` also returns a `sleep_ms`, but it is measured before the transport. `main` does not use
+it; it computes the sleep again after the radio work, so the radio time is included.
+
+A press changes the schedule. `sleep_after` in `packages/rs/sensing/src/button.rs` keeps the
+measured rest only for a scheduled wake (`WakePlan::Measure`). After a short press, a long press
+or a bounce, the Node sleeps a full period from now. The [buttons page](buttons.md) covers the
+presses.
+
+### Waking on the button
+
+`sleep` in `main.rs` arms the setup button as a second wake source, next to the timer, but only if
+the button reads released (`arm_button_wake`). A button held or stuck low would otherwise wake the
+Node again at once, in a loop. In that case the Node logs a warning and sleeps on the timer alone.
+`apps/rs/node/src/board/button.rs` (`arm_wake`) enables the pad's low-power wake path, which keeps
+the pull-up through the sleep. Every sleep logs `sleep ms= button_armed=`.
+
+## Power budget
+
+The design budget is an average sleep current of about 100 µA or less, with short wakes. The
+board options and the arithmetic behind it are in [Power and placement](../build/power.md) and in
+the power budget of [device-hardware.md](../../_bmad-output/specs/spec-coldframe/device-hardware.md).
+
+> [!WARNING]
+> The ~100 µA average sleep-current budget is **not yet measured**. No reference Node has been
+> through the bench run. Until it has, treat every current figure as a design estimate.
+
+To measure it, follow the [Node power checklist](../bench/node-power-checklist.md). Any build
+without `dev-mode` burns an eFuse key block on first boot, and that cannot be undone
+([identity.md](identity.md)); the checklist says how to prepare the board.
+
+## Key constants and timings
+
+| Constant | Value | Where |
+| --- | --- | --- |
+| `WAKE_PERIOD_MS` | 900 000 ms (15 min) | `packages/rs/sensing/src/wake.rs` |
+| `MIN_SLEEP_MS` | 1 000 ms | `packages/rs/sensing/src/wake.rs` |
+| `PROBE_SETTLE_MS` | 100 ms | `packages/rs/sensing/src/wake.rs` |
+| `DIVIDER_SETTLE_MS` | 10 ms | `packages/rs/sensing/src/wake.rs` |
+| `WATCHDOG_MS` | 30 000 ms | `apps/rs/node/src/main.rs` |
+| `SETUP_WATCHDOG_MS` | `SETUP_WINDOW_MS` + 30 s = 210 s | `apps/rs/node/src/main.rs` |
+| `CpuClock::_80MHz` | 80 MHz CPU clock | `apps/rs/node/src/main.rs` (`main`) |
+
+## Where to read the code
+
+- `apps/rs/node/src/main.rs` (module comment, `main`): the ten steps of a wake, the 80 MHz
+ setting and the sleep computed after the transport.
+- `apps/rs/node/src/main.rs` (`sleep`, `fail`): the single exit of every wake, and the failure
+ path that sleeps a full period.
+- `apps/rs/node/src/board/sleep.rs` (`wake_cause`, `deep_sleep`): reading the wake cause and
+ entering deep sleep with a timer deadline.
+- `apps/rs/node/src/board/rtc.rs` (`arm_watchdog`, `BoardRtc`): the RTC watchdog, and the uptime
+ that keeps counting through sleep.
+- `apps/rs/node/src/board/button.rs` (`arm_wake`): the setup button as a deep-sleep wake source.
+- `packages/rs/sensing/src/wake.rs` (`run_wake`, `sleep_ms`, `boot_id`): the measurement order,
+ the switched loads, the sleep arithmetic and the boot ID. Host-tested, no hardware needed.
+- `packages/rs/sensing/src/button.rs` (`sleep_after`, `arm_button_wake`): how a press resets the
+ schedule, and why a held button is not armed.
+- [`apps/rs/node/README.md`](../../apps/rs/node/README.md): boot steps, partitions, pins and log
+ lines.
+
+Governing items: AD-11 (time, boot ID and uptime) and AD-17 (bounded radio time) in the
+[architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md);
+the power budget (NFR-4) in
+[device-hardware.md](../../_bmad-output/specs/spec-coldframe/device-hardware.md).
diff --git a/docs/firmware/espnow.md b/docs/firmware/espnow.md
new file mode 100644
index 00000000..56deec62
--- /dev/null
+++ b/docs/firmware/espnow.md
@@ -0,0 +1,169 @@
+# ESP-NOW transport and channel following
+
+A Node never joins your Wi-Fi. It talks to the Hub over ESP-NOW: short, connectionless radio
+datagrams between two ESP32-S3 chips. The Hub is on the Wi-Fi network and relays what it hears to
+the Server. This page shows how a Node finds the Hub, sends its reports and learns that they
+arrived, and how it follows the Hub when the router changes channel. Read
+[sealing.md](sealing.md) first for what is inside a frame; [deep-sleep.md](deep-sleep.md) comes
+next and covers the rest of the wake. The index is [README.md](README.md).
+
+The governing rules are AD-9 (ingestion and acknowledgement), AD-11 (time) and AD-17 (frame
+sequence) in the [architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md),
+and N-1 and N-2 in [device-hardware.md](../../_bmad-output/specs/spec-coldframe/device-hardware.md).
+
+## How it works
+
+### The radio as a datagram port
+
+The transport logic does not know about ESP-NOW. It uses a small trait, `DatagramRadio`, in
+`packages/rs/hal/src/datagram.rs`: set a channel, send one payload (at most 250 bytes) to a MAC
+address or to broadcast, and receive with a timeout. The Node's adapter in
+`apps/rs/node/src/board/espnow.rs` (`BoardEspNow`) takes ESP-NOW from a Wi-Fi controller that
+never associates. The Node creates it after the measurement and drops it before deep sleep, so
+the radio is only on during the transport step.
+
+The key lesson of this layer is N-1: **the send status is not delivery**. Under Wi-Fi and BLE
+coexistence the radio often reports a failed send for a datagram that did arrive. So the
+transport ignores the result of every `send`. Only a sealed downlink from the Server counts as
+proof of delivery.
+
+### Four messages
+
+`packages/rs/protocol/src/radio.rs` (`Message`) defines the whole radio protocol. The first byte
+is the kind:
+
+- **Probe** (Node to broadcast): an 8-byte nonce. It asks "is a Hub on this channel?".
+- **Probe reply** (Hub to Node): the nonce echoed, plus `pending`, the number of downlinks the
+ Hub kept for this Node (0 to 8).
+- **Uplink** (Node to Hub): a sealed frame.
+- **Downlink** (Hub to Node): a sealed acknowledgement from the Server.
+
+Probes and replies are not authenticated. A forged reply can waste one burst, but it cannot
+delete a Reading or reset the miss count: only a downlink that opens under the `ack/v1` key
+can.
+
+### One wake, step by step
+
+`packages/rs/transport/src/transport.rs` (`run`) does the transport for one wake. Its module
+documentation lists the steps; in short:
+
+1. **Buffer** the wake report in flash.
+2. **Find a Hub** (`find_hub`). Probe the channel a Hub last answered on and wait up to 120 ms
+ for a reply with the same nonce (`probe`).
+3. **Collect kept downlinks.** If the reply says `pending > 0`, listen up to 120 ms for them.
+ These are late acknowledgements of the last wake's frames.
+4. **Send a burst.** Reserve one frame counter per report in flash first, then seal and send up
+ to 8 buffered reports, oldest first, unicast to the Hub's MAC address.
+5. **Listen** for up to 300 ms after the last send (`listen`), or until every frame of the
+ burst is acknowledged.
+6. **Remember** the outcome in the link state.
+
+A downlink deletes exactly the Readings it lists. It is *fresh* when it acknowledges a frame of
+this wake's burst or the previous one that no downlink has acknowledged before
+(`Burst::acknowledge` in `packages/rs/transport/src/link.rs`). Only a fresh downlink resets the
+miss count and sets the clock (AD-11).
+
+A frame that is never acknowledged is not resent in the same wake. Its report stays in the
+buffer and goes out again in the next wake's burst, under a new frame counter.
+
+### Channel following
+
+ESP-NOW only works when both chips are on the same Wi-Fi channel. The Node does not choose the
+channel; **the Hub's access point does**. The Hub is a Wi-Fi station, so its radio sits on the
+router's channel. Its ESP-NOW peers are added with `channel: None`
+(`apps/rs/hub/src/board/espnow.rs`, `send`), which means "use the station's current channel". If
+the router moves to another channel, the Hub moves with it, and the Node has to find it again.
+
+The Node does this with the link state, a small record in flash (`LinkState`, magic `CFLK`, in
+`packages/rs/transport/src/link.rs`). Every wake is a reboot, so the channel, the miss count and
+the scan hold-off must live in flash. `find_hub` uses them like this:
+
+- **Known channel first.** If a channel is stored, it is always probed first, every wake.
+- **A miss** is a wake without a fresh downlink. The count goes up by one on each miss and back
+ to 0 on the first fresh downlink.
+- **Scan.** With no stored channel, or after 3 consecutive misses (`RESCAN_AFTER_MISSES`), the
+ Node probes the known channel and then every other channel from 1 to 13. The first channel
+ where a Hub answers becomes the stored channel.
+- **Back-off.** A full scan that finds no Hub sets `scan_holdoff` to 4 (`SCAN_BACKOFF_WAKES`).
+ The next three wakes that want to scan only count it down and probe the known channel. The
+ fourth wake scans again. This stops a Node far from the Hub from scanning on every wake.
+
+> [!NOTE]
+> The Node re-evaluates its channel **only after the link drops**, that is, after 3 misses in a
+> row. There is no re-scan when the signal gets weaker (no RSSI check). A Node keeps using a
+> channel for as long as a Hub answers there.
+
+A lost or corrupt link state is harmless. It reads as the default state, which costs one scan
+and an unsynced clock, never a Reading.
+
+### The Hub relay
+
+The Hub side is a radio task, `relay_task` in `apps/rs/hub/src/board/espnow.rs`, around a plain
+state machine, `Relay` in `packages/rs/uplink/src/relay.rs`. The Hub reads only the kind byte:
+it never opens an envelope.
+
+- **Probes** are answered at once from the relay state, without asking the Server. So a reply
+ never waits for an HTTPS request in flight. A probe is answered only while the Hub can relay
+ (`Relay::set_relaying`): the station is associated and the clock is set. Otherwise a Node
+ would spend its frame counters on frames the Hub would drop.
+- **Uplinks** go into a queue of 8. The uplink task posts them to the Server as one batch. An
+ envelope already queued is not queued twice.
+- **Downlinks** from the Server are sent once, straight away. With a fresh TLS session per
+ request the Node is usually asleep by then. So the Hub also keeps them in RAM, up to 8 per
+ Node out of a pool of 24, and sends them once more right after its reply to that Node's next
+ probe (`Relay::take_kept`). That is the `pending` count in the reply.
+
+A Hub peer entry lives for one send only, so ESP-NOW's limit of 20 peers never fills up.
+
+> [!NOTE]
+> AD-9 says the Hub may keep "the latest" downlink per Node. The code keeps up to 8 per Node
+> (one burst), so a whole burst can be acknowledged at the next wake.
+
+## Key constants and timings
+
+| Constant | Value | Where |
+| --- | --- | --- |
+| `PROBE_WAIT_MS` | 120 ms per channel | `packages/rs/transport/src/transport.rs` |
+| `PENDING_WAIT_MS` | 120 ms for kept downlinks | `packages/rs/transport/src/transport.rs` |
+| `ACK_WINDOW_MS` | 300 ms after the last send (N-2) | `packages/rs/transport/src/transport.rs` |
+| `BURST_MAX` | 8 frames per wake | `packages/rs/transport/src/transport.rs` |
+| `RESCAN_AFTER_MISSES` | 3 consecutive misses | `packages/rs/transport/src/transport.rs` |
+| `SCAN_BACKOFF_WAKES` | 4 wakes after a failed scan | `packages/rs/transport/src/transport.rs` |
+| `FIRST_CHANNEL`, `LAST_CHANNEL` | channels 1 to 13 | `packages/rs/transport/src/transport.rs` |
+| `MAX_PAYLOAD` | 250 bytes per message | `packages/rs/protocol/src/radio.rs` |
+| `PENDING_MAX` | 8 kept downlinks per Node | `packages/rs/protocol/src/radio.rs` |
+| `SEND_TIMEOUT` (Node) | 200 ms per send | `apps/rs/node/src/board/espnow.rs` |
+| `SEND_TIMEOUT` (Hub) | 60 ms per send | `apps/rs/hub/src/board/espnow.rs` |
+| `UPLINK_QUEUE`, `DOWNLINK_POOL` | 8 uplinks, 24 kept downlinks | `packages/rs/uplink/src/relay.rs` |
+
+The Hub's 60 ms send timeout is deliberately shorter than the Node's 120 ms probe wait: a send to
+a Node that is already asleep must not delay the reply to the next probe. The worst-case wake
+(a full scan, 8 frames and the window) is worked out in the module documentation of
+`apps/rs/node/src/main.rs`: about 2.1 s, and 6.2 s if every send times out.
+
+## Where to read the code
+
+- `packages/rs/transport/src/transport.rs`: the module documentation, then `run` for the order
+ of one wake.
+- `packages/rs/transport/src/transport.rs` (`find_hub`, `probe`): the known channel, the scan
+ and the back-off.
+- `packages/rs/transport/src/transport.rs` (`listen`, `Session::apply`): the acknowledgement
+ window and what makes a downlink fresh.
+- `packages/rs/transport/src/link.rs` (`LinkState`, `Burst`): what the Node remembers between
+ wakes, and the flash record layout.
+- `packages/rs/protocol/src/radio.rs` (`Message`): the four radio messages and their encoding.
+- `packages/rs/hal/src/datagram.rs` (`DatagramRadio`): the radio port, and why the send status
+ is ignored.
+- `apps/rs/node/src/board/espnow.rs` (`BoardEspNow`): ESP-NOW without a Wi-Fi association.
+- `apps/rs/hub/src/board/espnow.rs` (`relay_task`, `send`): the Hub's radio task and the
+ `channel: None` peers.
+- `packages/rs/uplink/src/relay.rs` (`Relay`): the uplink queue, the kept downlinks and when
+ probes are answered.
+
+To see it on real boards, follow the
+[Node transport checklist](../bench/node-transport-checklist.md). The radio findings behind N-1
+and the `channel: None` peer scheme (F-7) are in
+[hub-radio-coexistence.md](../spikes/hub-radio-coexistence.md) and
+[radio-coexistence.md](radio-coexistence.md). The build-side READMEs are
+[apps/rs/node/README.md](../../apps/rs/node/README.md) and
+[apps/rs/hub/README.md](../../apps/rs/hub/README.md).
diff --git a/docs/firmware/identity.md b/docs/firmware/identity.md
new file mode 100644
index 00000000..a569ea41
--- /dev/null
+++ b/docs/firmware/identity.md
@@ -0,0 +1,183 @@
+# Device identity: the eFuse root and the key hierarchy
+
+Every Coldframe Device, Hub or Node, has one secret root key. Every other key and the Device ID
+are derived from it. This page shows how the firmware creates that root on first boot, keeps it in
+a read-protected eFuse block, and derives the rest with HMAC and HKDF. It is the first page of the
+[firmware reference](README.md); the next one, [sealing.md](sealing.md), shows how the purpose keys
+seal frames. The rule behind it all is AD-12 in the
+[architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md).
+
+> [!WARNING]
+> **eFuse burns are irreversible.** Any build without `--features dev-mode`, debug or release,
+> burns a key block on its first boot, including a plain `cargo run`. Check the key purposes
+> before you flash ([flash.md](../build/flash.md)) and follow the
+> [Hub identity checklist](../bench/hub-identity-checklist.md), which applies to the Node too.
+
+## How it works
+
+### One root, one derived key
+
+The root is 32 random bytes. It derives exactly one key, the device key `K_dev`:
+
+```text
+K_dev = HMAC-SHA256(key = root, msg = "coldframe/device/v1")
+```
+
+Everything else comes from `K_dev` with HKDF-SHA256 (empty salt, `K_dev` as input key material,
+the label as `info`):
+
+- `seal/v1`, 32 bytes: seals uplink frames (see [sealing.md](sealing.md)).
+- `ack/v1`, 32 bytes: seals downlinks from the Server.
+- `hub-auth/v1`, 32 bytes: the Hub signs its HTTP requests with it.
+- `device-id/v1`, 8 bytes: the Device ID, shown as 16 lowercase hex digits.
+
+`DeviceKeys::from_device_key` computes all of them in one go. `K_dev` itself is kept too, because
+BLE enrolment seals it with HPKE to the Server, which then derives the same purpose keys (see
+[ble-setup.md](ble-setup.md)). The Server never sees the root.
+
+The Device ID is derived, not stored. Two Devices with different roots get different IDs, and the
+same chip always gets the same ID back, boot after boot.
+
+### Release builds: the root lives in an eFuse key block
+
+The ESP32-S3 has six 256-bit eFuse key blocks, `KEY0` to `KEY5`. Each has a 4-bit purpose, a
+read-disable bit and a write-disable bit. A fuse bit, once set, stays set. Coldframe burns the root
+into one block with purpose `HMAC_UP` (raw value 8). The chip then read-protects the block: no
+software can read the key any more. Only the HMAC peripheral can use it.
+
+`provision_efuse` runs on every boot and does this:
+
+1. It looks for a block whose purpose is `HMAC_UP`. If there is exactly one and it is
+ read-protected, that is the identity; nothing is burned (`existing KEYn` in the log).
+2. It refuses to go on, and burns nothing, if two blocks have `HMAC_UP`
+ (`AmbiguousIdentity`), if the one block is not read-protected (`IdentityNotProtected`), or if no
+ identity exists and no block is unused (`NoFreeKeyBlock`).
+3. On first boot it turns the radio on, because the radio is the TRNG's entropy source. Without it
+ `BoardTrng` refuses to run rather than return pseudo-random bytes. It draws 32 bytes and rejects
+ all-zero or all-`0xFF` output (`BadEntropy`).
+4. It burns the bytes into the first unused block with purpose `HMAC_UP`, then re-reads the block
+ and checks the purpose and the read protection (`BurnNotVerified` otherwise).
+5. It asks the HMAC peripheral for `K_dev` and derives the rest.
+
+The root sits in RAM only between the TRNG draw and the burn. It is held in a small `Wiped`
+buffer that zeroes itself when dropped, on every return path. The crate forbids `unsafe`, so it
+uses `core::hint::black_box` instead of a volatile write to stop the compiler removing the zeroing.
+
+On the existing-identity path, which is every boot after the first, the radio is never started for
+the identity at all.
+
+### The HMAC peripheral in upstream mode
+
+The eFuse purpose and the HMAC peripheral mode have different names for the same idea. The burned
+purpose is `HMAC_UP` (`KeyPurpose::HmacUp`); `BoardHmac` configures the esp-hal peripheral with
+`HmacPurpose::ToUser`. In that mode software supplies the message and reads the 32-byte result,
+while the key stays in the fuse. If the block does not have purpose `HMAC_UP`, the peripheral
+refuses and the adapter reports `KeyPurposeMismatch`.
+
+> [!NOTE]
+> AD-12 says the root is burned with "HMAC purpose ToUser". In the code, `ToUser` is the
+> peripheral mode and the burned eFuse purpose is `HMAC_UP`. This page follows the code.
+
+### Hardware behind traits
+
+`packages/rs/crypto` never touches a register. `provision_efuse` takes four traits from
+`packages/rs/hal`: `Radio`, `Trng`, `Efuse` and `HmacPeripheral`. The `Efuse` trait deliberately has
+no way to read a key. This keeps the whole flow testable on the host with fakes, and the firmware
+only supplies thin adapters in `board/`.
+
+`BoardEfuse` can only read eFuses through esp-hal 1.2.2, so it burns through the ESP32-S3 ROM
+routine `ets_efuse_write_key`. That routine applies the Reed-Solomon coding of key blocks and sets
+data, purpose, write protection and (for a protected purpose) read protection in one call.
+Afterwards `ets_efuse_read` reloads the read registers so the re-check sees the real fuses.
+`BoardEfuse::take` hands out at most one adapter, so only one owner can program fuses.
+
+These ROM calls are the only `unsafe` code of the Node. Each `unsafe` block carries a `SAFETY`
+comment, and `unsafe_code = "deny"` in the firmware `Cargo.toml` makes any other use fail the
+build. `board/hmac.rs` needs no `unsafe`: it uses the safe esp-hal HMAC driver. The Hub has one more
+`unsafe` block outside the eFuse adapter, in `apps/rs/hub/src/board/rtc.rs`, which installs the
+mbedTLS clock hooks for TLS.
+
+### What can go wrong during a burn
+
+If power fails mid-burn, a block can end up with data but purpose `USER`. The chip then reports
+it as used, so the next boot burns a different free block: one block is lost, but no key is reused.
+A burn can also set `HMAC_UP` without read protection; that board then halts with
+`IdentityNotProtected` on every boot and can no longer hold an identity. The
+[Hub README](../../apps/rs/hub/README.md) covers both cases under "Identity modes".
+
+On an identity failure the Hub logs the stage and error and parks. The Node logs it and deep-sleeps
+one period instead, so a broken identity does not drain the battery.
+
+### Dev mode: a software root in flash
+
+A build with `--features dev-mode` uses `provision_dev` instead and compiles no eFuse or HMAC code
+at all. The root is a TRNG-drawn software key in the `cf_ident` flash partition, stored as a
+41-byte record: the magic `CFID`, version `0x01`, the 32-byte root, and the first 4 bytes of
+SHA-256 over the rest as a check.
+
+- An erased partition (all `0xFF`) means first boot: draw a root, write the record, read it back.
+- A valid record is used as it is.
+- Anything else is `CorruptDevIdentity`. A corrupt identity is never replaced silently; erasing the
+ partition is your decision ([flash.md](../build/flash.md), "Recovery: erase a region").
+
+Both modes end in `DeviceKeys::from_device_key`, so a given root gives the same keys whether
+`K_dev` comes from the HMAC peripheral or from `derive_device_key` in software.
+
+### The release guard
+
+A dev-mode root is readable from flash, so a release Device must never use it. Both firmware
+crates refuse the combination at compile time:
+
+```rust
+#[cfg(all(feature = "dev-mode", not(debug_assertions)))]
+compile_error!(
+ "the `dev-mode` feature is refused in release builds: a release Node uses its eFuse identity"
+);
+```
+
+The guard works only in one direction. A debug build *without* `dev-mode` is an eFuse build and
+burns on first boot, as the warning above says.
+
+## Key constants and timings
+
+All from the generated `packages/rs/crypto/src/spec.rs` (source:
+`packages/crypto-spec/crypto-spec.json`) unless noted.
+
+| Constant | Value | Where |
+| --- | --- | --- |
+| `ROOT_KEY_LENGTH` | 32 bytes | `packages/rs/crypto/src/spec.rs` |
+| `DEVICE_KEY_LABEL` | `coldframe/device/v1` | `packages/rs/crypto/src/spec.rs` |
+| `DEVICE_KEY_LENGTH`, `PURPOSE_KEY_LENGTH` | 32 bytes each | `packages/rs/crypto/src/spec.rs` |
+| `SEAL_LABEL`, `ACK_LABEL`, `HUB_AUTH_LABEL` | `seal/v1`, `ack/v1`, `hub-auth/v1` | `packages/rs/crypto/src/spec.rs` |
+| `DEVICE_ID_LABEL` | `device-id/v1` | `packages/rs/crypto/src/spec.rs` |
+| `DEVICE_ID_LENGTH`, `DEVICE_ID_HEX_LENGTH` | 8 bytes, 16 hex digits | `packages/rs/crypto/src/spec.rs` |
+| `KeyPurpose::HmacUp` | raw purpose 8 | `packages/rs/hal/src/efuse.rs` |
+| `FIRST_KEY_BLOCK` | 4 (`KEYn` is eFuse block 4 + n) | `apps/rs/node/src/board/efuse.rs` |
+| `DEV_RECORD_MAGIC`, `DEV_RECORD_VERSION` | `CFID`, 1 | `packages/rs/crypto/src/identity.rs` |
+| `DEV_RECORD_LENGTH` | 41 bytes | `packages/rs/crypto/src/identity.rs` |
+| `cf_ident` partition | Node `0xD000`, Hub `0xE000`, 4 KiB each | `apps/rs/node/partitions.csv`, `apps/rs/hub/partitions.csv` |
+
+There are no timings here: provisioning runs once per boot, before anything else needs a key.
+
+## Where to read the code
+
+- `packages/rs/crypto/src/identity.rs` (`provision_efuse`): the release flow, find or burn, then
+ derive.
+- `packages/rs/crypto/src/identity.rs` (`provision_dev`, `DEV_RECORD_MAGIC`): the dev-mode `CFID`
+ record in flash.
+- `packages/rs/crypto/src/identity.rs` (`device_keys_from_hmac`, `Wiped`): `K_dev` from the
+ peripheral, and how the root is wiped.
+- `packages/rs/crypto/src/keys.rs` (`DeviceKeys`, `derive_purpose_key`, `DeviceId`): the HKDF
+ hierarchy.
+- `packages/rs/hal/src/efuse.rs` (`Efuse`, `KeyPurpose`): the eFuse model, with no read-key method.
+- `packages/rs/hal/src/hmac.rs` (`HmacPeripheral`): the upstream-mode HMAC trait.
+- `apps/rs/node/src/board/efuse.rs` (`BoardEfuse`): the ROM burn, the only `unsafe` code of the
+ Node. `apps/rs/hub/src/board/efuse.rs` has the same code (only its module
+ comment differs).
+- `apps/rs/node/src/board/hmac.rs` (`BoardHmac`): the esp-hal HMAC driver in `ToUser` mode;
+ `apps/rs/hub/src/board/hmac.rs` is identical.
+- `apps/rs/node/src/board/rng.rs` (`BoardTrng`): why the radio must be on before the TRNG runs.
+- `apps/rs/node/src/main.rs` (`compile_error!`, `MODE`): the release guard and the provisioning
+ call; `apps/rs/hub/src/main.rs` has the same guard.
+- [Node README](../../apps/rs/node/README.md) and [Hub README](../../apps/rs/hub/README.md),
+ "Identity modes": the operator view and the log lines.
diff --git a/docs/firmware/radio-coexistence.md b/docs/firmware/radio-coexistence.md
new file mode 100644
index 00000000..51ce3109
--- /dev/null
+++ b/docs/firmware/radio-coexistence.md
@@ -0,0 +1,152 @@
+# Radio coexistence on the Hub
+
+The Hub runs a Wi-Fi station, ESP-NOW and a BLE peripheral on the one 2.4 GHz radio of an ESP32-S3,
+with a TLS client on top. Before any Hub code was written, a spike tested whether that works at
+all. This page summarises what the spike found and shows how the Hub firmware applies it. It is the
+last page of the firmware reference: the previous page is [ble-setup.md](ble-setup.md), and the
+full list is in the [index](README.md).
+
+The spike report is [`hub-radio-coexistence.md`](../spikes/hub-radio-coexistence.md). Its results
+and criteria tables live there; this page does not repeat them. The requirements it produced are
+H-1 to H-3 and N-1 to N-2 in [device-hardware.md](../../_bmad-output/specs/spec-coldframe/device-hardware.md).
+The relay itself is governed by AD-9 and AD-17 in the
+[architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md).
+
+## What the spike found
+
+The spike firmware ran Wi-Fi STA, ESP-NOW, a BLE GATT peripheral and an HTTPS client as concurrent
+embassy tasks on one core, with a Node sending a frame every second and a phone writing over BLE
+every 10 s. The verdict (section 10 of the report) is **GO: keep the ESP32-S3**. Over about 3 h 45 min
+of runs on two boards there was no crash, ESP-NOW loss stayed well under 1 %, the heap was flat and
+channel following recovered on its own. The single radio is time-shared by the Espressif coexistence
+arbiter; the firmware does not schedule airtime itself.
+
+The build-time findings that shape the Hub code are:
+
+- **F-3, certificate dates.** mbedTLS checks validity dates only when it is built with a wall-clock
+ hook, and that build needs cmake and ninja. This became H-2.
+- **F-4, one TLS connection.** The default TLS record buffers cost about 32 KiB of heap per
+ connection. Budget one connection and never open two at once.
+- **F-5, heap size.** The Hub heap is 144 KiB of regular RAM plus 64 KiB of reclaimed bootloader
+ RAM, 208 KiB in total. The runtime check is the lowest free heap since boot.
+- **F-6, no WPA3-SAE.** esp-radio 1.0.0-beta.1 has no WPA3-SAE station configuration. This became
+ H-3.
+- **F-7, ESP-NOW comes from the Wi-Fi controller.** There is no separate ESP-NOW init: it is taken
+ from the running `WifiController`, and a peer added with `channel: None` follows the station's
+ channel.
+
+## How it works
+
+### Boot order: heap, then BLE, then Wi-Fi
+
+`main` in `apps/rs/hub/src/main.rs` sets up the two heap regions first, with the spike's sizes
+(F-5): `heap_allocator!` with `#[ram(reclaimed)]` for 64 KiB, then a regular 144 KiB.
+
+It then creates the BLE connector (`BleConnector::new`) *before* the Wi-Fi controller
+(`WifiController::new`), as the esp-radio coexistence examples require. If either fails, the Hub
+logs the stage and halts. The Wi-Fi controller is also the radio the TRNG draws entropy from, so it
+is on before the identity is provisioned.
+
+A provisioned Hub does not advertise: `main` drops the BLE connector and logs
+`setup provisioned earlier; not advertising`. An unprovisioned Hub spawns `ble_task` and runs BLE
+setup while Wi-Fi joins in the same session ([ble-setup.md](ble-setup.md)). So BLE and Wi-Fi
+overlap on the Hub only during setup.
+
+### Heap budget
+
+The budget is at least 32 KiB free at the lowest point (spike criterion C5). `heap_min_free` in
+`main.rs` computes it as heap size minus the peak usage. The Hub logs it twice: once after setup,
+when BLE, Wi-Fi and the Server check's TLS have all run, and every 60 s in `uptime_task`. Nothing
+enforces the budget at run time; the log line is how you check it on the bench.
+
+### ESP-NOW on the access point's channel
+
+ESP-NOW has no channel of its own on the Hub. `BoardRadio::esp_now` in
+`apps/rs/hub/src/board/radio.rs` takes it from the running controller, and `send` in
+`apps/rs/hub/src/board/espnow.rs` adds each peer with `EspNowWifiInterface::Station` and
+`channel: None`. The Hub therefore always transmits on whatever channel its access point uses.
+
+The consequence is on the Node side: when the router changes channel, the Hub follows it, and the
+Node has to find the Hub again by probing ([espnow.md](espnow.md)). While the station is not
+associated, or the clock is not set, the relay does not answer probes at all, so a Node never
+settles on a Hub that cannot forward its frames.
+
+### Choosing the access point (H-1)
+
+When the station is not associated, `Uplink::step` in `packages/rs/uplink/src/uplink.rs` calls its
+`join`, which scans and hands the result to `select_bssid` in `packages/rs/uplink/src/bssid.rs`.
+`BoardWifi::scan` in `apps/rs/hub/src/board/wifi.rs` does an active scan of every channel and keeps
+the strongest 32 access points (`SCAN_CAPACITY`).
+
+`select_bssid` looks only at access points with the provisioned SSID. It skips any whose security
+is unsupported and returns the one with the highest RSSI; on a tie, the first heard wins. It never
+takes the first match. `BoardWifi::join` then pins the station to that BSSID and channel. This
+matters for mesh networks, where several access points share one SSID.
+
+A join failure retries with a backoff of 1 s, doubling to at most 60 s, and reset by a successful
+join.
+
+> [!NOTE]
+> H-1 also asks the Hub to re-evaluate its access point when RSSI degrades. The code does not do
+> this. The Hub rescans only after the link has dropped: while it stays associated it keeps its
+> BSSID, however weak.
+
+### Supported security (H-3)
+
+`security` in `apps/rs/hub/src/board/wifi.rs` maps esp-radio's authentication method onto the
+`Security` enum in `packages/rs/hal/src/wifi.rs`, and `Security::is_supported` decides:
+
+- **Open** — supported. An empty passphrase joins with no authentication.
+- **WPA or WPA2 Personal** (including mixed WPA/WPA2) — supported.
+- **WPA2/WPA3 transition mode** — supported. The Hub joins it with WPA2-Personal.
+- **WPA3-only** (WPA3 Personal / SAE, and the WPA3 extended-PSK modes) — unsupported.
+- **Anything else** (WEP, OWE, enterprise, WAPI) — unsupported.
+
+`BoardWifi::join` only ever configures `Wpa2Personal` (or `Open`), and refuses a passphrase shorter
+than 8 characters as a wrong password. If every access point with the SSID is unsupported,
+`select_bssid` returns `SelectError::Unsupported`, and the Hub never tries to join. During BLE setup
+the app sees this as the `UnsupportedSecurity` Wi-Fi status. If the access point itself rejects the
+security at join time (disconnect reasons 210 and 211), `join_error` reports the same thing.
+
+> [!WARNING]
+> WPA3-only Wi-Fi is not supported. esp-radio 1.0.0-beta.1 has no WPA3-SAE station mode (spike F-6).
+> Put the router in WPA2 or WPA2/WPA3 transition mode for the Hub's network.
+
+### Certificate dates (H-2)
+
+`apps/rs/hub/Cargo.toml` builds mbedtls-rs with `hook-wall-clock`, so certificate validity dates are
+checked (the spike's `tls-time-check`). `main` installs the RTC as mbedTLS's wall clock before
+anything can open TLS. Until SNTP sets the clock, the RTC reads 1970 and every certificate fails
+closed, which is why the uplink runs SNTP once per boot before its first request (AD-11).
+
+## Key constants and timings
+
+| Constant | Value | Where |
+| --- | --- | --- |
+| Hub heap | 64 KiB reclaimed + 144 KiB regular | `heap_allocator!` in `apps/rs/hub/src/main.rs` |
+| Heap budget | at least 32 KiB free at the lowest point (logged, not enforced) | `heap_min_free` in `apps/rs/hub/src/main.rs` |
+| Heap log interval | 60 s | `UPTIME_EVERY` in `apps/rs/hub/src/main.rs` |
+| Scan results kept | 32 access points | `SCAN_CAPACITY` in `packages/rs/uplink/src/lib.rs` |
+| Join timeout | 30 s | `JOIN_TIMEOUT` in `apps/rs/hub/src/board/wifi.rs` |
+| Join backoff | 1 s, doubling, at most 60 s | `BACKOFF_INITIAL_MS`, `BACKOFF_MAX_MS` in `packages/rs/uplink/src/lib.rs` |
+| ESP-NOW send timeout | 60 ms | `SEND_TIMEOUT` in `apps/rs/hub/src/board/espnow.rs` |
+| ESP-NOW peer channel | `None` (the station's channel) | `send` in `apps/rs/hub/src/board/espnow.rs` |
+
+## Where to read the code
+
+- `apps/rs/hub/src/main.rs` (`main`): the heap regions, BLE before Wi-Fi, and when BLE is dropped.
+- `apps/rs/hub/src/main.rs` (`heap_min_free`, `uptime_task`): how the heap budget is measured and
+ logged.
+- `apps/rs/hub/src/board/radio.rs` (`BoardRadio::esp_now`): ESP-NOW taken from the running Wi-Fi
+ controller (F-7).
+- `apps/rs/hub/src/board/espnow.rs` (`send`, `relay_task`): peers with `channel: None`, one peer
+ entry per send.
+- `packages/rs/uplink/src/bssid.rs` (`select_bssid`): the strongest supported BSSID for the SSID
+ (H-1).
+- `packages/rs/uplink/src/uplink.rs` (`Uplink::step`, `join`): rescan and re-join only after the
+ link drops, with backoff.
+- `apps/rs/hub/src/board/wifi.rs` (`security`, `join_error`, `BoardWifi::join`): which networks the
+ Hub can join, and why WPA3-only is refused (H-3).
+- `packages/rs/hal/src/wifi.rs` (`Security::is_supported`): the one place the supported set is
+ defined.
+- `apps/rs/hub/README.md`: building and flashing the Hub ([README](../../apps/rs/hub/README.md)).
diff --git a/docs/firmware/sealing.md b/docs/firmware/sealing.md
new file mode 100644
index 00000000..0231b80e
--- /dev/null
+++ b/docs/firmware/sealing.md
@@ -0,0 +1,175 @@
+# Sealing frames: counters and the replay window
+
+Every wake report a Node sends, and every acknowledgement the Server sends back, is sealed with
+ChaCha20-Poly1305. The Hub in between relays the sealed bytes and can neither read nor forge them.
+This page shows how a frame is sealed, where the counter that makes each nonce unique comes from,
+how the Server refuses replays, and why a report leaves the Node's flash only on a sealed
+acknowledgement. It builds on the purpose keys from [identity.md](identity.md); the radio that
+carries the frames is in [espnow.md](espnow.md). The index is [README.md](README.md).
+
+The governing decisions are AD-12 (sealing in both directions) and AD-17 (counters, the replay
+window and the report buffer) in the
+[architecture spine](../../_bmad-output/planning-artifacts/architecture/architecture-coldframe-2026-09-26/ARCHITECTURE-SPINE.md),
+and requirement N-1 (delivery) in [device-hardware.md](../../_bmad-output/specs/spec-coldframe/device-hardware.md).
+
+## How it works
+
+### Two keys, one per direction
+
+The Node derives two 32-byte purpose keys from its device key: `seal/v1` for uplink frames and
+`ack/v1` for downlinks (`DeviceKeys` in `packages/rs/crypto/src/keys.rs`). Because the two
+directions use different keys, an uplink and a downlink can never share a (key, nonce) pair, even
+when their counters are equal. Each side keeps its own counter: the Node reserves uplink counters
+in flash, and the Server reserves downlink counters in its database.
+
+### Nonce and associated data
+
+ChaCha20-Poly1305 needs a 12-byte nonce that never repeats under one key. Coldframe builds it from
+the Device ID and the frame counter (`nonce` in `packages/rs/crypto/src/frame.rs`):
+
+```text
+nonce = device_id[0..4] ‖ counter (u64, big-endian) 12 bytes
+aad = protocol_major (u8) ‖ device_id (8 bytes) ‖ counter (u64, big-endian) 17 bytes
+```
+
+The key is already unique to the Device, so the counter alone keeps the nonce unique; the four
+Device ID bytes are only a prefix. The associated data (`aad`) is authenticated but not
+encrypted. It binds the ciphertext to the full Device ID, the counter and the wire major, so a
+frame cannot be replayed under another Device ID or counter, or read as another protocol version,
+without the tag failing. `seal` and `open` in the same file wrap the AEAD in
+`packages/rs/crypto/src/aead.rs`: the sealed bytes are the ciphertext followed by the 16-byte tag.
+
+### The envelope
+
+On the wire both directions use one message, `SealedEnvelope` in
+`packages/proto/coldframe/device/v1/envelope.proto`. It carries the protocol version, the Device
+ID and the counter in the clear, and the ciphertext. The receiver needs those clear fields to
+rebuild the nonce and the AAD. The plaintext of an uplink is a `NodeFrame` (one wake report); the
+plaintext of a downlink is a `Downlink`.
+
+`seal_uplink` in `packages/rs/transport/src/frame.rs` encodes the `NodeFrame`, seals it with the
+`seal/v1` key under the given counter, wraps it in a `SealedEnvelope`, and puts the radio kind
+byte in front. If the result would not fit one ESP-NOW payload it returns `SealError::TooLong`.
+The plaintext buffer is zeroed after use.
+
+`open_downlink` in the same file does the reverse for the Node. It drops a downlink silently
+unless the envelope is protocol version 1, names this Node's Device ID, opens under the `ack/v1`
+key, and decodes to a `Downlink` of version 1.
+
+### The frame counter in flash
+
+A counter that repeats after a reset would reuse a nonce, which breaks ChaCha20-Poly1305. So the
+Node never keeps the frame counter in RAM alone. `ReservedCounter` in
+`packages/rs/sensing/src/counter.rs` stores a *ceiling* in flash: every value below it may already
+have been used. To get counters, `reserve(n)` writes the raised ceiling, reads it back, and only
+then hands out `ceiling..ceiling + n`. A reset or power cut at any point can waste values (gaps
+are allowed) but never repeat one.
+
+The records go into two 4096-byte sectors of 32-byte slots, appended one after another. When the
+active sector is full, the other sector is erased and the next record goes there, so one sector
+always holds a valid ceiling. On boot the counter is the maximum over the valid records of both
+sectors. A region with written slots but no valid record is `CounterError::Corrupt`, and the
+counter never falls back to 0.
+
+The frame counter lives in the `cf_frame` partition (`apps/rs/node/partitions.csv`, opened by
+`FRAME_PARTITION` in `apps/rs/node/src/board/flash.rs`), with the record magic `CFFC`
+(`FRAME_MAGIC` in `packages/rs/transport/src/transport.rs`). Each wake that sends reserves one
+counter per frame in a single write (`reserve_counters`), and every frame of the burst is sealed
+under its own fresh counter. If the reservation fails, nothing is sealed and the reports stay
+buffered.
+
+> [!WARNING]
+> Never erase the `cf_frame` partition on a Node that has sent frames. The counter would restart
+> at 0. The next frames would then be sealed under nonces already used with the same `seal/v1`
+> key, which leaks plaintext to anyone who recorded the old frames. The Server would also refuse
+> them as replays, because they are far below its high-water mark, so the Node could not deliver
+> anything until its counter climbed past the old one. Only a board that has never sent a frame,
+> or a `dev-mode` Node whose flash root in `cf_ident` is erased with it (a new identity, so new
+> keys, enrolled again), may have `cf_frame` erased. An eFuse Node can never get new keys. The
+> partition table is in the [Node README](../../apps/rs/node/README.md).
+
+### The replay window
+
+An attacker can record a sealed frame and send it again; the tag is still valid. The receiver
+refuses it by its counter. `ReplayWindow` in `packages/rs/crypto/src/frame.rs` keeps the highest
+accepted counter and a 64-bit mask of the 64 counters ending at it. A counter above the high-water
+mark is accepted; a counter within the window is accepted once; anything older, or seen before,
+is a replay. `open_checked` refuses a replay *before* decrypting, and moves the window only
+*after* the frame authenticates, so a forged frame cannot push the window forward.
+
+The window tolerates the gaps and reordering that a reserved counter and a lossy radio produce.
+A large jump, as after many unsent wakes, simply forgets the old window.
+
+Who runs it: the Server, not the firmware. The Server's Device grain loads the window from the
+database, checks each uplink against it, and commits the moved window in the same transaction as
+the Readings (`apps/cs/server/Devices/DeviceGrain.cs`, using the C# port in
+`packages/cs/crypto/Frames.cs`). The Rust `ReplayWindow` and `open_checked` are the reference
+implementation, tested against the shared vectors; no firmware crate calls them today.
+
+### Downlinks on the Node
+
+The Node does not keep a replay window for downlinks. It does not need one, because an authentic
+downlink can only delete what the Server has already stored. Instead the transport decides
+whether a downlink is *fresh*: its `acked_counter` must be a frame of this wake's burst or the
+previous one, not acknowledged before (`Burst::acknowledge` in
+`packages/rs/transport/src/link.rs`). Only a fresh downlink resets the miss count and sets the
+clock. A replayed or late copy is harmless.
+
+### The report buffer and deletion on acknowledgement
+
+A wake report is written to the flash buffer `cf_buf` before the radio starts (`ReportBuffer` in
+`packages/rs/transport/src/buffer.rs`). It stays there, across deep sleep and power loss, until a
+sealed downlink acknowledges it. The buffer holds 96 reports (24 h at one wake every 15 minutes);
+when it is full, the oldest report is dropped.
+
+The `Downlink` lists the `reading_seq` values the Server has durably stored, as ranges
+(`acked_readings`). `ReportBuffer::ack` deletes exactly the Readings and reports whose sequence
+numbers fall in those ranges, by clearing bits of the record's live word. A record with no live
+bit left is dead, and its sector is erased later. What the radio reports for a send is never read:
+only a downlink that passed `open_downlink` deletes anything. A lost frame or a lost downlink
+therefore costs only a resend. A resend is the same report sealed again under a new counter, with
+the boot ID and uptime of the resend, and the Server takes it as a duplicate.
+
+## Key constants and timings
+
+| Constant | Value | Where |
+| --- | --- | --- |
+| `SEAL_LABEL`, `ACK_LABEL` | `seal/v1`, `ack/v1` | `packages/rs/crypto/src/spec.rs` |
+| `AEAD_NONCE_LENGTH` | 12 bytes | `packages/rs/crypto/src/spec.rs` |
+| `AEAD_TAG_LENGTH` | 16 bytes | `packages/rs/crypto/src/spec.rs` |
+| `FRAME_NONCE_DEVICE_ID_PREFIX_LENGTH` | 4 bytes | `packages/rs/crypto/src/spec.rs` |
+| `FRAME_AAD_LENGTH` | 17 bytes | `packages/rs/crypto/src/spec.rs` |
+| `DEVICE_ID_LENGTH` | 8 bytes | `packages/rs/crypto/src/spec.rs` |
+| `PROTOCOL_MAJOR` | 1 | `packages/rs/crypto/src/spec.rs` |
+| `REPLAY_WINDOW` | 64 counters | `packages/rs/crypto/src/spec.rs` |
+| `FRAME_MAGIC` | `CFFC` | `packages/rs/transport/src/transport.rs` |
+| `SECTORS`, `SLOT_SIZE` | 2 sectors, 32-byte slots | `packages/rs/sensing/src/counter.rs` |
+| `CAPACITY` | 96 reports | `packages/rs/transport/src/buffer.rs` |
+| `MIN_SECTORS`, `MAX_SECTORS` | 5 to 8 sectors | `packages/rs/transport/src/buffer.rs` |
+| `BURST_MAX` | 8 frames per wake | `packages/rs/transport/src/transport.rs` |
+| `ACK_WINDOW_MS` | 300 ms | `packages/rs/transport/src/transport.rs` |
+| `MAX_PAYLOAD` | 250 bytes per radio message | `packages/rs/protocol/src/radio.rs` |
+
+The constants in `packages/rs/crypto/src/spec.rs` are generated from `packages/crypto-spec`, so
+the Rust, C# and Kotlin sides share them.
+
+## Where to read the code
+
+- `packages/rs/crypto/src/frame.rs` (`nonce`, `aad`, `seal`, `open`): the whole sealing rule in
+ a few lines.
+- `packages/rs/crypto/src/frame.rs` (`ReplayWindow`, `open_checked`): the sliding window and the
+ check-before-decrypt, accept-after-authenticate order.
+- `packages/rs/transport/src/frame.rs` (`seal_uplink`, `open_downlink`): how a `NodeFrame`
+ becomes a radio message, and what a Node accepts back.
+- `packages/rs/sensing/src/counter.rs` (`ReservedCounter`): a power-loss-safe counter on NOR
+ flash; the module doc explains the record layout.
+- `packages/rs/transport/src/transport.rs` (`reserve_counters`, `send_burst`): one reservation
+ per wake, one fresh counter per frame.
+- `packages/rs/transport/src/buffer.rs` (`ReportBuffer`, `ack`): the report buffer and deletion
+ on a sealed acknowledgement.
+- `packages/rs/transport/src/link.rs` (`Burst`): which downlinks count as fresh.
+- `packages/proto/coldframe/device/v1/envelope.proto` (`SealedEnvelope`, `NodeFrame`,
+ `Downlink`): the wire messages, with comments.
+- `apps/rs/node/partitions.csv`: where `cf_frame` and `cf_buf` sit in flash.
+- `apps/cs/server/Devices/DeviceGrain.cs`: the Server side, where the replay window is enforced
+ and the downlink counter is reserved.
diff --git a/docs/test.sh b/docs/test.sh
index ad83c846..9c6c337f 100755
--- a/docs/test.sh
+++ b/docs/test.sh
@@ -332,6 +332,15 @@ test -z "${STUB_ESP:-}"
expect "without the export file a ci:run block still runs, unsourced" 0 "1 blocks in 2 files pass" - \
-- env -u STUB_ESP PATH="${stubs}:${PATH}" DOCS_REPO_ROOT="${no_export}" ESP_EXPORT="${work}/none.sh" "${commands}"
+firmware_unmarked=$(fixture firmware-unmarked)
+page "${firmware_unmarked}" docs/firmware/x.md '# Firmware
+
+```bash
+true
+```'
+expect "docs/firmware/*.md is checked by default" 1 - "docs/firmware/x.md:3" \
+ -- check "${firmware_unmarked}"
+
expect "a file argument that is no file is a usage error" 2 "" "usage:" -- check "${good}" docs/build/nope.md
expect "the repository guide marks every block" 0 "pass" "" -- "${commands}" --list
diff --git a/packages/crypto-spec/scripts/lib/spec.ts b/packages/crypto-spec/scripts/lib/spec.ts
index 2437edc9..4afbb0ca 100644
--- a/packages/crypto-spec/scripts/lib/spec.ts
+++ b/packages/crypto-spec/scripts/lib/spec.ts
@@ -206,7 +206,7 @@ export function constantsOf(spec: CryptoSpec): ConstantGroup[] {
{
title: 'Sensor identity (AD-19)',
constants: [
- { name: 'sensorIdNamespace', kind: 'str', value: sensorId.namespace, doc: `UUIDv5 namespace of every Sensor ID, itself UUIDv5(URL namespace, "${sensorId.namespaceName}"): \`${sensorId.construction}\`.` },
+ { name: 'sensorIdNamespace', kind: 'str', value: sensorId.namespace, doc: `UUIDv5 namespace of every Sensor ID, itself UUIDv5(URL namespace, \`${sensorId.namespaceName}\`): \`${sensorId.construction}\`.` },
{ name: 'sensorIdNameFormat', kind: 'str', value: sensorId.nameFormat, doc: `The UUIDv5 name of a Sensor: ${sensorId.name}.` },
{ name: 'sensorQuantitySoilMoisture', kind: 'str', value: sensorId.quantities.soilMoisture, doc: 'Quantity token of soil moisture.' },
{ name: 'sensorQuantityAirTemperature', kind: 'str', value: sensorId.quantities.airTemperature, doc: 'Quantity token of air temperature.' },
diff --git a/packages/cs/crypto/Generated/CryptoSpec.g.cs b/packages/cs/crypto/Generated/CryptoSpec.g.cs
index b759acfa..a14e4789 100644
--- a/packages/cs/crypto/Generated/CryptoSpec.g.cs
+++ b/packages/cs/crypto/Generated/CryptoSpec.g.cs
@@ -286,7 +286,7 @@ public static class CryptoSpec
///
/// UUIDv5 namespace of every Sensor ID, itself UUIDv5(URL namespace,
- /// "https://github.com/escendit/coldframe/sensor"): `sensorId = UUIDv5(namespace, name), name = UTF-8
+ /// `https://github.com/escendit/coldframe/sensor`): `sensorId = UUIDv5(namespace, name), name = UTF-8
/// of nameFormat; namespace = UUIDv5(urlNamespace, namespaceName)`.
///
public const string SensorIdNamespace = "5f496f62-2f32-5456-8b2a-ebe01228c999";
diff --git a/packages/kt/core/generated/kotlin/com/escendit/coldframe/core/crypto/CryptoSpec.kt b/packages/kt/core/generated/kotlin/com/escendit/coldframe/core/crypto/CryptoSpec.kt
index ad6e27d3..829a5fd9 100644
--- a/packages/kt/core/generated/kotlin/com/escendit/coldframe/core/crypto/CryptoSpec.kt
+++ b/packages/kt/core/generated/kotlin/com/escendit/coldframe/core/crypto/CryptoSpec.kt
@@ -195,7 +195,7 @@ public object CryptoSpec {
/**
* UUIDv5 namespace of every Sensor ID, itself UUIDv5(URL namespace,
- * "https://github.com/escendit/coldframe/sensor"): `sensorId = UUIDv5(namespace, name), name = UTF-8
+ * `https://github.com/escendit/coldframe/sensor`): `sensorId = UUIDv5(namespace, name), name = UTF-8
* of nameFormat; namespace = UUIDv5(urlNamespace, namespaceName)`.
*/
public const val SENSOR_ID_NAMESPACE: String = "5f496f62-2f32-5456-8b2a-ebe01228c999"
diff --git a/packages/proto/coldframe/setup/v1/setup.proto b/packages/proto/coldframe/setup/v1/setup.proto
index 333201be..1dd0e625 100644
--- a/packages/proto/coldframe/setup/v1/setup.proto
+++ b/packages/proto/coldframe/setup/v1/setup.proto
@@ -76,8 +76,11 @@ message SetupMessage {
// What kind of Device answers.
enum DeviceKind {
+ // Not set: the default when the field is absent.
DEVICE_KIND_UNSPECIFIED = 0;
+ // A Hub: relays Node frames to the Server over Wi-Fi.
DEVICE_KIND_HUB = 1;
+ // A Node: measures and reports to its Hub over ESP-NOW.
DEVICE_KIND_NODE = 2;
}
@@ -99,8 +102,11 @@ message WifiScanRequest {}
// How a Wi-Fi network authenticates, as the Hub sees it.
enum WifiSecurity {
+ // Not set: the default when the field is absent.
WIFI_SECURITY_UNSPECIFIED = 0;
+ // No passphrase.
WIFI_SECURITY_OPEN = 1;
+ // WPA2 with a passphrase.
WIFI_SECURITY_WPA2_PERSONAL = 2;
// WPA2/WPA3 transition mode; the Hub joins with WPA2.
WIFI_SECURITY_WPA3_TRANSITION = 3;
@@ -140,6 +146,7 @@ message WifiConfig {
// The outcome of joining.
enum WifiStatus {
+ // Not set: the default when the field is absent.
WIFI_STATUS_UNSPECIFIED = 0;
// Joined, and the Server at SiteBinding.server_url was reached and accepted the Hub (a signed
// heartbeat answered 200). Only then is the provisioning record stored.
@@ -196,6 +203,7 @@ message EnrolmentResponse {
// Why a step failed.
enum SetupErrorCode {
+ // Not set: the default when the field is absent.
SETUP_ERROR_CODE_UNSPECIFIED = 0;
// The message did not parse or had missing fields.
SETUP_ERROR_CODE_MALFORMED_MESSAGE = 1;
diff --git a/packages/rs/README.md b/packages/rs/README.md
index b00c79de..1163d314 100644
--- a/packages/rs/README.md
+++ b/packages/rs/README.md
@@ -16,4 +16,8 @@ These crates are members of the Cargo workspace in the repository root. The Hub
of them but `sensing` and `transport`, and the Node firmware links all of them (`setup` brings in
`uplink`, which the Node never runs), with the `esp` toolchain, which is Rust 1.95. That is why they declare `rust-version = "1.95"`. None depends on an
esp crate.
+
+The [firmware reference](../../docs/firmware/README.md) explains the mechanisms these crates
+implement and where to read them.
+
Unit tests stay inline next to the code. Integration tests live in [`tests/rs`](../../tests/rs).
diff --git a/packages/rs/crypto/src/spec.rs b/packages/rs/crypto/src/spec.rs
index 919ffd28..97cb0619 100644
--- a/packages/rs/crypto/src/spec.rs
+++ b/packages/rs/crypto/src/spec.rs
@@ -178,7 +178,7 @@ pub const HEARTBEAT_MAX_SKEW_MS: u64 = 300000;
// Sensor identity (AD-19)
/// UUIDv5 namespace of every Sensor ID, itself UUIDv5(URL namespace,
-/// "https://github.com/escendit/coldframe/sensor"): `sensorId = UUIDv5(namespace, name), name =
+/// `https://github.com/escendit/coldframe/sensor`): `sensorId = UUIDv5(namespace, name), name =
/// UTF-8 of nameFormat; namespace = UUIDv5(urlNamespace, namespaceName)`.
pub const SENSOR_ID_NAMESPACE: &str = "5f496f62-2f32-5456-8b2a-ebe01228c999";
diff --git a/packages/rs/protocol/build.rs b/packages/rs/protocol/build.rs
index f8db2bf0..3f70309c 100644
--- a/packages/rs/protocol/build.rs
+++ b/packages/rs/protocol/build.rs
@@ -3,7 +3,10 @@
//!
//! protox compiles the file into a descriptor set in pure Rust (no `protoc`), and micropb-gen
//! turns that into no_std Rust with heapless containers. Every string, bytes and repeated field
-//! gets a fixed capacity, so each message has a compile-time `MAX_SIZE`.
+//! gets a fixed capacity, so each message has a compile-time `MAX_SIZE`. The descriptor set keeps
+//! its source info, so micropb-gen turns every proto comment into the rustdoc of the generated
+//! item. A message, field or enum value without a comment warns under `missing_docs`, which CI
+//! turns into a failure with `-D warnings` (clippy in the `rust` job, rustdoc in the Docs workflow).
use std::path::PathBuf;
use std::{env, fmt};
@@ -46,7 +49,7 @@ fn main() {
}
let mut compiler = protox::Compiler::new([&proto_root]).expect("proto root exists");
- compiler.include_source_info(false);
+ compiler.include_source_info(true);
compiler
.open_file(SETUP_PROTO)
.expect("setup.proto compiles");
diff --git a/packages/rs/protocol/src/lib.rs b/packages/rs/protocol/src/lib.rs
index f7923020..3c559ea0 100644
--- a/packages/rs/protocol/src/lib.rs
+++ b/packages/rs/protocol/src/lib.rs
@@ -3,8 +3,8 @@
//! `no_std` without `alloc`: every string, bytes and repeated field is a heapless container with
//! a fixed capacity, so each message has a compile-time `MAX_SIZE`.
//!
-//! - [`setup_v1`]: the BLE setup protocol, `coldframe.setup.v1` (AD-25). Module path in the
-//! generated code: `coldframe_::setup_::v1_`; the `oneof body` is `SetupMessage_::Body`.
+//! - [`setup_v1`]: the BLE setup protocol, `coldframe.setup.v1` (AD-25); the `oneof body` is
+//! `SetupMessage_::Body`.
//! - [`device_v1`]: sealed Device frames and downlinks, `coldframe.device.v1` (AD-9, AD-12):
//! `SealedEnvelope`, `NodeFrame` (its `oneof measured` is `NodeFrame_::Measured`), `Downlink`
//! and `SpecificationSet`, sized for a Node with four Sensors.
@@ -13,6 +13,9 @@
//! - [`encode`] and [`decode`]: one message to or from a byte slice; [`encode_node_frame`] for a
//! `NodeFrame` in field-number order.
//!
+//! Both wire modules re-export the generated package modules, whose rustdoc is the comments of
+//! the `.proto` files.
+//!
//! Types that can hold a Wi-Fi password (`WifiConfig`, `SetupMessage` and its `Body`) do not
//! implement `Debug`, so a password cannot reach a log through `{:?}`.
@@ -28,7 +31,6 @@ use micropb::{MessageDecode, MessageEncode, PbEncoder};
clippy::pedantic,
nonstandard_style,
unused,
- missing_docs,
reason = "generated by micropb-gen"
)]
mod generated {
@@ -39,11 +41,17 @@ pub mod radio;
pub use micropb;
-/// `coldframe.setup.v1`: the BLE setup protocol.
-pub use generated::coldframe_::setup_::v1_ as setup_v1;
+/// `coldframe.setup.v1`: the BLE setup protocol (AD-25), from
+/// `packages/proto/coldframe/setup/v1/setup.proto`.
+pub mod setup_v1 {
+ pub use super::generated::coldframe_::setup_::v1_::*;
+}
-/// `coldframe.device.v1`: sealed Device frames and downlinks.
-pub use generated::coldframe_::device_::v1_ as device_v1;
+/// `coldframe.device.v1`: sealed Device frames and downlinks (AD-9, AD-12), from
+/// `packages/proto/coldframe/device/v1/envelope.proto`.
+pub mod device_v1 {
+ pub use super::generated::coldframe_::device_::v1_::*;
+}
/// The wire major every message carries in `protocol_version`.
pub const PROTOCOL_VERSION: u32 = 1;
diff --git a/packages/rs/sensing/src/lib.rs b/packages/rs/sensing/src/lib.rs
index 8996132d..80a92e3d 100644
--- a/packages/rs/sensing/src/lib.rs
+++ b/packages/rs/sensing/src/lib.rs
@@ -7,7 +7,8 @@
//! decides what the wake does.
//! - [`battery`]: the switched-divider scaling and the LiPo discharge curve, giving an
//! approximate [`BatteryLevel`].
-//! - [`env`]: [`env_sample_from`], the one conversion of the BME680's floats to integer units.
+//! - [`env`](mod@env): [`env_sample_from`], the one conversion of the BME680's floats to integer
+//! units.
//! - [`counter`]: [`ReservedCounter`], a strictly increasing `u64` reserved in flash before any
//! value is handed out (AD-17). It backs both `reading_seq` and the boot counter.
//! - [`wake`]: [`run_wake`], one measurement with the probe and divider switched on only while