commits
Updated for the actual speaker on the bench (GRS 3FR-4, not the
Adafruit 1314 we initially specced — same 60 mm M3 mounting pattern
but a much deeper magnet). Reference doc renamed and rewritten with
the new specs.
Geometry consequences:
* Speaker posts grow from 22 mm to 44 mm to position the frame plate
with 1 mm of clearance below the magnet (vibration isolation; the
alternative of resting the magnet on the plate would couple bass
into the nightstand surface).
* Chamber inner height grows to 46 mm (44 mm post + 2 mm foam tape
on top of the frame, compressed against the cover underside).
* Speaker frame extent goes from 78 mm to 95 mm (the GRS is wider
across the mounting ears), so the chassis grows to ~107×162×49 mm
assembled.
Cable clip rework:
* Was a closed rectangular tunnel centered on the back wall — over-
engineered (the goal was strain relief, not a zip-tie pass-through)
and conflicted with the centered back snap-fit tab.
* Now an open L-shape (post + cap), positioned ~14 mm from the side
wall to clear the snap tab. The cable drops in from above during
assembly and rests in the L corner — post on the chamber-interior
side, cap extending toward the side wall, cable contained on three
sides with the open side facing the wall.
* Cable notch in the back wall and matching notch in the back edge
of the bottom plate shifted to match the L-clip's X position.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reorganized so the bottom plate is the structural foundation and the
chassis ("cover") is purely cosmetic. Cover comes off without tools,
exposing all internals at once.
* Speaker mounts to four tall posts on the bottom plate (60 mm M3
pattern), with self-tap pilots at the post tops. No more screw holes
through the cover's top panel. Foam sealing, if it turns out to be
needed, becomes a foam donut on top of the speaker frame compressed
against the cover underside during assembly.
* USB cable routes internally. The connector lives on the Atom inside
the chamber; cable threads through a printed strain-relief tunnel
a few cm in front of the back wall and exits through a small notch
at the bottom of the back wall, matched by a notch in the plate's
back edge so the exit sits flush with the bottom of the unit.
* Cover retention is snap-fit. Four tabs on the plate carry hemi-
spherical bumps that catch in detent recesses on the chassis inner
walls. M3 screws and bosses are gone.
Post height, cable diameter, and snap-bump diameter are param-tunable
guesses to be dialed in after a test print.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The assembled nightstand unit gets a printable shell. `make enclosure`
renders three STLs to `enclosure/build/`: the chassis (open-bottom shell
with integrated top), a separate bottom plate that screws on with four
M3 self-tap screws, and a translucent snooze cap. The chassis houses
the Atom Echo press-fit onto a daughterboard via M5's 5+4 "Atom Stack"
header pattern, a MAX98357A I2S amp, and the Adafruit 1314 speaker.
Snooze cap on the front-top half presses the Atom's button via a
captive plunger; speaker fires up through a perforated grille on the
rear-top half; USB-C exits a cutout in the back wall.
Layout decisions worth flagging for future archaeology:
* Speaker fires up — less directional, more room-filling for a
nightstand. Snooze cap on the near (front) half of the top, grille on
the far (rear) half.
* No internal divider between the snooze and speaker zones — single
shared chamber so the USB-C cable can route from the front-mounted
Atom straight to the rear cutout. White noise through a 3" driver
doesn't need a sealed speaker chamber.
* Press-fit on the 5+4 side headers as the only Atom mount — no screws
on the Atom itself. M5's mechanical drawing shows two M2 mounting
holes on the bottom face, but they aren't externally accessible on
the actual unit (covered by the label sticker).
* Bottom plate screws into corner bosses with M3 self-tap into PLA;
four register tabs on the plate keep it laterally aligned during
assembly before the screws snug.
* 1.5 mm fillet on visible top + side edges; cap top has a 1.5 mm
spherical dome for tactile cue in the dark.
* Daughterboard is hand-soldered protoboard, not a custom-fab PCB.
Standoffs at the four corners of a 38 × 38 footprint for now; will
re-tune once a real piece of perfboard is on the bench.
The reference docs needed several corrections that fell out of measuring
the actual hardware (photos of the bottom, back, and top-right faces
added under `reference/atom-echo/`):
* Bottom face has the 5+4 press-fit headers + a male HY2.0-4P GROVE in
the middle.
* USB-C is on the back face, not the top.
* The onboard NS4168 speaker grille shares the top face with the
button, not the front.
* Planned MAX98357A wiring: I2S DIN moves from G21 (which isn't exposed
on the Atom's external pins) to G25.
* Stale PAM8302 references in the Adafruit 1314 doc updated to
MAX98357A — that path was the rejected analog-amp architecture.
References:
* https://docs.m5stack.com/en/atom/atomecho
* https://www.adafruit.com/product/3006 (MAX98357A breakout)
* https://www.adafruit.com/product/1314 (3" 4Ω speaker)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Devices now ship new firmware to themselves via Home Assistant. The
publish side is a `make firmware-ota-publish` that builds the binary,
copies it to a static HTTP host on the LAN, and announces the version
on a shared retained MQTT topic. HA's `update` entity compares the
shared latest against each device's installed version and shows an
Install button on the device card; clicking it sends the device the
URL pattern, which streams the binary via `esp_https_ota` into the
inactive partition slot, with `update_percentage` republished every 5%
so HA renders a real progress bar. After reboot, the firmware confirms
itself with `esp_ota_mark_app_valid_cancel_rollback` once MQTT is back,
which arms ESP-IDF's two-slot rollback against any version that can't
reach the broker.
Notable bits in the diff:
* Two-slot OTA partition table (`firmware/partitions.csv`); a one-time
wired flash migrates a v0.2.x device to it. From v0.3 onward every
bump is OTA.
* `CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP=y` — without it the C function
rejects http:// URLs at config-validation time and never opens a
socket. Plain HTTP is intentional; the trust boundary is the LAN.
* `make firmware-ota-publish` reads `OTA_LOCAL_DIR`/`OTA_URL_BASE`/
`MQTT_URL` from `.envrc.private` (gitignored). The firmware reads
`ota_url_base` from `cfg.toml`; consequently any change to that
value is a wired-flash event for now.
* `firmware/src/channels.rs` — the std::sync::mpsc + esp-idf-rs
pthread-mutex incompatibility bites the OTA worker too; we use
FreeRTOS native queues here as elsewhere.
* `reference/mqtt-contract.md` and `operating-modes.md` rewritten to
match the actually-implemented behavior: button is a `sensor` with
an idle-after-N-ms reset (the v0.2.1 change), no more `rssi`, the
`update` entity, the shared `sound-machine/firmware/latest` topic,
and the OTA flow + rollback semantics.
References:
* https://docs.espressif.com/projects/esp-idf/en/v5.3.3/esp32/api-reference/system/ota.html#app-rollback
* https://www.home-assistant.io/integrations/update.mqtt/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch the button discovery from `event` to `sensor`. Event entities
are designed for fire-and-forget transient triggers and don't carry a
meaningful "current state", so the device card was always showing
"Unknown" — useless for a glance at what just happened.
The sensor entity holds a stable resting value of "idle". On a
gesture, firmware publishes the event_type briefly (non-retained), and
the network task schedules a retained "idle" republish 800ms later
so the card snaps back to rest. Existing MQTT-trigger automations
(payload `{"event_type":"short"}` etc.) keep working unchanged — same
topic, same payload, just bracketed by idle states.
The retired-entities list now also clears the old `event` discovery
config so HA drops the placeholder entity automatically on first boot
of the new firmware.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both nightstands now appear in Home Assistant with a button event,
white-noise switch, volume slider (0-100, snapped to the existing
preset list), and an uptime sensor. Topic prefix is the lowercase MAC
hex (`nightstand/<mac_hex>/...`) — no KNOWN_DEVICES table to maintain.
HA users name and pair devices in the UI; `unique_id` stays MAC-stable.
Short press in online mode round-trips through HA: the device publishes
the event and waits for HA to send back `cmd/play ON`/`OFF`, so a single
gesture can do different things depending on time of day, occupancy,
etc. Long-press still cycles volume locally in both modes (with a
publish for HA logging when online); double-press is a pure HA gesture
for the late-night-lights routine. Offline, short-press toggles audio
locally as a fallback so muscle memory still works without HA.
Every amplitude transition is a 700 ms per-sample fade — start, stop,
and volume changes (preset cycle or HA slider). Constant step rate, so
small adjustments finish in proportionally less time and feel snappy
while big ones feel deliberate. Fade lives inside the noise generator
so the pink filter doesn't see any discontinuity; no clicks.
Cross-task channels are backed by FreeRTOS native queues
(`channels.rs`). `std::sync::mpsc` and `crossbeam-channel` are both
broken on esp-idf-rs because of the `PTHREAD_MUTEX_INITIALIZER`
mismatch (newlib's zeros vs. ESP-IDF's `0xffffffff`); the inner
`Mutex<Waker>` they rely on crashes the moment a multi-producer send
needs to wake a blocked receiver. FreeRTOS queues are the native
primitive on this platform, ISR-safe, and bypass the pthread layer
entirely. Wrapper API is intentionally close to mpsc so the call sites
didn't have to learn anything new.
WiFi creds + MQTT URL come from `cfg.toml` (gitignored, with a
`cfg.toml.example` placeholder) via the toml-cfg crate. Empty values
panic at boot; the public repo never sees real secrets.
Top-level + firmware Makefiles; `make firmware-flash` is the headless
flash entry point and `firmware-flash-monitor` is interactive. Both
auto-create the project-local libxml2.so.2 → libxml2.so.16 symlink
that ESP-IDF's bundled esp-clang needs on Ubuntu Questing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-contained sound machine. Short press toggles noise on/off; long
press cycles volume yo-yo through [10, 25, 50, 75, 100]% (hold-to-
cycle at 1s cadence); double press is wired up but inert — logs a
TODO line for the eventual HA late-night-lights gesture. NVS persists
volume index, direction, and play state across reboots, with auto-
resume on boot. RGB LED on G27 shows status with a press-flash overlay.
Factored as independent task modules (audio / button / led / nvs)
communicating via mpsc channels carrying typed events. Adding MQTT
later is purely additive: another producer of AudioCommands and
publisher of ButtonEvents, no surgery on existing modules.
Pink-noise generator (Paul Kellet's IIR). Sounds rough on the onboard
NS4168 + tiny built-in driver — speaker bandwidth, not firmware —
but should be the right answer once external amps arrive.
Design docs updated to reflect the new gesture mapping: double-press
is the late-night-lights gesture, long-press is the volume cycle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-Atom-Echo nightstand sound machine project. Includes design docs
(MQTT contract, operating modes, signal chain), hardware reference
(Atom Echo pinmap, datasheets, speaker notes), and a v0.0.1 Rust
firmware that boots, reads the button, and plays a beep through the
onboard amp.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updated for the actual speaker on the bench (GRS 3FR-4, not the
Adafruit 1314 we initially specced — same 60 mm M3 mounting pattern
but a much deeper magnet). Reference doc renamed and rewritten with
the new specs.
Geometry consequences:
* Speaker posts grow from 22 mm to 44 mm to position the frame plate
with 1 mm of clearance below the magnet (vibration isolation; the
alternative of resting the magnet on the plate would couple bass
into the nightstand surface).
* Chamber inner height grows to 46 mm (44 mm post + 2 mm foam tape
on top of the frame, compressed against the cover underside).
* Speaker frame extent goes from 78 mm to 95 mm (the GRS is wider
across the mounting ears), so the chassis grows to ~107×162×49 mm
assembled.
Cable clip rework:
* Was a closed rectangular tunnel centered on the back wall — over-
engineered (the goal was strain relief, not a zip-tie pass-through)
and conflicted with the centered back snap-fit tab.
* Now an open L-shape (post + cap), positioned ~14 mm from the side
wall to clear the snap tab. The cable drops in from above during
assembly and rests in the L corner — post on the chamber-interior
side, cap extending toward the side wall, cable contained on three
sides with the open side facing the wall.
* Cable notch in the back wall and matching notch in the back edge
of the bottom plate shifted to match the L-clip's X position.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reorganized so the bottom plate is the structural foundation and the
chassis ("cover") is purely cosmetic. Cover comes off without tools,
exposing all internals at once.
* Speaker mounts to four tall posts on the bottom plate (60 mm M3
pattern), with self-tap pilots at the post tops. No more screw holes
through the cover's top panel. Foam sealing, if it turns out to be
needed, becomes a foam donut on top of the speaker frame compressed
against the cover underside during assembly.
* USB cable routes internally. The connector lives on the Atom inside
the chamber; cable threads through a printed strain-relief tunnel
a few cm in front of the back wall and exits through a small notch
at the bottom of the back wall, matched by a notch in the plate's
back edge so the exit sits flush with the bottom of the unit.
* Cover retention is snap-fit. Four tabs on the plate carry hemi-
spherical bumps that catch in detent recesses on the chassis inner
walls. M3 screws and bosses are gone.
Post height, cable diameter, and snap-bump diameter are param-tunable
guesses to be dialed in after a test print.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The assembled nightstand unit gets a printable shell. `make enclosure`
renders three STLs to `enclosure/build/`: the chassis (open-bottom shell
with integrated top), a separate bottom plate that screws on with four
M3 self-tap screws, and a translucent snooze cap. The chassis houses
the Atom Echo press-fit onto a daughterboard via M5's 5+4 "Atom Stack"
header pattern, a MAX98357A I2S amp, and the Adafruit 1314 speaker.
Snooze cap on the front-top half presses the Atom's button via a
captive plunger; speaker fires up through a perforated grille on the
rear-top half; USB-C exits a cutout in the back wall.
Layout decisions worth flagging for future archaeology:
* Speaker fires up — less directional, more room-filling for a
nightstand. Snooze cap on the near (front) half of the top, grille on
the far (rear) half.
* No internal divider between the snooze and speaker zones — single
shared chamber so the USB-C cable can route from the front-mounted
Atom straight to the rear cutout. White noise through a 3" driver
doesn't need a sealed speaker chamber.
* Press-fit on the 5+4 side headers as the only Atom mount — no screws
on the Atom itself. M5's mechanical drawing shows two M2 mounting
holes on the bottom face, but they aren't externally accessible on
the actual unit (covered by the label sticker).
* Bottom plate screws into corner bosses with M3 self-tap into PLA;
four register tabs on the plate keep it laterally aligned during
assembly before the screws snug.
* 1.5 mm fillet on visible top + side edges; cap top has a 1.5 mm
spherical dome for tactile cue in the dark.
* Daughterboard is hand-soldered protoboard, not a custom-fab PCB.
Standoffs at the four corners of a 38 × 38 footprint for now; will
re-tune once a real piece of perfboard is on the bench.
The reference docs needed several corrections that fell out of measuring
the actual hardware (photos of the bottom, back, and top-right faces
added under `reference/atom-echo/`):
* Bottom face has the 5+4 press-fit headers + a male HY2.0-4P GROVE in
the middle.
* USB-C is on the back face, not the top.
* The onboard NS4168 speaker grille shares the top face with the
button, not the front.
* Planned MAX98357A wiring: I2S DIN moves from G21 (which isn't exposed
on the Atom's external pins) to G25.
* Stale PAM8302 references in the Adafruit 1314 doc updated to
MAX98357A — that path was the rejected analog-amp architecture.
References:
* https://docs.m5stack.com/en/atom/atomecho
* https://www.adafruit.com/product/3006 (MAX98357A breakout)
* https://www.adafruit.com/product/1314 (3" 4Ω speaker)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Devices now ship new firmware to themselves via Home Assistant. The
publish side is a `make firmware-ota-publish` that builds the binary,
copies it to a static HTTP host on the LAN, and announces the version
on a shared retained MQTT topic. HA's `update` entity compares the
shared latest against each device's installed version and shows an
Install button on the device card; clicking it sends the device the
URL pattern, which streams the binary via `esp_https_ota` into the
inactive partition slot, with `update_percentage` republished every 5%
so HA renders a real progress bar. After reboot, the firmware confirms
itself with `esp_ota_mark_app_valid_cancel_rollback` once MQTT is back,
which arms ESP-IDF's two-slot rollback against any version that can't
reach the broker.
Notable bits in the diff:
* Two-slot OTA partition table (`firmware/partitions.csv`); a one-time
wired flash migrates a v0.2.x device to it. From v0.3 onward every
bump is OTA.
* `CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP=y` — without it the C function
rejects http:// URLs at config-validation time and never opens a
socket. Plain HTTP is intentional; the trust boundary is the LAN.
* `make firmware-ota-publish` reads `OTA_LOCAL_DIR`/`OTA_URL_BASE`/
`MQTT_URL` from `.envrc.private` (gitignored). The firmware reads
`ota_url_base` from `cfg.toml`; consequently any change to that
value is a wired-flash event for now.
* `firmware/src/channels.rs` — the std::sync::mpsc + esp-idf-rs
pthread-mutex incompatibility bites the OTA worker too; we use
FreeRTOS native queues here as elsewhere.
* `reference/mqtt-contract.md` and `operating-modes.md` rewritten to
match the actually-implemented behavior: button is a `sensor` with
an idle-after-N-ms reset (the v0.2.1 change), no more `rssi`, the
`update` entity, the shared `sound-machine/firmware/latest` topic,
and the OTA flow + rollback semantics.
References:
* https://docs.espressif.com/projects/esp-idf/en/v5.3.3/esp32/api-reference/system/ota.html#app-rollback
* https://www.home-assistant.io/integrations/update.mqtt/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch the button discovery from `event` to `sensor`. Event entities
are designed for fire-and-forget transient triggers and don't carry a
meaningful "current state", so the device card was always showing
"Unknown" — useless for a glance at what just happened.
The sensor entity holds a stable resting value of "idle". On a
gesture, firmware publishes the event_type briefly (non-retained), and
the network task schedules a retained "idle" republish 800ms later
so the card snaps back to rest. Existing MQTT-trigger automations
(payload `{"event_type":"short"}` etc.) keep working unchanged — same
topic, same payload, just bracketed by idle states.
The retired-entities list now also clears the old `event` discovery
config so HA drops the placeholder entity automatically on first boot
of the new firmware.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both nightstands now appear in Home Assistant with a button event,
white-noise switch, volume slider (0-100, snapped to the existing
preset list), and an uptime sensor. Topic prefix is the lowercase MAC
hex (`nightstand/<mac_hex>/...`) — no KNOWN_DEVICES table to maintain.
HA users name and pair devices in the UI; `unique_id` stays MAC-stable.
Short press in online mode round-trips through HA: the device publishes
the event and waits for HA to send back `cmd/play ON`/`OFF`, so a single
gesture can do different things depending on time of day, occupancy,
etc. Long-press still cycles volume locally in both modes (with a
publish for HA logging when online); double-press is a pure HA gesture
for the late-night-lights routine. Offline, short-press toggles audio
locally as a fallback so muscle memory still works without HA.
Every amplitude transition is a 700 ms per-sample fade — start, stop,
and volume changes (preset cycle or HA slider). Constant step rate, so
small adjustments finish in proportionally less time and feel snappy
while big ones feel deliberate. Fade lives inside the noise generator
so the pink filter doesn't see any discontinuity; no clicks.
Cross-task channels are backed by FreeRTOS native queues
(`channels.rs`). `std::sync::mpsc` and `crossbeam-channel` are both
broken on esp-idf-rs because of the `PTHREAD_MUTEX_INITIALIZER`
mismatch (newlib's zeros vs. ESP-IDF's `0xffffffff`); the inner
`Mutex<Waker>` they rely on crashes the moment a multi-producer send
needs to wake a blocked receiver. FreeRTOS queues are the native
primitive on this platform, ISR-safe, and bypass the pthread layer
entirely. Wrapper API is intentionally close to mpsc so the call sites
didn't have to learn anything new.
WiFi creds + MQTT URL come from `cfg.toml` (gitignored, with a
`cfg.toml.example` placeholder) via the toml-cfg crate. Empty values
panic at boot; the public repo never sees real secrets.
Top-level + firmware Makefiles; `make firmware-flash` is the headless
flash entry point and `firmware-flash-monitor` is interactive. Both
auto-create the project-local libxml2.so.2 → libxml2.so.16 symlink
that ESP-IDF's bundled esp-clang needs on Ubuntu Questing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-contained sound machine. Short press toggles noise on/off; long
press cycles volume yo-yo through [10, 25, 50, 75, 100]% (hold-to-
cycle at 1s cadence); double press is wired up but inert — logs a
TODO line for the eventual HA late-night-lights gesture. NVS persists
volume index, direction, and play state across reboots, with auto-
resume on boot. RGB LED on G27 shows status with a press-flash overlay.
Factored as independent task modules (audio / button / led / nvs)
communicating via mpsc channels carrying typed events. Adding MQTT
later is purely additive: another producer of AudioCommands and
publisher of ButtonEvents, no surgery on existing modules.
Pink-noise generator (Paul Kellet's IIR). Sounds rough on the onboard
NS4168 + tiny built-in driver — speaker bandwidth, not firmware —
but should be the right answer once external amps arrive.
Design docs updated to reflect the new gesture mapping: double-press
is the late-night-lights gesture, long-press is the volume cycle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-Atom-Echo nightstand sound machine project. Includes design docs
(MQTT contract, operating modes, signal chain), hardware reference
(Atom Echo pinmap, datasheets, speaker notes), and a v0.0.1 Rust
firmware that boots, reads the button, and plays a beep through the
onboard amp.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>