diff --git a/.envrc b/.envrc index 6670067..24480bf 100644 --- a/.envrc +++ b/.envrc @@ -15,3 +15,7 @@ export LIBCLANG_PATH="$HOME/.rustup/toolchains/esp/xtensa-esp32-elf-clang/esp-20 # The symlink in firmware/.lib points the old SONAME at the new library; # the ABI clang touches is small and stable enough that this works. export LD_LIBRARY_PATH="$PWD/firmware/.lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + +# Host-only secrets (OTA push directory, etc.) live in .envrc.private, +# which is gitignored. See .envrc.private.example for the shape. +source_env_if_exists .envrc.private diff --git a/.envrc.private.example b/.envrc.private.example new file mode 100644 index 0000000..860d41d --- /dev/null +++ b/.envrc.private.example @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Host-only env vars for the publish/OTA flow. +# +# Copy this file to `.envrc.private` (gitignored) and fill in real values. +# `.envrc` sources it automatically when direnv loads. +# +# These are needed by the `make ota-publish` flow on the dev machine; the +# firmware itself reads its own copy of OTA_URL_BASE from `firmware/cfg.toml` +# at compile time. + +# Where on this machine the .bin files get copied so the static HTTP server +# can pick them up. Subdirectory per project; the publish flow copies to +# $OTA_LOCAL_DIR/sound-machine-.bin. +export OTA_LOCAL_DIR="/path/to/firmware/sound-machine" + +# Public-ish base URL the device will fetch from. Must match the firmware's +# `ota_url_base` in cfg.toml. Trailing slash optional; the publish flow +# normalizes it. +export OTA_URL_BASE="http://firmware.example.lan/sound-machine" + +# MQTT broker URL used by `make ota-publish` to push the new latest_version +# to the shared topic. mosquitto_pub accepts the same scheme as the device's +# cfg.toml mqtt_url, so it's fine to reuse that value. +export MQTT_URL="mqtt://mqtt.example.lan:1883" diff --git a/.gitignore b/.gitignore index ab5424c..d00dfe3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .direnv/ +.envrc.private *.swp *.swo .DS_Store diff --git a/Makefile b/Makefile index f308795..3105be4 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ MAKEFLAGS += --no-print-directory -.PHONY: all firmware firmware-check firmware-flash firmware-flash-monitor firmware-monitor firmware-clean clean help +.PHONY: all firmware firmware-check firmware-flash firmware-flash-monitor firmware-monitor firmware-ota-publish firmware-clean clean help all: firmware @@ -25,6 +25,9 @@ firmware-flash-monitor: firmware-monitor: $(MAKE) -C firmware monitor +firmware-ota-publish: + $(MAKE) -C firmware ota-publish + firmware-clean: $(MAKE) -C firmware clean @@ -38,6 +41,7 @@ help: @echo " firmware-flash headless: build + flash, no monitor" @echo " firmware-flash-monitor interactive: build + flash + serial monitor (needs TTY)" @echo " firmware-monitor serial monitor only" + @echo " firmware-ota-publish save .bin to OTA_LOCAL_DIR + publish latest_version" @echo " firmware-clean cargo clean" @echo " clean clean everything" @echo "" diff --git a/README.md b/README.md index 70d5475..313e3fb 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,18 @@ This repo holds everything for the project: firmware, design docs, hardware refe ``` sound-machine/ -├── README.md # this file -├── Makefile # top-level entry: `make` builds firmware (and future model rendering) -├── .envrc # direnv: ESP toolchain env + libxml2 shim path -├── firmware/ # Rust firmware (esp-idf-svc, std). See firmware/README.md -└── reference/ # Design docs and hardware reference - ├── mqtt-contract.md # wire protocol between device and HA - ├── operating-modes.md # firmware state machine, LED scheme, NVS - ├── signal-chain.md # audio path: ESP32 → MAX98357A → speaker - ├── atom-echo/ # M5Stack Atom Echo pinmap, dimensions, schematic - ├── speakers/ # Adafruit 1314 driver notes - └── datasheets/ # vendor PDFs for ESP32-PICO-D4, NS4168, SPM1423 +├── README.md # this file +├── Makefile # top-level entry: `make` builds firmware (and future model rendering) +├── .envrc # direnv: ESP toolchain env + libxml2 shim path +├── .envrc.private.example # template for host-only secrets (OTA dir, MQTT URL) +├── firmware/ # Rust firmware (esp-idf-svc, std). See firmware/README.md +└── reference/ # Design docs and hardware reference + ├── mqtt-contract.md # wire protocol between device and HA + ├── operating-modes.md # firmware state machine, LED scheme, NVS, OTA + ├── signal-chain.md # audio path: ESP32 → MAX98357A → speaker + ├── atom-echo/ # M5Stack Atom Echo pinmap, dimensions, schematic + ├── speakers/ # Adafruit 1314 driver notes + └── datasheets/ # vendor PDFs for ESP32-PICO-D4, NS4168, SPM1423 ``` ## Status @@ -26,8 +27,9 @@ sound-machine/ - ✅ **Hardware research and selection complete** — see `reference/` - ✅ **MQTT contract and operating modes designed** — `reference/mqtt-contract.md`, `reference/operating-modes.md` - ✅ **Toolchain validated end-to-end** — `firmware/` builds, flashes, and runs on real hardware -- ✅ **v0.1.0 — offline-mode firmware** — button, audio, NVS, LED — 2026-04-25 +- ✅ **v0.1.0 — offline-mode firmware** — button, audio, NVS, LED - ✅ **v0.2.0 — online-mode firmware** — WiFi + MQTT + HA Discovery +- ✅ **v0.3.x — OTA-capable firmware** — two-slot partition layout, HA `update` entity with progress bar, `esp_ota_mark_app_valid` rollback - 🚧 **Awaiting hardware** — MAX98357A amps on order from DigiKey - 🚧 **Enclosure design** — 3D-printable case TBD @@ -35,7 +37,9 @@ sound-machine/ Each device runs Rust firmware (esp-idf-svc, std mode) on an Atom Echo. WiFi connects to the home network, MQTT to a LAN-only broker (no TLS), HA Discovery announces entities. The button publishes events; HA decides what to do; HA sends back a "play white noise" command. Audio is generated locally on-device (no streaming dependency) and sent over I2S to an external MAX98357A amp driving a 3" 4Ω speaker. Onboard NS4168 amp is bypassed (no I2S data sent to its pins) — it's known not to be sized for sustained white noise. When WiFi or MQTT drops, the device falls into offline mode where the button toggles white noise locally; same code path as travel use. -Both units run **the same firmware binary** — identity is derived at runtime from the chip's STA MAC and used directly as the topic-prefix segment (`nightstand//...`). HA users name each device in the HA UI; the MQTT contract guarantees stable `unique_id`s per MAC. One build, OTA-pushed to both. (OTA is v1.5; v1 ships USB-flashed.) +Firmware updates are over-the-air via HA's MQTT `update` entity: `make firmware-ota-publish` builds the new binary, copies it to a static HTTP host on the LAN, and announces the version on a shared MQTT topic. HA shows an Install button on each device's card; clicking it streams the firmware in over plain HTTP, with a live progress bar driven by retained MQTT publishes. ESP-IDF's two-slot partition layout means a broken firmware automatically rolls back to the previous version on the next reset. + +Both units run **the same firmware binary** — identity is derived at runtime from the chip's STA MAC and used directly as the topic-prefix segment (`nightstand//...`). HA users name each device in the HA UI; the MQTT contract guarantees stable `unique_id`s per MAC. For the gory details: `firmware/README.md`, `reference/mqtt-contract.md`, `reference/operating-modes.md`. diff --git a/firmware/Cargo.lock b/firmware/Cargo.lock index 305efe5..e83fe3c 100644 --- a/firmware/Cargo.lock +++ b/firmware/Cargo.lock @@ -1543,7 +1543,7 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "sound-machine" -version = "0.2.0" +version = "0.3.4" dependencies = [ "anyhow", "embuild", diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index f2609a4..8fcc5af 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sound-machine" -version = "0.2.0" +version = "0.3.4" edition = "2021" resolver = "2" rust-version = "1.77" diff --git a/firmware/Makefile b/firmware/Makefile index 626ae7a..814a792 100644 --- a/firmware/Makefile +++ b/firmware/Makefile @@ -12,18 +12,38 @@ SYSTEM_LIBXML2 := $(firstword $(wildcard /usr/lib/x86_64-linux-gnu/libxml2.so.16 export LD_LIBRARY_PATH := $(LIB_DIR):$(LD_LIBRARY_PATH) +# Inject the absolute path to partitions.csv via a generated overlay file. +# ESP-IDF resolves relative CONFIG_PARTITION_TABLE_FILENAME against an +# internal CMake source dir under target/, not against this directory, so +# baking a relative path into the committed sdkconfig.defaults would fail. +# Generating the overlay here keeps the committed config portable. +GEN_DIR := $(CURDIR)/target/gen +GEN_PARTITIONS_SDKCONFIG := $(GEN_DIR)/sdkconfig.defaults.partitions +PARTITIONS_CSV := $(CURDIR)/partitions.csv + +# esp-idf-sys reads ESP_IDF_SDKCONFIG_DEFAULTS as the list of project sdkconfig +# defaults files (it then internally builds its own SDKCONFIG_DEFAULTS env var +# for cmake by prepending its generated defaults). +export ESP_IDF_SDKCONFIG_DEFAULTS := $(CURDIR)/sdkconfig.defaults;$(GEN_PARTITIONS_SDKCONFIG) + CARGO ?= cargo BIN := target/xtensa-esp32-espidf/release/sound-machine +BOOTLOADER_BIN := target/xtensa-esp32-espidf/release/bootloader.bin # First /dev/ttyUSB* / /dev/ttyACM* found, used for headless flashing. # Override on the command line: `make flash PORT=/dev/ttyUSB1` PORT ?= $(firstword $(wildcard /dev/ttyUSB* /dev/ttyACM*)) -.PHONY: build check flash flash-monitor monitor clean help $(LIBXML2_COMPAT) +# Firmware version, parsed from Cargo.toml. Used for the OTA filename and +# the latest_version MQTT publish. +VERSION := $(shell sed -nE '0,/^version *= *"([^"]+)"/{s//\1/p}' Cargo.toml) +OTA_BIN := sound-machine-$(VERSION).bin + +.PHONY: build check flash flash-monitor monitor clean help ota-publish $(LIBXML2_COMPAT) -build: $(LIBXML2_COMPAT) +build: $(LIBXML2_COMPAT) $(GEN_PARTITIONS_SDKCONFIG) $(CARGO) build --release -check: $(LIBXML2_COMPAT) +check: $(LIBXML2_COMPAT) $(GEN_PARTITIONS_SDKCONFIG) $(CARGO) check # Headless flash — builds, then writes to the device with no interactive @@ -34,16 +54,47 @@ flash: build echo "no serial port found (looked for /dev/ttyUSB* and /dev/ttyACM*)"; \ exit 1; \ fi - espflash flash --port $(PORT) $(BIN) + espflash flash --port $(PORT) \ + --bootloader $(BOOTLOADER_BIN) \ + --partition-table $(PARTITIONS_CSV) \ + --erase-parts otadata \ + $(BIN) # Interactive flash + monitor — builds, flashes, attaches the serial monitor. # Needs a TTY (the monitor writes to terminal and reads keyboard input). -flash-monitor: $(LIBXML2_COMPAT) +flash-monitor: $(LIBXML2_COMPAT) $(GEN_PARTITIONS_SDKCONFIG) $(CARGO) run --release monitor: espflash monitor +# Publish a new firmware version. Reads OTA_LOCAL_DIR / OTA_URL_BASE / MQTT_URL +# from the environment (sourced via direnv from .envrc.private). Builds the +# release binary, generates the flat .bin via espflash save-image, copies it +# into the static-HTTP serve directory, then publishes the new latest_version +# retained to the shared MQTT topic. Both nightstands' HA update cards light +# up the moment the publish lands. +ota-publish: build + @if [ -z "$(OTA_LOCAL_DIR)" ]; then \ + echo "OTA_LOCAL_DIR is not set (see .envrc.private.example)"; exit 1; \ + fi + @if [ -z "$(OTA_URL_BASE)" ]; then \ + echo "OTA_URL_BASE is not set (see .envrc.private.example)"; exit 1; \ + fi + @if [ -z "$(MQTT_URL)" ]; then \ + echo "MQTT_URL is not set (see .envrc.private.example)"; exit 1; \ + fi + @if [ -z "$(VERSION)" ]; then \ + echo "could not parse version from Cargo.toml"; exit 1; \ + fi + @echo "publishing v$(VERSION) → $(OTA_URL_BASE)/$(OTA_BIN)" + espflash save-image --chip esp32 --flash-size 4mb $(BIN) $(OTA_LOCAL_DIR)/$(OTA_BIN) + mosquitto_pub \ + -L "$(MQTT_URL)/sound-machine/firmware/latest" \ + -r \ + -m "$(VERSION)" + @echo "done. devices will see the update in HA within a few seconds." + clean: $(CARGO) clean @@ -60,6 +111,14 @@ $(LIBXML2_COMPAT): fi; \ fi +# Generated sdkconfig overlay carrying the absolute path to partitions.csv. +# Regenerated whenever the CSV or this Makefile changes. +$(GEN_PARTITIONS_SDKCONFIG): $(PARTITIONS_CSV) $(MAKEFILE_LIST) + @mkdir -p $(GEN_DIR) + @printf 'CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="%s"\nCONFIG_PARTITION_TABLE_FILENAME="%s"\n' \ + '$(PARTITIONS_CSV)' '$(PARTITIONS_CSV)' > $@ + @echo "generated $@" + help: @echo "firmware targets:" @echo " build cargo build --release (default)" @@ -67,4 +126,5 @@ help: @echo " flash headless: build + flash, no monitor (works in any shell)" @echo " flash-monitor interactive: build + flash + serial monitor (needs a TTY)" @echo " monitor espflash monitor only" + @echo " ota-publish save .bin to \$$OTA_LOCAL_DIR + publish latest_version to MQTT" @echo " clean cargo clean" diff --git a/firmware/README.md b/firmware/README.md index 4c05609..9ccc7d1 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -6,7 +6,7 @@ For the *what* and *why* (architecture, MQTT contract, operating modes, signal c ## Status -**Milestone v0.2.0 — online mode (WiFi + MQTT + HA Discovery).** Adds WiFi/MQTT layered on the v0.1.0 offline foundation. Short press now round-trips through HA when online (button publishes `{"event_type":"short"}`, HA's automation publishes back `cmd/play ON`/`OFF`); offline, short-press still toggles audio locally as a fallback. Long-press cycles volume locally in both modes (with a publish for HA logging when online). Double-press is purely an MQTT gesture for HA's late-night-lights routine. The MAC-derived topic prefix means one binary works on every unit; HA names devices in its UI. +**Milestone v0.3.x — OTA-capable online firmware.** v0.3 adds end-to-end OTA: a two-slot partition layout, an HA `update` entity with installed/latest versions and a live progress bar, the `make ota-publish` workflow, and `esp_ota_mark_app_valid_cancel_rollback` boot validation so a broken firmware reverts to the previous slot. v0.2 (online mode: WiFi + MQTT + HA Discovery, round-tripped short-press) and v0.1 (offline button + audio + NVS + LED) are the foundation underneath. One binary works on every unit — identity is derived at runtime from the STA MAC. | Subsystem | State | | --- | --- | @@ -16,10 +16,10 @@ For the *what* and *why* (architecture, MQTT contract, operating modes, signal c | Continuous pink noise generator (Paul Kellet IIR) | ✅ xorshift32 white → pink filter, volume-scaled | | Button state machine (short / long / double) | ✅ working | | NVS persistence (volume + direction + playing) | ✅ working | -| RGB LED (SK6812 on G27 via RMT) | ✅ working — composes (net, audio) → color, press-flash overlay | +| RGB LED (SK6812 on G27 via RMT) | ✅ working — (net, audio) base + OTA/Error overrides + press-flash | | WiFi | ✅ working — STA, hostname `nightstand-`, auto-reconnect | -| MQTT client + HA discovery | ✅ working — LWT, retained discovery on (re)connect, `cmd/play` + `cmd/volume` subscribed | -| OTA updates | ❌ v1.5 (design in `reference/mqtt-contract.md`) | +| MQTT client + HA discovery | ✅ working — LWT, retained discovery on (re)connect, `cmd/play`/`cmd/volume`/`cmd/update` subscribed | +| OTA updates | ✅ working — `esp_https_ota` chunked download with HA progress bar, two-slot rollback | | Hardware: external MAX98357A + 1314 speaker | ❌ amps in transit; using onboard for now | ## Module layout @@ -32,13 +32,15 @@ firmware/src/ ├── nvs.rs — typed NVS wrapper for volume_index, volume_direction, was_playing ├── audio.rs — I2S + xorshift white noise + Paul Kellet pink filter + audio task ├── button.rs — 5-state button FSM + button task (owns G39 PinDriver) -├── led.rs — SK6812 RMT driver + LED task; (net, audio) → color composition +├── led.rs — SK6812 RMT driver + LED task; (net, audio) base + OTA/Error overrides ├── network.rs — WiFi + MQTT state machine; gatekeeper for online/offline routing ├── discovery.rs — HA Discovery JSON payloads (built via format!() — no serde_json) +├── ota.rs — esp_https_ota wrapper with chunked progress + mark_app_valid +├── channels.rs — FreeRTOS-queue-backed Sender/Receiver (std::sync::mpsc is broken on esp-idf) └── secrets.rs — toml-cfg config struct sourced from cfg.toml at compile time ``` -The deliberate factoring: each task owns its peripherals exclusively; cross-task communication is via `std::sync::mpsc` channels carrying typed events. The network task is the gatekeeper — it decides whether short presses toggle audio locally (offline) or wait for HA to publish back via MQTT (online). Long-press cycles volume locally in both modes; double is a pure-MQTT gesture. +The deliberate factoring: each task owns its peripherals exclusively; cross-task communication is via FreeRTOS-queue channels carrying typed events. The network task is the gatekeeper — it decides whether short presses toggle audio locally (offline) or wait for HA to publish back via MQTT (online), and it owns the OTA install path. Long-press cycles volume locally in both modes; double is a pure-MQTT gesture. ## Build & flash @@ -46,10 +48,12 @@ Project uses direnv (`.envrc` at the project root). Open a terminal in any subdi ```bash # from anywhere in the repo: -make firmware # cargo build --release -make firmware-flash # cargo run --release (flash + monitor) -make firmware-monitor # serial monitor only -make firmware-check # cargo check +make firmware # cargo build --release +make firmware-flash # headless: build + write bootloader + partition table + app +make firmware-flash-monitor # interactive: build + flash + serial monitor (needs TTY) +make firmware-monitor # serial monitor only +make firmware-check # cargo check +make firmware-ota-publish # build + save .bin to OTA dir + publish latest_version (see OTA below) make firmware-clean ``` @@ -58,12 +62,16 @@ Or directly with cargo: ```bash cd firmware cargo build --release -espflash flash --port /dev/ttyUSB0 target/xtensa-esp32-espidf/release/sound-machine +espflash flash --port /dev/ttyUSB0 \ + --bootloader target/xtensa-esp32-espidf/release/bootloader.bin \ + --partition-table partitions.csv \ + --erase-parts otadata \ + target/xtensa-esp32-espidf/release/sound-machine ``` Incremental builds are ~5 s. First-time builds are ~20 min — they download and compile ESP-IDF (~500 MB) plus all the Rust deps. -### `cfg.toml` — secrets +### `cfg.toml` — compile-time config Before the first build, copy [`cfg.toml.example`](cfg.toml.example) to `cfg.toml` and fill in real values: @@ -72,9 +80,16 @@ Before the first build, copy [`cfg.toml.example`](cfg.toml.example) to `cfg.toml wifi_ssid = "your-wifi-ssid" wifi_password = "your-wifi-password" mqtt_url = "mqtt://your-broker.lan:1883" +ota_url_base = "http://firmware.example.lan/sound-machine" ``` -`cfg.toml` is gitignored. The build will panic at boot if any of the three values is empty, so you can't accidentally flash a no-config binary. +`cfg.toml` is gitignored. The firmware checks for empty values at boot and refuses to proceed with an empty `wifi_ssid`/`wifi_password`/`mqtt_url`, so you can't accidentally flash a no-config binary. An empty `ota_url_base` only blocks OTA installs (with a warning); the rest of the firmware still runs. + +**Compile-time means wired-flash** — changing any of these values requires a USB reflash of every device that needs the new value. WiFi/MQTT/OTA host changes are infrequent enough to be worth the inconvenience for the simpler model. + +### `.envrc.private` — host-only secrets + +The publish flow needs three host-side variables — `OTA_LOCAL_DIR` (where to copy the .bin), `OTA_URL_BASE` (matches `cfg.toml`), and `MQTT_URL`. Copy [`../.envrc.private.example`](../.envrc.private.example) to `../.envrc.private` and fill in real values; direnv loads it automatically. `.envrc.private` is gitignored. ### Monitoring without re-flashing @@ -86,6 +101,44 @@ espflash monitor --port /dev/ttyUSB0 `Ctrl+C` exits the monitor; the device keeps running. Anything written via `log::info!()` etc. shows up here, prefixed with the crate name and a millisecond timestamp. +## OTA workflow + +Once a device is on v0.3.x, the next version goes out over WiFi. Two parts: the publish, and the install. + +### Publish (your dev machine) + +```bash +# Cargo.toml: bump version +make firmware-ota-publish +``` + +That target: +1. Builds a release binary +2. `espflash save-image`s it to `$OTA_LOCAL_DIR/sound-machine-.bin` +3. `mosquitto_pub`s the new version retained to `sound-machine/firmware/latest` + +Both nightstands' HA update cards light up within a couple seconds. + +### Install (HA UI) + +Click "Install" on the device's Firmware card. The device: +1. LED switches to a magenta pulse +2. Streams the binary into the inactive OTA slot, publishing `update_percentage` every 5 % (HA renders a progress bar) +3. Reboots into the new slot +4. Reconnects to WiFi + MQTT +5. Calls `esp_ota_mark_app_valid_cancel_rollback` — confirms the new firmware works and disables the bootloader's pending-rollback timer +6. Republishes `installed_version` matching `latest_version`; HA's card flips back to "Up to date" + +If the new firmware fails to reach MQTT (panic, WiFi misconfig, etc.) the bootloader rolls back to the previous slot on the next reset, and the device comes up on the old version. HA notices `installed_version` reverted and shows "Update available" again. + +### Bootstrap (the one-time wired flash) + +Going from a pre-v0.3 partition layout (single-slot) to v0.3.x's two-slot layout requires a wired `make firmware-flash` once per device. The `flash` target writes the new bootloader, partition table, and otadata in addition to the app. After that, OTA is the path forever; if you ever need wired access (changing `cfg.toml`, debugging OTA breakage in the OTA path itself), USB still works. + +### Webserver expectations + +`$OTA_LOCAL_DIR` should be served as static HTTP at `$OTA_URL_BASE`. Plain HTTP is fine and intentional — the trust boundary is the LAN, same as MQTT. ESP-IDF will refuse plain HTTP for OTA *unless* `CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP=y` is set in `sdkconfig.defaults` (it is). + ## Toolchain (one-time setup) Already done on Chris's vega; documented here for re-setup or a second machine. @@ -156,7 +209,21 @@ ESP-IDF's I2S driver, once enabled, will keep clocking out whatever's in its DMA This keeps the amp's input continuously fed, even when "doing nothing." -### 5. The onboard NS4168 + tiny speaker is a prototype-only path +### 5. `std::sync::mpsc` and `crossbeam-channel` are broken on esp-idf-rs + +Both libraries assume the GNU/newlib `pthread_mutex_t` ABI (40 bytes, zero-initializer). ESP-IDF's pthread layer uses a 4-byte handle with `0xffffffff` as the lazy-init sentinel. Sending across a channel under contention triggers `pthread_mutex_lock` on a misaligned struct → `LoadProhibited` exception. Symptom: `::try_recv` or `::send` panics in the bowels of mpsc. + +**Fix:** [`channels.rs`](src/channels.rs) wraps `esp_idf_svc::hal::task::queue::Queue` (the FreeRTOS native queue, ISR-safe, byte-copy semantics) with `Sender`/`Receiver` types that mimic the mpsc API. `T: Copy` is required (FreeRTOS queues memcpy values) — usually fine for typed events. Use these in place of `std::sync::mpsc::channel` everywhere. + +### 6. ESP-IDF's `CONFIG_PARTITION_TABLE_FILENAME` resolves relative to the CMake source dir, not your project + +The ESP-IDF Kconfig docs imply the path is relative to your project. In an esp-idf-rs build, the CMake "project" is the embuild-generated directory under `target/`, so a relative path silently fails. The Makefile generates a small sdkconfig overlay at build time with the *absolute* path to `partitions.csv`, chained via `ESP_IDF_SDKCONFIG_DEFAULTS`. The committed `sdkconfig.defaults` only carries the boolean (`CONFIG_PARTITION_TABLE_CUSTOM=y`). + +### 7. `esp_https_ota` rejects plain HTTP unless an opt-in flag is set + +Despite working fine at the protocol level, `esp_https_ota` validates the URL scheme and refuses plain HTTP unless `CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP=y` is in sdkconfig. With the flag missing, `esp_https_ota_begin` returns immediately with an error and never opens a socket — webserver logs show no GET requests. The flag is set in `sdkconfig.defaults`. + +### 8. The onboard NS4168 + tiny speaker is a prototype-only path We're using the I2S pins that drive the built-in amp (G19 BCLK, G22 DOUT, G33 LRCK) for hello-world. The plan ([signal-chain.md](../reference/signal-chain.md)) is to switch to an external MAX98357A on G21/G26/G32 once amps arrive. Don't run sustained white noise on the built-in speaker — short test bursts only. The thermal warning on this is real (we briefly demonstrated it via the swapped-pins bug above). @@ -168,11 +235,16 @@ firmware/ ├── rust-toolchain.toml # pins to the `esp` channel ├── .cargo/config.toml # target = xtensa-esp32-espidf, ldproxy, runner ├── build.rs # embuild bootstrap -├── sdkconfig.defaults # ESP-IDF kconfig knobs +├── Makefile # build/flash/ota-publish entry points +├── sdkconfig.defaults # ESP-IDF kconfig knobs (4MB flash, two-OTA layout, allow-HTTP-OTA) +├── partitions.csv # custom two-slot OTA partition table +├── cfg.toml.example # template for compile-time config +├── cfg.toml # real values (gitignored) ├── src/ -│ └── main.rs # entry point — currently the hello-world +│ └── *.rs # see "Module layout" above ├── .lib/ # libxml2 shim (gitignored) ├── target/ # cargo build output (gitignored) +│ └── gen/ # generated sdkconfig overlay for absolute partitions.csv path └── .embuild/ # embuild-managed ESP-IDF clone (gitignored) ``` diff --git a/firmware/cfg.toml.example b/firmware/cfg.toml.example index 733768e..6877833 100644 --- a/firmware/cfg.toml.example +++ b/firmware/cfg.toml.example @@ -12,3 +12,7 @@ wifi_ssid = "your-wifi-ssid" wifi_password = "your-wifi-password" # Full URL form so we can swap to mqtts://host:8883 later without a schema change. mqtt_url = "mqtt://mqtt.example.local:1883" +# Base URL the device fetches OTA images from. Image URL is +# `/sound-machine-.bin`. Plain HTTP is fine on a +# trusted LAN; TLS-without-code-signing only protects transit, not authenticity. +ota_url_base = "http://firmware.example.lan/sound-machine" diff --git a/firmware/partitions.csv b/firmware/partitions.csv new file mode 100644 index 0000000..2f10349 --- /dev/null +++ b/firmware/partitions.csv @@ -0,0 +1,22 @@ +# Two-slot OTA partition table for the Atom Echo (4MB flash, ESP32-PICO-D4). +# +# Layout: +# 0x01000 - 0x08000 bootloader (28KB, fixed) +# 0x08000 - 0x09000 partition table (this file, 4KB) +# 0x09000 - 0x0F000 nvs 24KB — audio state, WiFi creds cache +# 0x0F000 - 0x11000 otadata 8KB — bootloader's "active slot" pointer +# 0x11000 - 0x12000 phy_init 4KB — RF calibration data +# 0x20000 - 0x200000 ota_0 1.875MB — app slot A +# 0x200000- 0x3E0000 ota_1 1.875MB — app slot B +# 0x3E0000- 0x400000 (free) 128KB — slack for future use +# +# App partitions must be 0x10000-aligned, hence the 56KB gap after phy_init. +# NVS offset/size is unchanged from the single-app default so existing +# persisted state (volume, was_playing) survives the swap. +# +# Name, Type, SubType, Offset, Size, +nvs, data, nvs, 0x9000, 0x6000, +otadata, data, ota, 0xf000, 0x2000, +phy_init, data, phy, 0x11000, 0x1000, +ota_0, app, ota_0, 0x20000, 0x1E0000, +ota_1, app, ota_1, 0x200000, 0x1E0000, diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults index b81f830..1d72d91 100644 --- a/firmware/sdkconfig.defaults +++ b/firmware/sdkconfig.defaults @@ -10,3 +10,31 @@ CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384 # Use the hostname we set on the netif for DHCP, so routers and HA show # something meaningful (nightstand-) instead of generic ESP32-XXXX CONFIG_LWIP_LOCAL_HOSTNAME=y + +# 4MB flash on the ESP32-PICO-D4 in the Atom Echo. Default is 2MB which +# can't accommodate two OTA slots. +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="4MB" + +# Custom partition table with two OTA slots (see partitions.csv). This is +# the one-time disruptive change — after this flash, every future firmware +# update can be delivered over-the-air via MQTT. +# +# The absolute path to partitions.csv is injected via a generated overlay +# (see Makefile target sdkconfig.defaults.partitions). ESP-IDF resolves +# relative paths against an internal CMake dir buried in target/, not this +# directory, so a relative entry here would break the build. +CONFIG_PARTITION_TABLE_CUSTOM=y + +# Enable rollback support: new firmware boots in a "pending verify" state. +# If the app crashes (or doesn't call esp_ota_mark_app_valid_cancel_rollback) +# before the next reset, the bootloader rolls back to the previous slot. +# Belt and braces against bricked devices. +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y + +# Permit `esp_https_ota` to fetch over plain HTTP. Without this, the C +# function rejects http:// URLs at config-validation time and never opens +# a socket. Our LAN-only firmware host is plain HTTP intentionally — TLS +# without code signing only protects transit, not authenticity, and the +# trust boundary already matches MQTT. +CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP=y diff --git a/firmware/src/discovery.rs b/firmware/src/discovery.rs index 70dd7ea..cd9cc7c 100644 --- a/firmware/src/discovery.rs +++ b/firmware/src/discovery.rs @@ -23,8 +23,14 @@ pub struct DiscoveryEntry { pub payload: String, } -/// Build the discovery entries (button, switch, number, uptime sensor) for -/// the given device. `mac_hex` is lowercase hex with no separators. +/// Topic that carries the latest available firmware version. Same for every +/// device on this firmware; the publisher pushes one retained message and +/// every nightstand sees it. Per-device `installed_version` lives under +/// `nightstand//update/installed`. +pub const SHARED_LATEST_VERSION_TOPIC: &str = "sound-machine/firmware/latest"; + +/// Build the discovery entries (button, switch, number, uptime sensor, update) +/// for the given device. `mac_hex` is lowercase hex with no separators. pub fn all(mac_hex: &str, sw_version: &str) -> Vec { let device_id = format!("nightstand_{mac_hex}"); let topic_prefix = format!("nightstand/{mac_hex}"); @@ -36,6 +42,7 @@ pub fn all(mac_hex: &str, sw_version: &str) -> Vec { switch(&device_id, &topic_prefix, &avail_topic, &state_topic), number(&device_id, &topic_prefix, &avail_topic, &state_topic), uptime(&device_id, &avail_topic, &state_topic), + update(&device_id, &topic_prefix, &avail_topic), ] } @@ -123,19 +130,77 @@ fn uptime(device_id: &str, avail: &str, state_topic: &str) -> DiscoveryEntry { DiscoveryEntry { topic, payload } } +/// HA `update` entity. The state_topic carries a single JSON payload with +/// `installed_version`, `in_progress`, and (during a download) the +/// `update_percentage` — that gives HA enough to render a progress bar +/// while OTA runs. The latest_version comes from the shared topic so a +/// single `make ota-publish` lights up the card on every nightstand at +/// once. `cmd/update` receives `install` when the user clicks Install +/// (`cmd/+` is already subscribed for play/volume). +fn update(device_id: &str, topic_prefix: &str, avail: &str) -> DiscoveryEntry { + let topic = format!("homeassistant/update/{device_id}/firmware/config"); + let payload = format!( + concat!( + r#"{{"name":"Firmware","unique_id":"{device_id}_update","#, + r#""state_topic":"{topic_prefix}/update/state","#, + r#""latest_version_topic":"{shared}","#, + r#""latest_version_template":"{{{{ value }}}}","#, + r#""command_topic":"{topic_prefix}/cmd/update","#, + r#""payload_install":"install","#, + r#""device_class":"firmware","entity_category":"config","#, + r#""device":{{"identifiers":["{device_id}"]}},"#, + r#""availability_topic":"{avail}"}}"#, + ), + device_id = device_id, + topic_prefix = topic_prefix, + shared = SHARED_LATEST_VERSION_TOPIC, + avail = avail, + ); + DiscoveryEntry { topic, payload } +} + #[cfg(test)] mod tests { use super::*; #[test] - fn four_entries_with_correct_topics() { + fn five_entries_with_correct_topics() { let entries = all("aabbccddeeff", "0.2.0"); - assert_eq!(entries.len(), 4); + assert_eq!(entries.len(), 5); let topics: Vec<&str> = entries.iter().map(|e| e.topic.as_str()).collect(); assert!(topics.contains(&"homeassistant/sensor/nightstand_aabbccddeeff/button/config")); assert!(topics.contains(&"homeassistant/switch/nightstand_aabbccddeeff/white_noise/config")); assert!(topics.contains(&"homeassistant/number/nightstand_aabbccddeeff/volume/config")); assert!(topics.contains(&"homeassistant/sensor/nightstand_aabbccddeeff/uptime/config")); + assert!(topics.contains(&"homeassistant/update/nightstand_aabbccddeeff/firmware/config")); + } + + #[test] + fn update_entry_uses_shared_latest_and_json_state() { + let entries = all("aabbccddeeff", "0.3.0"); + let update = entries + .iter() + .find(|e| e.topic.contains("/update/")) + .expect("update entry"); + assert!( + update.payload.contains(SHARED_LATEST_VERSION_TOPIC), + "{}", + update.payload + ); + assert!( + update + .payload + .contains(r#""command_topic":"nightstand/aabbccddeeff/cmd/update""#), + "{}", + update.payload + ); + assert!( + update + .payload + .contains(r#""state_topic":"nightstand/aabbccddeeff/update/state""#), + "{}", + update.payload + ); } #[test] diff --git a/firmware/src/events.rs b/firmware/src/events.rs index a835fd9..d53b08e 100644 --- a/firmware/src/events.rs +++ b/firmware/src/events.rs @@ -64,8 +64,9 @@ pub enum NetStatus { /// What the audio / network tasks tell the LED task to display. /// /// The LED task tracks the most recent `Audio(_)` and `Net(_)` separately and -/// renders the combined color from a 2-axis lookup. `Error` overrides both; -/// `PressFlash` is an overlay that brightens whatever is currently shown. +/// renders the combined color from a 2-axis lookup. `Updating` and `Error` +/// override both; `PressFlash` is an overlay that brightens whatever is +/// currently shown. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LedSignal { /// Audio task reporting current playback state. @@ -74,6 +75,14 @@ pub enum LedSignal { Net(NetStatus), /// Brief brighter flash on top of whatever's currently being shown. PressFlash, + /// OTA download in progress — overrides everything else with a magenta + /// pulse so it's visually obvious the device is mid-update. + Updating, + /// OTA download finished (either success or failure). Clears the + /// `Updating` override so the LED falls back to the audio/net axes. + /// On success the device reboots immediately, so this primarily exists + /// for the failure path (so the LED doesn't stay stuck magenta). + UpdateDone, /// Something's broken — slow red blink. Reserved for unrecoverable /// failures (I2S init, etc.); network outages are just `Net(Offline)`. Error, diff --git a/firmware/src/led.rs b/firmware/src/led.rs index 831704a..b050e8f 100644 --- a/firmware/src/led.rs +++ b/firmware/src/led.rs @@ -84,10 +84,11 @@ fn led_loop( let mut tx = TxRmtDriver::new(rmt_channel, pin, &config) .map_err(|e| anyhow!("RMT init: {e}"))?; - // Two orthogonal pieces of state, plus the optional overriding Error and - // the transient PressFlash overlay. + // Two orthogonal pieces of state, plus optional overrides (Updating, + // Error) and the transient PressFlash overlay. let mut audio = AudioStatus::Idle; let mut net = NetStatus::Connecting; + let mut updating = false; let mut error = false; let mut flash_started: Option = None; let start = Instant::now(); @@ -106,12 +107,16 @@ fn led_loop( Some(LedSignal::Audio(a)) => audio = a, Some(LedSignal::Net(n)) => net = n, Some(LedSignal::PressFlash) => flash_started = Some(Instant::now()), + Some(LedSignal::Updating) => updating = true, + Some(LedSignal::UpdateDone) => updating = false, Some(LedSignal::Error) => error = true, None => { // Timeout — render the next frame. let elapsed = Instant::now().duration_since(start); let base = if error { error_color(elapsed) + } else if updating { + updating_color(elapsed) } else { base_color_for(net, audio, elapsed) }; @@ -147,6 +152,15 @@ fn base_color_for(net: NetStatus, audio: AudioStatus, t: Duration) -> Rgb { } } +/// Magenta pulse during an OTA download — distinct from any normal state so +/// it's obvious the device is mid-update and shouldn't be power-cycled. +fn updating_color(t: Duration) -> Rgb { + let phase = (t.as_millis() as f32 / 400.0) * std::f32::consts::PI; + let pulse = (phase.sin() * 0.5 + 0.5) * 35.0 + 10.0; + let v = pulse as u8; + Rgb::new(v, 0, v) +} + /// ~2 Hz red blink for unrecoverable failures (I2S init, etc.). fn error_color(t: Duration) -> Rgb { if (t.as_millis() / 250) % 2 == 0 { diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 4b24795..5e6a8ee 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -28,6 +28,7 @@ mod events; mod led; mod network; mod nvs; +mod ota; mod secrets; mod state; diff --git a/firmware/src/network.rs b/firmware/src/network.rs index 050410e..47973d2 100644 --- a/firmware/src/network.rs +++ b/firmware/src/network.rs @@ -29,6 +29,7 @@ use crate::discovery; use crate::events::{ AudioCommand, ButtonEvent, LedSignal, NetStatus, OutboundEvent, StateSnapshot, }; +use crate::ota; use crate::secrets::CONFIG; use crate::state::snap_to_preset_index; use anyhow::{anyhow, Result}; @@ -42,6 +43,8 @@ use esp_idf_svc::wifi::{ BlockingWifi, ClientConfiguration, Configuration, EspWifi, WifiDeviceId, }; use log::{info, warn}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::thread::{Builder, JoinHandle}; use std::time::{Duration, Instant}; @@ -88,6 +91,49 @@ enum NetTaskMsg { Connected, Disconnected, Outbound(OutboundEvent), + /// New `latest_version` seen on the shared topic. Cached so that an + /// install request later can build the download URL from it. + LatestVersion(VersionBuf), + /// HA published `install` to `cmd/update`. + OtaInstall, + /// OTA worker reporting download progress (0..=100). + OtaProgress(u8), + /// OTA worker finished (true = success, false = failure). On success + /// the worker also calls `esp_restart` so we may never observe this + /// variant for the success case; on failure it lets us repaint the + /// LED and clear the in-progress state. + OtaFinished(bool), +} + +/// Stack-allocated, `Copy`-friendly version string. FreeRTOS queues copy by +/// value, so we can't pass `String`/`heapless::String` (both are non-Copy). +/// 31 bytes covers any reasonable semver, including pre-release tags. +#[derive(Debug, Clone, Copy)] +struct VersionBuf { + bytes: [u8; 31], + len: u8, +} + +impl VersionBuf { + fn from_bytes(b: &[u8]) -> Option { + if b.is_empty() || b.len() > 31 { + return None; + } + // Reject anything that isn't valid UTF-8; saves the as_str caller a + // failure mode it can't recover from. + std::str::from_utf8(b).ok()?; + let mut bytes = [0u8; 31]; + bytes[..b.len()].copy_from_slice(b); + Some(Self { + bytes, + len: b.len() as u8, + }) + } + + fn as_str(&self) -> &str { + // SAFETY: from_bytes verified UTF-8 at construction. + unsafe { std::str::from_utf8_unchecked(&self.bytes[..self.len as usize]) } + } } fn run( @@ -133,6 +179,12 @@ fn run( let cmd_filter = format!("{topic_prefix}/cmd/+"); let cmd_play_topic = format!("{topic_prefix}/cmd/play"); let cmd_volume_topic = format!("{topic_prefix}/cmd/volume"); + let cmd_update_topic = format!("{topic_prefix}/cmd/update"); + // HA's update entity reads this single JSON-state topic for installed + // version, in_progress flag, and update_percentage. We keep the file- + // path-style suffix `update/state` even though the discovery payload + // calls it state_topic — clearer when subscribed via `mosquitto_sub`. + let update_state_topic = format!("{topic_prefix}/update/state"); let client_id = format!("nightstand_{mac_hex}"); let hostname = format!("nightstand-{mac_hex}"); @@ -175,10 +227,13 @@ fn run( // Connected/Disconnected → state-thread queue // Received(/cmd/play) → audio_tx::Play/Stop // Received(/cmd/volume) → audio_tx::SetVolumeIndex(snap_to_preset(pct)) + // Received(/cmd/update) → state-thread queue (install request) + // Received(shared latest)→ state-thread queue (cache new version) let cb_msg_tx = msg_tx.clone(); let cb_audio_tx = audio_tx.clone(); let cb_cmd_play = cmd_play_topic.clone(); let cb_cmd_volume = cmd_volume_topic.clone(); + let cb_cmd_update = cmd_update_topic.clone(); let mqtt_lwt_payload = b"offline"; let mqtt_config = MqttClientConfiguration { client_id: Some(&client_id), @@ -198,12 +253,17 @@ fn run( &cb_audio_tx, &cb_cmd_play, &cb_cmd_volume, + &cb_cmd_update, ); }) .map_err(|e| anyhow!("EspMqttClient::new_cb: {e}"))?; - // Drop our extra Sender so msg_rx will know if everyone hangs up. - drop(msg_tx); + // Keep one Sender alive so the OTA worker can post Progress/Finished + // back to this loop without racing the MQTT callback's clone. We + // intentionally don't drop the original msg_tx — there's no point + // detecting a closed channel from this thread, since this thread is + // the only consumer and the only loop body. + let msg_tx_for_ota = msg_tx; let mut last_snapshot: Option = None; let mut online = false; @@ -213,6 +273,20 @@ fn run( // sensor-style button entity in HA needs a stable resting state to show // instead of the "Unknown" of the old event entity. let mut button_idle_at: Option = None; + // Most recent `latest_version` seen on the shared topic. None until + // we've received our first retained message. The OTA URL is built as + // `/sound-machine-.bin` at install time. + let mut latest_version: Option = None; + // Cancel-rollback runs once on the first healthy MQTT connect of a + // boot. Set after the call so re-Connecteds are no-ops. + let mut have_marked_valid = false; + // Guards against a second OTA being kicked off while one is already + // running (e.g., HA Install double-click). Cleared on failure; on + // success the device reboots and the flag goes away with it. + let ota_in_progress = Arc::new(AtomicBool::new(false)); + // OTA progress state surfaced into HA's update entity via JSON state. + // None when no OTA is running. Updated in 5% steps from the OTA worker. + let mut ota_progress: Option = None; info!("network task: entering main loop"); @@ -246,12 +320,21 @@ fn run( &avail_topic, &state_topic, &button_topic, + &update_state_topic, &cmd_filter, &mac_hex, sw_version, last_snapshot, + ota_progress, boot_at, ); + if !have_marked_valid { + match ota::mark_app_valid() { + Ok(()) => info!("OTA: marked running app as valid (rollback canceled)"), + Err(e) => warn!("OTA: mark_app_valid failed: {e}"), + } + have_marked_valid = true; + } } NetTaskMsg::Disconnected => { info!("MQTT disconnected"); @@ -271,6 +354,51 @@ fn run( publish_state(&mut client, &state_topic, snap, boot_at); } } + NetTaskMsg::LatestVersion(v) => { + info!("OTA: latest_version is now {}", v.as_str()); + latest_version = Some(v); + } + NetTaskMsg::OtaInstall => { + if handle_ota_install(latest_version, &led_tx, &ota_in_progress, &msg_tx_for_ota) { + ota_progress = Some(0); + if online { + publish_update_state( + &mut client, + &update_state_topic, + sw_version, + ota_progress, + ); + } + } + } + NetTaskMsg::OtaProgress(pct) => { + ota_progress = Some(pct); + if online { + publish_update_state( + &mut client, + &update_state_topic, + sw_version, + ota_progress, + ); + } + } + NetTaskMsg::OtaFinished(success) => { + ota_progress = None; + ota_in_progress.store(false, Ordering::SeqCst); + let _ = led_tx.send(LedSignal::UpdateDone); + if !success && online { + // Republish a non-progress state JSON so HA stops + // showing the progress bar. (On success the device + // reboots before reaching this, so success path + // mainly exists for symmetry.) + publish_update_state( + &mut client, + &update_state_topic, + sw_version, + ota_progress, + ); + } + } } } } @@ -303,6 +431,7 @@ fn mqtt_callback( audio_tx: &Sender, cmd_play: &str, cmd_volume: &str, + cmd_update: &str, ) { match event.payload() { EventPayload::Connected(_) => { @@ -339,6 +468,20 @@ fn mqtt_callback( let _ = audio_tx.try_send(AudioCommand::SetVolumeIndex(idx)); } } + } else if topic == cmd_update { + if data == b"install" { + let _ = msg_tx.try_send(NetTaskMsg::OtaInstall); + } + } else if topic == discovery::SHARED_LATEST_VERSION_TOPIC { + let trimmed = trim_ascii(data); + if let Some(v) = VersionBuf::from_bytes(trimmed) { + let _ = msg_tx.try_send(NetTaskMsg::LatestVersion(v)); + } else { + warn!( + "shared latest_version: invalid payload (len={}, dropped)", + data.len() + ); + } } } EventPayload::Error(e) => { @@ -348,6 +491,20 @@ fn mqtt_callback( } } +/// Strip leading/trailing ASCII whitespace from a byte slice without +/// allocating. (`bytes::trim_ascii` is unstable.) +fn trim_ascii(b: &[u8]) -> &[u8] { + let mut start = 0; + let mut end = b.len(); + while start < end && b[start].is_ascii_whitespace() { + start += 1; + } + while end > start && b[end - 1].is_ascii_whitespace() { + end -= 1; + } + &b[start..end] +} + fn connect_wifi_with_retry(wifi: &mut BlockingWifi>) { loop { match try_connect_wifi(wifi) { @@ -413,10 +570,12 @@ fn publish_online_announce( avail_topic: &str, state_topic: &str, button_topic: &str, + update_state_topic: &str, cmd_filter: &str, mac_hex: &str, sw_version: &str, last_snapshot: Option, + ota_progress: Option, boot_at: Instant, ) { if let Err(e) = client.publish(avail_topic, QoS::AtLeastOnce, true, b"online") { @@ -433,6 +592,10 @@ fn publish_online_announce( // v0.2.1 changed the button from `event` (no resting state) to // `sensor` (idle/short/long/double). format!("homeassistant/event/nightstand_{mac_hex}/button/config"), + // v0.3.3 split the update entity to a JSON state_topic so we can + // surface progress; the old plain-string `update/installed` + // retains stale config after the discovery payload changed. + format!("nightstand/{mac_hex}/update/installed"), ]; for topic in &retired { if let Err(e) = client.publish(topic, QoS::AtLeastOnce, true, b"") { @@ -455,6 +618,11 @@ fn publish_online_announce( // (and HA on first discovery) see a stable resting state. publish_button_idle(client, button_topic); + // Publish our installed firmware version + any in-flight OTA progress + // as a single retained JSON to the update entity's state_topic. HA + // reads installed_version, in_progress, and update_percentage from it. + publish_update_state(client, update_state_topic, sw_version, ota_progress); + if let Some(snap) = last_snapshot { publish_state(client, state_topic, snap, boot_at); } @@ -462,8 +630,108 @@ fn publish_online_announce( if let Err(e) = client.subscribe(cmd_filter, QoS::AtLeastOnce) { warn!("subscribe {cmd_filter} failed: {e}"); } + // Subscribe to the shared latest-version topic. Retained, so the broker + // delivers the current value immediately (if any) and we cache it. + if let Err(e) = client.subscribe(discovery::SHARED_LATEST_VERSION_TOPIC, QoS::AtLeastOnce) { + warn!( + "subscribe {} failed: {e}", + discovery::SHARED_LATEST_VERSION_TOPIC + ); + } } +/// Publish the JSON `state_topic` for HA's update entity. `installed_version` +/// is always present; `in_progress` and `update_percentage` are added when +/// an OTA is mid-download. Retained so HA picks up the current state on +/// discovery + restart without waiting for the next change. +fn publish_update_state( + client: &mut EspMqttClient<'_>, + update_state_topic: &str, + sw_version: &str, + ota_progress: Option, +) { + let payload = match ota_progress { + Some(pct) => format!( + r#"{{"installed_version":"{sw}","in_progress":true,"update_percentage":{pct}}}"#, + sw = sw_version, + pct = pct, + ), + None => format!( + r#"{{"installed_version":"{sw}","in_progress":false}}"#, + sw = sw_version + ), + }; + if let Err(e) = client.publish(update_state_topic, QoS::AtLeastOnce, true, payload.as_bytes()) + { + warn!("publish update state failed: {e}"); + } +} + +/// Triggered when HA publishes "install" to `cmd/update`. Builds the +/// firmware URL from the cached latest version and the configured +/// `ota_url_base`, then spawns a worker thread that does the chunked +/// download with progress callbacks. Returns `true` if the install was +/// accepted (so the caller can update its local progress state). +fn handle_ota_install( + latest: Option, + led_tx: &Sender, + in_progress: &Arc, + msg_tx: &Sender, +) -> bool { + let Some(version) = latest else { + warn!("OTA install requested but no latest_version cached yet — ignoring"); + return false; + }; + if CONFIG.ota_url_base.is_empty() { + warn!("OTA install requested but ota_url_base is empty in cfg.toml — ignoring"); + return false; + } + if in_progress + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + warn!("OTA install requested but one is already in progress — ignoring"); + return false; + } + + let base = CONFIG.ota_url_base.trim_end_matches('/'); + let url = format!("{base}/sound-machine-{}.bin", version.as_str()); + info!("OTA install: kicking download thread for {url}"); + let _ = led_tx.send(LedSignal::Updating); + + let progress_tx = msg_tx.clone(); + let finished_tx = msg_tx.clone(); + if let Err(e) = Builder::new() + .name("ota".into()) + .stack_size(OTA_THREAD_STACK) + .spawn(move || { + let result = ota::download_and_install(&url, |pct| { + let _ = progress_tx.try_send(NetTaskMsg::OtaProgress(pct)); + }); + match result { + Ok(()) => { + // No need to send OtaFinished(true) — we're about to + // reboot, the network state thread won't get a chance + // to act on it. esp_restart returns `!`. + info!("OTA: rebooting into new firmware"); + esp_idf_svc::hal::reset::restart(); + } + Err(e) => { + warn!("OTA: download_and_install failed: {e}"); + let _ = finished_tx.send(NetTaskMsg::OtaFinished(false)); + } + } + }) + { + warn!("OTA: failed to spawn worker thread: {e}"); + in_progress.store(false, Ordering::SeqCst); + return false; + } + true +} + +const OTA_THREAD_STACK: usize = 12 * 1024; + /// Publish a retained "idle" state to the button topic. Called on connect /// and after the BUTTON_IDLE_AFTER_MS window following any gesture. fn publish_button_idle(client: &mut EspMqttClient<'_>, button_topic: &str) { diff --git a/firmware/src/ota.rs b/firmware/src/ota.rs new file mode 100644 index 0000000..5fe877e --- /dev/null +++ b/firmware/src/ota.rs @@ -0,0 +1,126 @@ +//! Over-the-air firmware updates via the chunked `esp_https_ota_*` API. +//! +//! `download_and_install` runs the begin/perform-loop/finish dance from a +//! single Rust call and reports progress via a caller-supplied closure +//! (typically posts an `OtaProgress(percent)` message to the network state +//! thread so HA's update entity can render a progress bar). Throttled to +//! ~5% steps so the broker doesn't see ~1200 messages per upgrade. +//! +//! Despite the name, `esp_https_ota` is fine over plain HTTP, but only when +//! `CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP=y` is set in sdkconfig — without that +//! the underlying validator rejects http:// URLs at config time, before +//! opening any socket. The trust boundary is the LAN, same as MQTT; signed +//! images (ESP-IDF secure boot) are the answer for tamper resistance. +//! +//! After the new firmware boots, `mark_app_valid` cancels the bootloader's +//! pending-rollback timer once the app proves it works (in `network.rs`, +//! after the first MQTT `Connected` event). + +use anyhow::{anyhow, Result}; +use esp_idf_svc::sys::{ + esp, esp_http_client_config_t, esp_https_ota_abort, esp_https_ota_begin, + esp_https_ota_config_t, esp_https_ota_finish, esp_https_ota_get_image_len_read, + esp_https_ota_get_image_size, esp_https_ota_handle_t, esp_https_ota_is_complete_data_received, + esp_https_ota_perform, esp_ota_mark_app_valid_cancel_rollback, ESP_ERR_HTTPS_OTA_IN_PROGRESS, + ESP_OK, +}; +use log::info; +use std::ffi::CString; +use std::ptr; + +/// Step size (in percent) between successive progress callbacks. Smaller +/// values mean smoother HA progress bars at the cost of more MQTT chatter. +/// 5% → ~20 publishes per upgrade — plenty smooth, easy on the broker. +const PROGRESS_STEP_PCT: u8 = 5; + +/// Download firmware from `url` into the inactive OTA partition, calling +/// `progress(pct)` at ~5% intervals as bytes flow in. On success the new +/// image is set as the boot partition; caller must reboot. +/// +/// The callback is also invoked once with `0` immediately after the HTTP +/// connection is established, and once with `100` right before returning, +/// so HA always sees a complete 0→100 sweep. +pub fn download_and_install(url: &str, mut progress: impl FnMut(u8)) -> Result<()> { + info!("OTA: downloading from {url}"); + + let url_c = CString::new(url).map_err(|_| anyhow!("OTA URL contains nul byte"))?; + + let http_config = esp_http_client_config_t { + url: url_c.as_ptr(), + // Per-recv timeout, not a total deadline. Generous for slow/flaky + // 2.4 GHz links — beats failing partway through a 1+ MB download. + timeout_ms: 60_000, + keep_alive_enable: true, + ..Default::default() + }; + + let ota_config = esp_https_ota_config_t { + http_config: &http_config as *const _, + ..Default::default() + }; + + let mut handle: esp_https_ota_handle_t = ptr::null_mut(); + // SAFETY: configs and url_c live for the duration of this function. + unsafe { esp!(esp_https_ota_begin(&ota_config as *const _, &mut handle)) } + .map_err(|e| anyhow!("esp_https_ota_begin: {e}"))?; + + // Total size from Content-Length. Returns -1 for chunked encoding, + // in which case we just can't report a percentage. Treat that as 0 + // for the math and the callback effectively becomes a heartbeat. + let total = unsafe { esp_https_ota_get_image_size(handle) }; + info!("OTA: image size = {total} bytes"); + progress(0); + let mut last_reported = 0u8; + + let result = loop { + let r = unsafe { esp_https_ota_perform(handle) }; + if r == ESP_ERR_HTTPS_OTA_IN_PROGRESS { + if total > 0 { + let read = unsafe { esp_https_ota_get_image_len_read(handle) }; + let pct = ((read as i64 * 100) / total as i64).clamp(0, 99) as u8; + if pct >= last_reported.saturating_add(PROGRESS_STEP_PCT) { + progress(pct); + last_reported = pct; + } + } + continue; + } + // Anything else terminates the perform loop — success or failure. + break r; + }; + + if result != ESP_OK as i32 { + // SAFETY: handle is non-null past begin(); abort accepts it. + unsafe { esp_https_ota_abort(handle) }; + // Keep url_c alive until after abort. + drop(url_c); + return Err(anyhow!("esp_https_ota_perform failed: 0x{:x}", result)); + } + + // The HTTP server can return 200 with a truncated body; the helper + // explicitly checks Content-Length matches what was actually written. + if !unsafe { esp_https_ota_is_complete_data_received(handle) } { + unsafe { esp_https_ota_abort(handle) }; + drop(url_c); + return Err(anyhow!( + "OTA download incomplete: server sent fewer bytes than Content-Length" + )); + } + + unsafe { esp!(esp_https_ota_finish(handle)) } + .map_err(|e| anyhow!("esp_https_ota_finish: {e}"))?; + + drop(url_c); + progress(100); + info!("OTA: download complete; new firmware staged in inactive slot"); + Ok(()) +} + +/// Confirm the running firmware is healthy and cancel the bootloader's +/// pending rollback. No-op on partitions that aren't in pending-verify +/// state (i.e., wired-flashed firmware), so safe to call on every boot's +/// first successful MQTT connect. +pub fn mark_app_valid() -> Result<()> { + unsafe { esp!(esp_ota_mark_app_valid_cancel_rollback()) } + .map_err(|e| anyhow!("esp_ota_mark_app_valid_cancel_rollback: {e}")) +} diff --git a/firmware/src/secrets.rs b/firmware/src/secrets.rs index abaebf8..4053536 100644 --- a/firmware/src/secrets.rs +++ b/firmware/src/secrets.rs @@ -16,4 +16,9 @@ pub struct Config { pub wifi_password: &'static str, #[default("")] pub mqtt_url: &'static str, + /// Base URL the device fetches OTA images from. The full image URL is + /// `/sound-machine-.bin`. Trailing slash is + /// stripped at use-time so either form works. + #[default("")] + pub ota_url_base: &'static str, } diff --git a/reference/mqtt-contract.md b/reference/mqtt-contract.md index d0554f7..9952c8a 100644 --- a/reference/mqtt-contract.md +++ b/reference/mqtt-contract.md @@ -24,6 +24,8 @@ The interface between each nightstand device and Home Assistant. Firmware and HA | Drive speaker via I2S | ✓ | | | Track playing state | ✓ | | | Announce entities via discovery | ✓ | | +| Download new firmware over HTTP, write to flash | ✓ | | +| Decide when to push a new version | | ✓ (driven by `make ota-publish`) | ## Device identity @@ -31,7 +33,7 @@ The interface between each nightstand device and Home Assistant. Firmware and HA - At boot, the firmware reads the ESP32's STA MAC and logs it loudly so it's visible in the serial monitor before any WiFi attempt. - Topic prefix: `nightstand//...` -- Discovery `unique_id`s: `nightstand__button`, `_white_noise`, `_volume`, `_rssi`, `_uptime`. Stable across firmware upgrades. +- Discovery `unique_id`s: `nightstand__button`, `_white_noise`, `_volume`, `_uptime`, `_update`. Stable across firmware upgrades. - Discovery `device.name` defaults to `"Nightstand"`. The HA UI lets the user rename each device per-unit ("Bedroom Nightstand", "Guest Room Nightstand", etc.) without breaking the MQTT contract — `unique_id` is what HA uses to track entities, not `name`. **Why:** one firmware binary works on every unit, no per-unit table to maintain, no reflash dance after first boot. The user names devices in the place that already understands renaming (HA) instead of in firmware source. @@ -46,46 +48,53 @@ The interface between each nightstand device and Home Assistant. Firmware and HA ``` homeassistant//nightstand_//config ← discovery (retain=true) nightstand//available ← LWT + online announce (retain=true) -nightstand//button ← event stream (retain=false) -nightstand//state ← JSON state snapshot (retain=true) +nightstand//button ← gesture sensor JSON (retain=true) +nightstand//state ← audio state snapshot JSON (retain=true) +nightstand//update/state ← firmware update state JSON (retain=true) nightstand//cmd/play ← inbound: "ON" / "OFF" nightstand//cmd/volume ← inbound: integer 0-100 +nightstand//cmd/update ← inbound: "install" +sound-machine/firmware/latest ← shared latest_version (retain=true) ``` `` is the lowercase 12-char STA MAC with no separators (e.g. `aabbccddeeff`). -Keeping all state in a single `state` JSON topic (rather than one topic per field) simplifies the device's publish logic and HA's `value_template` wiring. +Per-device state topics are split by *concern* — `state` for audio playback, `update/state` for firmware progress, `button` for the most recent gesture — because HA's update entity wants its progress fields in their own topic and mixing them would force every audio publish to also re-emit firmware fields. + +The shared `sound-machine/firmware/latest` topic carries the announced latest version once, retained, for every device on this firmware. One `make ota-publish` lights up the update card on every nightstand at the same time without per-device fanout. ## Entities exposed -### 1. Button — `event` type +### 1. Button — `sensor` type -Distinguishes short press, double press, and long press (≥ 2s hold). HA automations trigger on event type. +Carries the most-recent gesture as a sensor state (idle/short/long/double). The device publishes the gesture on press, then publishes a retained `idle` ~800 ms later so the entity has a stable resting value — HA's automations trigger on the state transition (e.g. `to: short`) rather than on event types. -Discovery topic: `homeassistant/event/nightstand_/button/config` +Discovery topic: `homeassistant/sensor/nightstand_/button/config` ```json { "name": "Button", "unique_id": "nightstand__button", "state_topic": "nightstand//button", - "event_types": ["short", "double", "long"], "value_template": "{{ value_json.event_type }}", + "icon": "mdi:gesture-tap-button", "device": { "identifiers": ["nightstand_"], "name": "Nightstand", "manufacturer": "guid.foo", - "model": "Sound Machine v1", - "sw_version": "0.2.0" + "model": "Sound Machine", + "sw_version": "0.3.4" }, "availability_topic": "nightstand//available" } ``` -Event payload (published to `nightstand//button`, not retained): +Payload (retained): ```json {"event_type": "short"} ``` -or `double`, or `long`. +…where the value is one of `idle`, `short`, `long`, `double`. After ~800 ms the device publishes `{"event_type":"idle"}` so the entity returns to a stable resting state instead of stuck on the gesture. + +**Why not `event`-type?** Earlier firmware (≤ 0.2.0) used HA's `event` entity, which is event-as-fact-without-resting-state. HA renders that as "Unknown" any time you look at the device card outside the brief moment of a press. The `sensor` + idle-after-N-ms pattern gives the same automation triggers (`to: short`) plus a sane idle reading. ### 2. White noise — `switch` @@ -131,54 +140,77 @@ Discovery topic: `homeassistant/number/nightstand_/volume/config` } ``` -### 4. Diagnostics — `sensor` ×2 +### 4. Uptime diagnostic — `sensor` -Helpful for debugging; marked as diagnostic so they hide in the default device view. +Helpful for debugging power blips and reconnection. Marked as diagnostic so it hides in the default device view. -`homeassistant/sensor/nightstand_/rssi/config`: +`homeassistant/sensor/nightstand_/uptime/config`: ```json { - "name": "WiFi Signal", - "unique_id": "nightstand__rssi", + "name": "Uptime", + "unique_id": "nightstand__uptime", "state_topic": "nightstand//state", - "value_template": "{{ value_json.rssi }}", - "unit_of_measurement": "dBm", - "device_class": "signal_strength", + "value_template": "{{ value_json.uptime_s }}", + "unit_of_measurement": "s", + "device_class": "duration", "entity_category": "diagnostic", "device": {"identifiers": ["nightstand_"]}, "availability_topic": "nightstand//available" } ``` -`homeassistant/sensor/nightstand_/uptime/config`: +(Earlier firmware also exposed an RSSI sensor; it was dropped in v0.2.0 because the value was rarely meaningful — WiFi signal at the nightstand is consistent.) + +### 5. Firmware update — `update` + +Drives HA's standard update card: shows installed-vs-latest version, an Install button, and a progress bar during a download. State is split between a per-device JSON state topic and the shared latest-version topic: + +- `state_topic`: `nightstand//update/state` — JSON, retained, written by the device on connect and during an OTA. Carries `installed_version` always; `in_progress` and `update_percentage` while a download is underway. +- `latest_version_topic`: `sound-machine/firmware/latest` — plain string, retained, written by `make ota-publish`. Shared across every device running this firmware. +- `command_topic`: `nightstand//cmd/update` — receives the literal `install`. + +Discovery topic: `homeassistant/update/nightstand_/firmware/config` ```json { - "name": "Uptime", - "unique_id": "nightstand__uptime", - "state_topic": "nightstand//state", - "value_template": "{{ value_json.uptime_s }}", - "unit_of_measurement": "s", - "device_class": "duration", - "entity_category": "diagnostic", + "name": "Firmware", + "unique_id": "nightstand__update", + "state_topic": "nightstand//update/state", + "latest_version_topic": "sound-machine/firmware/latest", + "latest_version_template": "{{ value }}", + "command_topic": "nightstand//cmd/update", + "payload_install": "install", + "device_class": "firmware", + "entity_category": "config", "device": {"identifiers": ["nightstand_"]}, "availability_topic": "nightstand//available" } ``` -## State payload +State payload (idle): +```json +{"installed_version":"0.3.4","in_progress":false} +``` + +State payload during a download: +```json +{"installed_version":"0.3.4","in_progress":true,"update_percentage":35} +``` + +The device updates `update_percentage` in 5% steps (~20 publishes per upgrade) — smooth enough for HA's progress bar, light enough that the broker isn't drinking from a hose. -Published to `nightstand//state` (retained) on every state change: +## State payload (audio) + +Published to `nightstand//state` (retained) on every audio-state change: ```json { "playing": "ON", "volume": 65, - "rssi": -58, "uptime_s": 12847 } ``` -Single JSON payload keeps discovery templates simple and lets HA parse any field out with `value_template`. +Single JSON payload keeps discovery templates simple and lets HA parse any field with `value_template`. Firmware state lives in the separate `update/state` topic so an OTA progress publish doesn't churn the audio entities. ## Availability (LWT) @@ -192,12 +224,16 @@ HA marks every entity unavailable within ~seconds of the device losing WiFi. 1. WiFi up → MQTT connect (with LWT registered) 2. Publish retained `online` to `nightstand//available` -3. Publish retained discovery configs for every entity (cheap — broker dedupes retained messages) -4. Publish retained initial state snapshot to `nightstand//state` -5. Subscribe to `nightstand//cmd/+` -6. Enter main loop +3. Publish retained empty payloads to any retired discovery topics (clears stale HA entities from earlier firmware versions) +4. Publish retained discovery configs for every current entity (cheap — broker dedupes retained messages) +5. Publish retained `idle` to `nightstand//button` so the gesture sensor has a stable resting value +6. Publish retained `update/state` JSON with the running `installed_version` +7. Publish retained audio state snapshot to `nightstand//state` (if cached) +8. Subscribe to `nightstand//cmd/+` and to `sound-machine/firmware/latest` +9. Call `esp_ota_mark_app_valid_cancel_rollback` — confirms the running app is healthy and stops the bootloader's pending-rollback timer (no-op for wired flashes; meaningful only after an OTA reboot) +10. Enter main loop -Republishing discovery every boot is fine — it's idempotent and makes entity config portable even after HA restores from backup or the broker loses retained state. +Republishing discovery every boot is fine — it's idempotent and makes entity config portable even after HA restores from backup or the broker loses retained state. Republishing empty payloads to retired topics keeps HA from carrying stale entities forward across firmware versions. ## Button behavior @@ -342,57 +378,75 @@ The device is blissfully unaware of any of this. Total latency: tens of milliseconds on LAN. Feels instant. -## v1.5 planned extension: OTA via HA `update` entity +## OTA workflow -Not in v1, but designed-in so we don't paint ourselves into a corner. Added when the units are enclosed and physical USB reflash becomes tedious. +Firmware is delivered over plain HTTP from a static file server on the LAN. The trust boundary is already the LAN (MQTT is also plain), so TLS would only protect transit, not authenticity — secure boot + signed images is the answer for tamper resistance and isn't in scope yet. -### Mechanism +### Roles -- Chris builds firmware locally, copies `.bin` to HA's `/config/www/firmware/`, publishes a `latest_version` announcement to MQTT. -- HA's MQTT `update` entity compares `installed_version` vs `latest_version` and shows an "Install" button on the device card. -- User clicks Install → HA publishes to `nightstand//cmd/update` → firmware downloads from `http://homeassistant.local:8123/local/firmware/sound-machine-.bin` → `esp_ota_*` writes to the inactive partition → reboot into new firmware → device reports new `installed_version`. -- ESP-IDF's OTA handles two-partition rollback automatically — a bootloop reverts to the previous good firmware. +- **Publisher** (Chris's dev machine): builds the binary, copies it to the static host, announces the new version on MQTT. +- **Static HTTP host**: serves `/sound-machine-.bin`. Plain HTTP, LAN-only. +- **HA**: renders the update card from the MQTT entity, sends the install command on user click, watches the progress bar. +- **Device**: subscribes to the shared latest topic and to its own `cmd/update`; on `install`, downloads + flashes + reboots; reports installed version + progress on `update/state`. -### Additional entity +### Flow -`homeassistant/update/nightstand_/firmware/config`: -```json -{ - "name": "Firmware", - "unique_id": "nightstand__firmware", - "state_topic": "nightstand//update", - "command_topic": "nightstand//cmd/update", - "payload_install": "INSTALL", - "latest_version_topic": "nightstand//update", - "latest_version_template": "{{ value_json.latest_version }}", - "value_template": "{{ value_json.installed_version }}", - "release_url": "", - "device": {"identifiers": ["nightstand_"]}, - "availability_topic": "nightstand//available" -} ``` - -Update state topic payload (retained, written by device on boot and after install): -```json -{ - "installed_version": "0.2.1", - "latest_version": "0.3.0" -} +publisher static host broker HA device + │ make ota-publish: │ │ │ │ + │ espflash save-image │ │ │ │ + │ cp .../sound-machine-X.bin│ │ │ │ + │ ─────────────────────────► (file) │ │ │ + │ mosquitto_pub -L .../sound-machine/firmware/latest -m X │ │ + │ ──────────────────────────────────────────► retained ─────►│ │ + │ │ │ │ + │ │ │ ──cmp installed│ + │ │ │ vs latest───►│ + │ │ │ │ + │ (user clicks │ │ │ + │ Install in HA) │ │ │ + │ │ │ cmd/update │ + │ │ │ "install" │ + │ │ │ ─────────────► │ + │ │ │ │ esp_https_ota_begin + │ │ │ │ → GET + │ ────HTTP 200────────────────────────────────── │ + │ │ │ │ chunks → ota_1 + │ │ │ update/state │ (every 5%) + │ │ │ in_progress=true,update_percentage=N + │ │ │ ◄─────────────── │ + │ │ │ │ esp_https_ota_finish + │ │ │ │ esp_restart() + │ │ │ │ + │ │ │ │ (reboot from ota_1) + │ │ │ update/state │ + │ │ │ installed=X,in_progress=false + │ │ │ ◄─────────────── │ + │ │ │ │ esp_ota_mark_app_valid_ + │ │ │ │ cancel_rollback() ``` -Chris's release workflow (`make ota` or similar) publishes the `latest_version` field with retain=true; devices pick it up on next connect. +### Boot validation and rollback + +Each OTA leaves the new firmware in **pending-verify** state. The device must explicitly call `esp_ota_mark_app_valid_cancel_rollback` once it confirms the new firmware works — the firmware does this on the first successful MQTT `Connected` event after boot. If the new firmware crashes before that point, or never connects to MQTT, the bootloader rolls back to the previous slot on next reset and the device comes up on the old version. Belt-and-braces against bricked devices. + +For wired-flashed firmware (`make flash`), the partition isn't in pending-verify state; `mark_app_valid` is a documented no-op. + +### Why one binary works for every unit + +MAC-derived identity (see Device Identity section) means the same `sound-machine-.bin` runs correctly on both nightstands without per-unit builds. The shared `sound-machine/firmware/latest` topic means one publish notifies every device — no `nightstand/+/update` fanout required. -### Why one binary works for both units +### Compile-time vs. runtime config -MAC-derived identity (see Device Identity section) means the same `sound-machine-v0.3.0.bin` runs correctly on both nightstands without per-unit builds. `mosquitto_pub -t nightstand/+/update ...` notifies both units of the new version with one command. +`ota_url_base` lives in `firmware/cfg.toml` next to the WiFi and MQTT config — compile-time. Changing the firmware host is currently a wired-flash event, the same as changing WiFi credentials. (Putting the URL in the `latest_version` payload would make it pure runtime config; that's a future cleanup if hosts change often, which they don't.) -## What we're deliberately NOT including (v1) +## What we're deliberately NOT including -- **Noise type selection** (pink, brown, rain, etc.) — shipping with a single hand-tuned noise generator that Chris will iterate on to match what he and his wife actually want. Parameters live in source, not in MQTT; tuning = reflash, not a runtime knob. -- **RGB LED control from HA** — the onboard SK6812 will be used by firmware for local status (idle / playing / WiFi down). No HA entity for it yet. +- **Noise type selection** (pink, brown, rain, etc.) — shipping with a single hand-tuned noise generator that Chris will iterate on to match what he and his wife actually want. Parameters live in source, not in MQTT; tuning = OTA, not a runtime knob. +- **RGB LED control from HA** — the onboard SK6812 is used by firmware for local status (audio × net axes, OTA progress, error). No HA entity for it. - **Media player entity** — too much complexity for what is basically a toggle. Can revisit if we want HA TTS announcements on the device. - **Triple press patterns** — too much to remember. Single/double/long is the max. -- **OTA updates** — designed in (see v1.5 section) but not built for v1. Bring-up with USB flashing; add OTA once enclosed. +- **TLS / signed firmware** — LAN-only deployment; TLS without code signing only protects transit. Secure boot + signed images is the right answer when the threat model warrants it. ## Sources diff --git a/reference/operating-modes.md b/reference/operating-modes.md index ff96493..de9c550 100644 --- a/reference/operating-modes.md +++ b/reference/operating-modes.md @@ -41,11 +41,12 @@ Does not cover: the Rust implementation details (that's firmware code), the audi Entered on power-on or reset. Responsibilities: 1. Initialize I2S, GPIO, NVS, RGB LED 2. Read persistent state from NVS: `volume_index`, `volume_direction`, `was_playing` -3. Look up this chip's MAC in `KNOWN_DEVICES` table → logical identity +3. Read the STA MAC; the lowercase 12-char hex is the device's identity for MQTT topics and discovery `unique_id`s (see [`mqtt-contract.md`](./mqtt-contract.md)) 4. **If `was_playing == true`**: start white noise generator immediately at saved volume (power-blip recovery — don't wake the user with silence) -5. Attempt WiFi connect with 30s timeout against stored credentials -6. If WiFi connects, attempt MQTT connect with 10s timeout +5. Attempt WiFi connect against compile-time stored credentials, with 60s retry on failure +6. If WiFi connects, attempt MQTT connect (the C MQTT client manages its own reconnect) 7. Transition to ONLINE or OFFLINE based on outcome +8. On the first successful MQTT `Connected` event, call `esp_ota_mark_app_valid_cancel_rollback` to confirm the running firmware (see "OTA + rollback" below) BOOT should complete to some steady mode within ~45 seconds worst case. @@ -98,20 +99,26 @@ The SK6812 behind the button cap is the only status indicator. Goal: visible eno All colors are at **dim brightness** (~5-10% of full) unless noted. -| State | Color | Pattern | +The LED state machine composes a base color from two orthogonal axes — audio playback state and network state — and applies overrides for OTA and unrecoverable errors on top. `Updating` and `Error` win over the base; `PressFlash` is a transient brightening overlay that decays over ~150 ms. + +| Audio × Net | Color | Pattern | +| --- | --- | --- | +| Connecting (any audio) | Cyan | Slow pulse (~1 Hz) | +| Online, idle | Green | Solid, very dim | +| Online, playing | Green | Solid, medium-dim | +| Offline, idle | Amber | Solid, very dim | +| Offline, playing | Amber | Solid, medium-dim | + +| Override | Color | Pattern | | --- | --- | --- | -| BOOT (connecting WiFi) | Blue | Slow pulse (1 Hz) | -| BOOT (connecting MQTT) | Cyan | Slow pulse (1 Hz) | -| ONLINE, idle | Green | Solid, very dim | -| ONLINE, playing | Green | Solid, medium-dim | -| OFFLINE, idle | Amber | Solid, very dim | -| OFFLINE, playing | Amber | Solid, medium-dim | -| Error (I2S failed, OTA failed, etc.) | Red | Slow blink | -| OTA in progress (v1.5) | Magenta | Slow pulse | -| Button press ack (transient) | Flash brighter for ~100ms, then return to status color | — | +| OTA download in progress | Magenta | Slow pulse (~1.25 Hz) | +| Error (I2S init failed, etc.) | Red | Slow blink (~2 Hz) | +| Button press ack | Brighten the current color ~50 % | Decays over 150 ms | The button-press flash is a nice tactile confirmation — press, see a brief brighter pulse, know it registered even in the dark. +The OTA-failure path explicitly clears the magenta override (via an internal `UpdateDone` signal from the OTA worker) so a failed install drops the LED back to the audio×net base color instead of leaving it stuck pulsing magenta forever. + ## Persistent state (NVS) Stored in ESP32's NVS flash partition. Survives power loss, restarts, even OTA updates (separate partition from app binaries). @@ -155,6 +162,36 @@ When OFFLINE (either never connected or dropped from ONLINE): No exponential backoff — device is wall-powered, we don't care about battery life, and 60s is a reasonable balance between "react to the network coming back" and "not spam the broker during multi-hour outages." +## OTA + rollback + +The firmware ships with a two-OTA partition layout (`ota_0` and `ota_1`, each 1.875 MB) plus an `otadata` partition that records which slot is active. New firmware is written to the *inactive* slot via `esp_https_ota`; on success, otadata is flipped and the device reboots into the new slot. + +### Partition layout (4 MB ESP32-PICO-D4) + +| Region | Offset | Size | Purpose | +| --- | --- | --- | --- | +| bootloader | `0x01000` | 28 KB | ESP-IDF stage-2 loader | +| partition table | `0x08000` | 4 KB | This file's binary form | +| nvs | `0x09000` | 24 KB | Volume, was_playing | +| otadata | `0x0F000` | 8 KB | Active-slot pointer | +| phy_init | `0x11000` | 4 KB | RF calibration (regenerated if missing) | +| ota_0 | `0x20000` | 1.875 MB | App slot A | +| ota_1 | `0x200000` | 1.875 MB | App slot B | + +NVS sits at the same offset as the single-slot v0.1.0/v0.2.0 layout, so the partition swap preserves persisted audio state. The 56 KB gap between phy_init and ota_0 is the cost of the 64 KB alignment requirement on app partitions. + +### Pending-verify and `mark_app_valid` + +After an OTA reboot, the new firmware boots in **pending-verify** state. The bootloader expects the running app to call `esp_ota_mark_app_valid_cancel_rollback` once it's confident things work; if a reset happens before that call, the bootloader reverts to the previous slot on the next boot. The firmware calls this on the first MQTT `Connected` event — proving WiFi and the broker both work, which is the device's primary job. Wired-flashed firmware isn't in pending-verify state, so the call is a no-op (documented behavior). + +If MQTT never connects after an OTA, the device will roll back on the next reset and come up on the previous version. HA notices the `installed_version` in `update/state` reverted; the update card flips back to "Update available." + +The trade-off is that any post-OTA reset before MQTT comes up looks like a rollback. In practice that means: don't power-cycle a device for 30 s after clicking Install. Watching the LED flip from magenta → cyan → green is the proxy for "OTA succeeded." + +### One-time wired migration + +The two-OTA layout is *not* the default ESP-IDF partition table. Devices going from v0.1.0 / v0.2.0 → v0.3.x must be wire-flashed once to write the new partition table; from v0.3.0 onward, every bump is OTA. The Makefile's `flash` target writes the new bootloader, partition table, and otadata in addition to the app, so the migration is a single `make flash`. + ## Error handling | Error | Behavior | @@ -164,14 +201,14 @@ No exponential backoff — device is wall-powered, we don't care about battery l | NVS write fails | Log, keep running. State won't persist across reboot but that's a graceful degradation. | | WiFi password wrong | Stay OFFLINE forever until updated. No good recovery. | | MQTT broker unreachable | Stay OFFLINE, retry per strategy above. | -| OTA download fails (v1.5) | Keep running current firmware. Log. Report failure via MQTT. | -| OTA bootloop (v1.5) | ESP-IDF's two-partition system auto-reverts to previous firmware. User sees the device come back on old version; MQTT state reflects it. | +| OTA download fails | Keep running current firmware. Log. Republish `update/state` with `in_progress: false` so HA's progress bar disappears. LED reverts from magenta to the audio×net base color. | +| OTA boot fails / app crashes before mark_valid | ESP-IDF's two-partition rollback auto-reverts to the previous slot. Device comes back on the old version; HA sees `installed_version` revert and lights up the "Update available" card again. | ## What's not in this doc -- **WiFi provisioning mechanism** — for v1, credentials are hardcoded-per-flash (via `cfg.toml` or similar). SoftAP/BLE/Improv provisioning is a v2 concern if we want it. -- **Audio generation parameters** — the actual noise generator's filter shape, amplitude, etc. live in source and are tuned over time. Chris will iterate on these with his wife's input once hardware is assembled. -- **OTA implementation** — designed in MQTT contract for v1.5; firmware implementation TBD. +- **WiFi provisioning mechanism** — credentials are baked into the binary at compile time via `cfg.toml`. SoftAP/BLE/Improv provisioning is a possible future addition. +- **Audio generation parameters** — the actual noise generator's filter shape, amplitude, etc. live in source and are tuned over time. +- **Secure boot / signed firmware** — we don't sign images. Threat model is LAN-only, same as MQTT being plain. ## Sources @@ -179,7 +216,9 @@ No exponential backoff — device is wall-powered, we don't care about battery l - [Signal chain](./signal-chain.md) — hardware audio path - [Atom Echo pinmap](./atom-echo/pinmap.md) — GPIO usage - [ESP-IDF NVS documentation][esp-idf-nvs] -- [ESP-IDF OTA documentation][esp-idf-ota] — v1.5 reference +- [ESP-IDF OTA documentation][esp-idf-ota] +- [ESP-IDF App rollback (mark_app_valid)][esp-idf-rollback] [esp-idf-nvs]: https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/storage/nvs_flash.html [esp-idf-ota]: https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/ota.html +[esp-idf-rollback]: https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/ota.html#app-rollback