Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTEXT-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ PiFinder is a multi-process Raspberry Pi finder/plate-solver. These contexts eac
- [Display](./docs/ax/display/CONTEXT.md) — how panels turn rendered pixels into light: each panel's brightness axes, the dimming policy (knee curve), and the camera-as-photometer bench rig that measures panel flux. Runtime policy + bench tooling.
- [NixOS](./docs/ax/nixos/CONTEXT.md) — how a NixOS PiFinder is built, published, and updated over the air: the Attic cache, the stable/beta/unstable channels, and the on-device upgrade flow. Cross-cutting infrastructure, not a runtime slice.
- [Bring-up](./docs/ax/bringup/CONTEXT.md) — first power-on validation of a freshly assembled board: the checks a builder runs at the bench and which of them can be machine-verified. Bench tooling, not a runtime slice.
- [Network](./docs/ax/network/CONTEXT.md) — the device's WiFi identity and connections: AP vs Client mode, first-boot AP provisioning (random SSID + WPA2 on new units, [ADR 0034](./docs/adr/0034-ap-provisioning-ssid-tokens-encrypted-default.md)), client network credentials, and getting another device connected (Connect WiFi screen, web Network page).

## Relationships

Expand All @@ -40,6 +41,9 @@ PiFinder is a multi-process Raspberry Pi finder/plate-solver. These contexts eac
- **Bring-up → UI**: bring-up reuses the display drivers, fonts and layout helpers, but **not** `UIModule`, `MenuManager` or the menu tree — it draws its own frames and never joins the navigation stack. It reads the keypad as **switches** at their **matrix positions**, below the layer where UI's logical **keys**, `ALT_*` chords and `LNG_*` long presses are formed.
- **Bring-up ↛ `hardware_detect`**: unlike `main.py` and `splash.py`, bring-up does **not** derive its panel from the BQ25895 probe. Doing so would make a dead charger indistinguishable from a dead screen on exactly the boards it exists to diagnose.

- **Network → UI**: the **Connect WiFi screen** renders the AP's identity — SSID, **AP passphrase**, **WiFi QR code** — from the Network context. On a **new unit** (encrypted by default, ADR 0034) it is the primary join path, not a convenience view, so it must work on every panel.
- **Network → NixOS**: the migration converts **client networks** into NetworkManager keyfiles (`nixos_migration_wifi.py`); AP provisioning has no NixOS equivalent yet, so the encrypted-by-default AP posture must survive migration deliberately, not by luck (ADR 0034).

Companion architecture docs live next to each `CONTEXT.md`:
- [`docs/ax/nixos.md`](./docs/ax/nixos.md)
- [`docs/ax/catalog.md`](./docs/ax/catalog.md)
Expand Down
39 changes: 39 additions & 0 deletions docs/adr/0034-ap-provisioning-ssid-tokens-encrypted-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# AP provisioning via SSID tokens: new units ship encrypted (EN 18031)

Fresh images ship their access point pre-configured as `ssid=PiFinder-CHANGEME-ENCRYPTME`. First boot consumes the tokens: `CHANGEME` becomes a random `PiFinder-XXXXX`, `ENCRYPTME` becomes WPA2 with a passphrase generated on that unit and shown nowhere but its own screen. Consuming a token strips it from the SSID. A software upgrade never provisions — units already in the field keep whatever AP they have, open or not.

## Why the default flipped

Issue #179 asked for exactly this in 2024: encrypted AP, random per-unit password. Review on PR #311 talked it down to opt-in, for a reason that was and is real: if the UI fails to start, an encrypted AP is one nobody can join, and the fallback is reading a 20-character password off a 1.5-inch screen — mrosseel called it a support nightmare. The QR code came out of that same thread (brickbots) as the mitigation, and the PR settled on "random name by default, encryption if you ask for it".

