# M5Stack StopWatch 0.7.0 — Implementation Status

Product/firmware version: **0.7.0** (see `VERSION`, `firmware/CMakeLists.txt`,
`firmware/main/CMakeLists.txt`). Control Layer / `CONTROL_VERSION` stays
**0.5.0**.

This is the daily-usability closeout round. It adds battery/charging UX, fresh
Codex quota, and Wi-Fi profile provisioning, and hardens the agent
completion/approval notifications. Speech Output remains deferred.

## Scope

| Feature | Result | Evidence |
|---|---|---|
| Speech Output deferred | PASS | No WSS/TLS/chunk/jitter/queue changes; `speech_playback` still not advertised |
| Battery 100% | PASS | `battery_display_percent` + latch; `firmware/tests/battery_status_test.cpp` |
| Charging indicator | PASS | `ChargeState` + drawn bolt; `wearable_renderer_test.cpp` |
| Codex quota refresh | PASS | `_quota_periodic_loop`; `server/tests/test_codex_quota_refresh.py` |
| Wi-Fi profiles | PASS | `wifi_profiles.h`; `firmware/tests/wifi_profiles_test.cpp` |
| Manual SoftAP | PASS | `ProvisioningMode` + `wifi_manager_start_provisioning`; shell input + profile tests |
| QR | PASS | `qr_code.h`; `firmware/tests/qr_code_test.cpp` (reference matrix match) |
| Web provisioning | PASS | Portal in `wifi_manager.cpp`; secrets boundary test |
| Known networks | PASS | `Menu::KnownNetworks`; renderer + shell input tests |
| USB provisioning | PASS | `wifi_usb_protocol.h`, `tools/mac/wifi_ctl.py`; `wifi_usb_test.cpp`, `run_wifi_ctl_test.py` |
| DONE/FAILED notification | PASS | `network_resilience_test.cpp` exactly-once section |
| Approval notification | PASS | `ApprovalNotifier`; `approval_control_test.cpp` |

## Battery

Raw and display values are separated:

- `BatteryStatus.percent` is the honest voltage estimate.
- `BatteryStatus.display_percent` only reaches 100% when the PMIC reports
  **charge complete** (CHG_STAT deasserted with VIN > 4 V) **and** the filtered
  pack voltage is at/above `BATTERY_FULL_VOLTAGE_MV` (4150 mV). A latch with a
  release floor (`BATTERY_FULL_RELEASE_MV`, 4090 mV) prevents `98 ↔ 100`
  oscillation across plug/unplug. A plain 98% is never promoted.
- `ChargeState` is `Discharging / Charging / Full / ExternalIdle`. The Watch
  battery row draws a two-triangle lightning bolt from renderer primitives when
  external power is present, so no Unicode glyph coverage is required. The
  System battery detail appends `charging` / `full` / `external`.
- Only a successful VIN read may clear `external_power`, so a failed
  diagnostic read cannot silently unplug the device in the UI.

The one-minute `[POWER]` diagnostic now also records `display_percent`,
`external`, `charging`, `charge_complete` and the derived `charge_state`.

## Codex quota

`CodexAdapter` runs a bounded server-side periodic refresh
(`GATEWAY_CODEX_QUOTA_REFRESH_SECONDS`, default 60 s) in addition to the
existing initialize read and `account/rateLimits/updated` handling:

- The device still only reads the cached value from `server_snapshot`; there is
  no per-snapshot native RPC.
- `quota_refresh_lock` serializes every refresh, so a timer tick and an event
  coalesce into at most one in-flight `account/rateLimits/read`.
- The socket reader never awaits the RPC; `schedule_quota_refresh` only creates
  a task.
- A failed read keeps the last valid snapshot and the next cycle retries;
  `quota_updated_at` records the last success for diagnostics/tests.
- `disconnected()` / `close()` cancel the periodic task cleanly.

## Wi-Fi provisioning