EN 18031 — the EU cybersecurity baseline backing the Radio Equipment Directive — takes the choice away for units placed on the EU market: an open-by-default radio configuration with no per-unit credentials is no longer shippable. So the 2025 preference lost to a 2025 regulation, and the objection converts into a mitigation bill rather than a veto: the Connect WiFi screen (QR join) and the recovery reset are now load-bearing, and both get validated on real hardware, on every panel type, before release.

## Why tokens in the SSID

A per-unit secret cannot be baked into a shared image — every builder flashes the same published file, so anything the image contains is by definition not unique. Whatever the mechanism, credentials must be *generated* at first boot, which needs a durable marker for "not yet generated".

The SSID itself is that marker. It lives in the exact file being provisioned, so it survives image flashing by construction and cannot drift out of sync with the thing it describes; there is no sidecar state file to lose or contradict. It is also self-describing in failure: a `PiFinder-CHANGEME` showing up in a phone's WiFi list tells you precisely which step never ran.

Stripping the token on consumption is what makes the boundary work: "tokens present" and "work to do" become the same predicate, provisioning is idempotent, and a field-upgraded unit (no tokens) is untouched with no version arithmetic at all. The #311 draft never stripped `ENCRYPTME` — under this default that means every new unit rewrites its config on every boot, which is why token stripping is the first defect the refresh fixes rather than a nice-to-have.

## Considered options

**A fixed default passphrase in the image** — a universal default credential is the exact thing the regulation exists to forbid.

**Generate credentials at image build time** — gives every copy of a published image the same "unique" secret. The same failure, one step removed.

**Force-encrypt field units on upgrade** — would break every saved phone connection in the installed base, unannounced, for devices the placed-on-market rule was not aimed at. The token mechanism gets us the honest split for free: fresh image provisions, existing config is left alone.

**A sidecar "provisioned" flag file** — duplicate state that can disagree with the config it describes, and one more thing a partial flash or manual edit can strand.

**NetworkManager-based provisioning** — not the Debian runtime today; hostapd's config is the source of truth. Noted for the NixOS future below rather than adopted early.

## Consequences

**The Connect WiFi screen is the primary join path.** A new unit's user cannot reach the web interface without it, so it must render and scan on every panel (128×128, 176×176, 320×240) and its QR payload gets machine-verified in tests.

**The recovery reset must restore an AP that stays open.** If the recovery configuration carried `ENCRYPTME`, the next boot would re-encrypt and lock the user out of the unit they just recovered — so it restores an open AP (random name via `CHANGEME` is fine) and the user re-encrypts from the web interface. Recovery requires physical access; whether that posture fully satisfies EN 18031 is confirmed as part of the refresh, and recorded here when it is.

**The NixOS migration must not undo this.** `nixos_migration_wifi.py` carries client networks into NetworkManager keyfiles; it has no AP-provisioning equivalent, so without deliberate work a migrated new unit could come back with a different AP posture than it shipped with. Tracked in the refresh plan.