Authoritative design: `docs/wifi-provisioning-plan.md` (unchanged; see its
Implementation Status section for the mapping).

Module boundaries (plan section 37):

```
wifi_manager (firmware/main)
    ├── WiFiProfileStore      boards/common/include/wifi_profiles.h
    ├── StationManager        wifi_manager.cpp station_task
    └── ProvisioningController wifi_manager.cpp (SoftAP + Portal + QR)
             └── QR encoder   boards/common/include/qr_code.h

USB CLI (tools/mac/wifi_ctl.py) ── same protocol ──> WiFiProfileStore / StationManager
Board UI (Settings > Network)  ── board_network_* ──> wifi_manager
```

Blocking requirements, all covered by host tests:

- **No automatic SoftAP.** Connection failure, absent known network, reboot
  without network, and Gateway unreachable all stay `OFFLINE / RECONNECTING`.
  Only `Settings → Network → Wi-Fi Setup` (an explicit, confirmed action)
  starts the AP.
- **Candidate-first.** Both front-ends share one validation routine. The Web
  Portal validates a submitted candidate and only persists it on success; a
  failure leaves existing profiles untouched and keeps the AP available.
  `USB wifi.add` runs the *same* validation: the credential is tested with a
  real station connection and is persisted only if it connects, at which point
  it is marked proven. A failed USB candidate is discarded and every existing
  profile is preserved. If a validation cannot run (a Portal test is in flight,
  the AP owns the interface, or the credential is malformed), the verb reports
  `result=UNAVAILABLE` rather than claiming success. Success marks the profile
  proven and (for the Portal) closes the AP without rebooting.
- **Candidate test disconnects first.** ESP-IDF requires an explicit
  `esp_wifi_disconnect()` before `esp_wifi_connect()` when the station is
  already associated. The candidate test therefore always runs
  pause → disconnect → settle → configure → connect → await, so a test while the
  device is online drops the current link first. On failure the candidate link
  is dropped and the normal station manager resumes, so a known profile can
  reconnect; on success the candidate link is kept.
- **Stable identity split.** Provisioning changes only SSID/password. Device
  identity, gateway URL and tokens are untouched.

USB wire format:

- Values are percent-encoded (RFC 3986 unreserved set), so spaces, `=`, `&`,
  `%`, `+` and UTF-8 SSIDs round-trip exactly. `+` is never used for space.
- `wifi.add` responds with an explicit `result=VERIFIED | REJECTED |
  UNAVAILABLE | INVALID | STORAGE_ERROR`.

Security boundary:

- Passwords never enter logs, the UI, the Portal response, or the USB response.
  `firmware/tests/wifi_secrets_test.py` enforces this statically, including
  that the Mac CLI exposes no `--password` flag.
- The Portal HTML-escapes every scanned SSID for both attribute and text
  contexts (`portal_html.h`), and rejects a POST body over 512 bytes before
  allocating anything.
- The Portal exposes only the device display name, nearby SSIDs, known SSID
  names, connection state and RSSI. No token, API key, STT credential, agent
  data, filesystem or shell.
- The fixed `PROVISIONING_AP_PASSWORD` (`agentpager-setup`) is defined once and
  is a development provisioning credential, not a Gateway credential.

USB recovery: `./tools/mac/device.sh wifi` reuses the existing serial discovery
and environment. The password is read by `wifi_ctl.py` through `getpass`, so it
never appears in process argv, and it is not echoed. Because a candidate test
also disconnects, connects, dispatches events and returns over serial, the host
waits `ADD_TIMEOUT_S` (25 s) for `add`, deliberately longer than the firmware's
15 s candidate budget. The firmware connection timeout is unchanged.

Build-time compatibility: `CONFIG_DEVICE_LAB_WIFI_SSID/_PASSWORD` are retained
as a **one-time first-run** migration source, never as a fallback. Migration is
gated by the pure `should_migrate_build_wifi` policy, which requires the store to
be genuinely **Missing**.

The store load has three distinct states, and they are never collapsed:

| State | Meaning | Migration | UI |
|---|---|---|---|
| `Missing` | key was never written | allowed once (if a build-time SSID exists) | `NO KNOWN WIFI` |
| `Valid` | image decoded, any profile count including 0 | never | connected or `NO KNOWN WIFI` |
| `Corrupt` | key existed but failed CRC/version/length/content validation | never | `NO KNOWN WIFI`, no SoftAP |
| (unreadable) | storage read failed | never | `NO KNOWN WIFI`, no SoftAP |

A deliberate Forget All persists an initialized-but-empty (`Valid`) store, so a
reboot keeps it empty and never re-imports the build-time credential. A corrupt
store exposes zero usable profiles but is **not** treated as a first boot, so a
damaged store can never silently restore a development network the user may have
removed. The storage boundary returns a `WiFiStoreLoadState`
(`Missing`/`Present`/`IoError`) so "key absent" is distinguishable from "key
present but invalid"; the profile store maps Present-but-rejected to `Corrupt`.

## Notifications

- `board_notify_pi` fires for a RUNNING → DONE/FAILED transition exactly once.
  Periodic snapshots and reconnects do not replay a historical terminal
  notification, and a stale session's terminal never reaches a switched device.
- `ApprovalNotifier` guarantees one vibration + approval tone per pending
  approval; periodic redraw does not repeat it, and a new approval after
  resolution notifies again.
- The vibration preference still gates vibration only; audio notification
  semantics are unchanged.

## Verification

```
./scripts/test-wearable-shell.sh        # host firmware + policy tests
python3 firmware/tests/run_network_resilience_test.py
python3 firmware/tests/run_shell_input_test.py
./scripts/test-server.sh                # server/Node/smoke + host tests
./scripts/build.sh m5stack-stopwatch    # firmware build
```

Firmware size for this round: `device_lab.bin` grew from `0x31dcd0` to
`0x32a800` (+55,088 bytes, ~53.8 KiB) for the Wi-Fi profile store, Portal,
QR encoder and settings screens; the app partition retains 21% free.

## Limitations

- **Speech Output deferred.** Local/LAN validation passed; production
  cross-border WSS/TLS showed unresolved multi-second read stalls. No WSS/TLS,
  buffer, queue, timeout, provider or ffmpeg changes were made.
- 2.4 GHz only. No BLE/NFC/WPS/SmartConfig, no 5 GHz, WPA Enterprise, or Mesh.
- Artificial Wi-Fi scan results are not de-duplicated by BSSID in the Portal
  list beyond what the scan returns.
- Physical acceptance pending. No device was flashed in this round.

### Mac Codex physical acceptance items

- **Candidate test while already connected (B7).** The disconnect-before-connect
  ordering is host-tested, but the ESP-IDF radio transition itself is not. On
  hardware, with the device already associated to network A:
  1. `./tools/mac/device.sh wifi` → Add B with a valid credential: expect
     `[WIFI] candidate test: disconnecting current ssid=A`, then `Save …
     Connection test: PASS.`, and B persisted and connected.
  2. Add C with a wrong password: expect the disconnect log, then
     `Connection test: FAIL`, C not persisted, and the station manager resuming
     so A reconnects on its own.
  3. Repeat from the Portal (`Settings → Network → Wi-Fi Setup`) to confirm the
     same disconnect-then-validate behaviour over the AP path.
- **Forget All across reboot (B6).** Forget All, power-cycle, and confirm the
  device stays `NO KNOWN WIFI` with no SoftAP and no reappearance of the
  build-time SSID.
- **Corrupt store (B9).** With a valid store present, damage the `wifi_store`
  NVS blob (for example erase a byte so the CRC fails), reboot with a non-empty
  build-time SSID configured, and confirm the log reports
  `store present but invalid; not migrating`, the device shows `NO KNOWN WIFI`,
  no SoftAP starts, and the build-time credential is **not** re-imported.