**Deliberately not settled here**, expected to resolve during the refresh: whether the web interface may disable encryption on a provisioned unit (leaning no); whether the two tokens collapse into one (leaning no — `ENCRYPTME` alone is the opt-in path for field units); whether first boot should surface the Connect WiFi screen unprompted (#179 asked for the credentials to be reachable from the main screens).
64 changes: 64 additions & 0 deletions docs/ax/network/CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Network (WiFi & Connectivity)

The Network context owns the device's WiFi identity and connections: whether the PiFinder broadcasts its own network or joins someone else's, how a fresh unit acquires a unique name and credentials at first boot, and how a phone or laptop gets connected to it. It is the vocabulary for the network half of `sys_utils`, the Connect WiFi screen, and the web interface's Network page.

## Language

### Modes

**WiFi mode**:
Which of two roles the device's radio plays: **AP** (the PiFinder broadcasts its own network) or **Client** (it joins an existing one). Exactly one is active at a time.
_Avoid_: "hotspot mode" (say AP), "station mode" (say Client).

**Access Point (AP)**:
The network the PiFinder itself broadcasts, named by its **SSID**. The hostapd configuration is the single source of truth for its identity: SSID, encryption, AP passphrase, country code.
_Avoid_: "hotspot".

**Client network**:
A saved network the PiFinder can join in Client mode (SSID plus credentials). The user manages the list from the web interface.
_Avoid_: "saved WiFi"; "home network" (a client network need not be at home).

### Provisioning

**Provisioning token**:
A marker embedded in a fresh image's AP SSID that first boot consumes: `CHANGEME` (generate a random SSID) and `ENCRYPTME` (enable WPA2 with a generated passphrase). Consuming a token strips it from the SSID — a token present at boot means work to do; no tokens means the unit is provisioned. Fresh images ship with both.
_Avoid_: "magic SSID"; leaving a token in the SSID after acting on it (that is the bug, not the design).

**New unit** vs **field-upgraded unit**:
The compliance boundary (EN 18031, see [ADR 0034](../../adr/0034-ap-provisioning-ssid-tokens-encrypted-default.md)). A **new unit** first-boots a fresh image, finds provisioning tokens, and provisions itself: random SSID, WPA2, unique AP passphrase. A **field-upgraded unit** took a software update over an existing configuration; its AP is left exactly as it was — an upgrade never provisions and never force-encrypts.
_Avoid_: reasoning from software version (the predicate is "tokens present", not "release N or later").

**AP passphrase**:
The WPA2 credential for an encrypted AP, generated on the unit itself at provisioning and shown nowhere but its own screen. User-facing surfaces display it as "Password".
_Avoid_: "PSK", "key" (implementation words); any shared or image-baked default value (per-unit uniqueness is the point).

**Country code**:
The regulatory domain the AP radio operates under (ISO 3166 two-letter code), set from the web interface.

### Connecting

**Connect WiFi screen**:
The on-device screen that gets another device onto the PiFinder: in AP mode a **WiFi QR code** or the SSID and AP passphrase in the clear; in Client mode the name of the network the PiFinder joined. For a new unit this is the primary join path, not a convenience view.
_Avoid_: "WiFi password screen" (it shows more than a password — see Flagged ambiguities).

**WiFi QR code**:
A QR encoding of the AP's SSID, security type, and AP passphrase in the standard `WIFI:` join format, which phone cameras turn into a one-tap connection.
_Avoid_: describing it as a link or URL (it is a credential payload, not a web address).

**Recovery reset**:
The documented physical-access procedure (console, or SSH over a network cable) that restores the AP to an open default so the user can get back in — the way into a unit whose encrypted AP cannot be joined because the screen cannot be read. The restored configuration must stay open until the user re-encrypts; deliberately not possible remotely.
_Avoid_: "factory reset" (nothing else is reset).

## Flagged ambiguities

- One screen, four names: the menu says "Connect WiFi", the module is `wifi_password.py`, the help folder is `wifi_connect`, the title bar says "WIFI". Canonical: **Connect WiFi screen**. The refresh should rename the module to match (`wifi_connect.py`).
- "Password" vs "passphrase": hostapd and the code say passphrase; screens and user docs say password. Canonical in code and internal docs: **AP passphrase**. User-facing text stays "password".

## Example dialogue

Dev: A user upgraded to the new release and their AP is still open. Bug?
Expert: No — field-upgraded unit. An upgrade must not touch their AP. Only a new unit provisions, and only because its image still carries the tokens.
Dev: And if they reflash that same device?
Expert: Then it first-boots a fresh image, finds `CHANGEME-ENCRYPTME` in the SSID, and comes up as a new unit: random SSID, WPA2, unique passphrase. Reflashing is how an old device becomes a new unit.
Dev: Screen is dead, AP is encrypted, they never wrote the passphrase down. Now what?
Expert: Recovery reset. Physical access, restore the open default, join it, re-encrypt from the web interface. If the recovery config carried `ENCRYPTME`, the next boot would lock them out again — which is why it must not.
Loading
Loading