Blob: firmware/README.md
Rust firmware
The application uses Rust std on ESP-IDF. ESP-IDF supplies hardware drivers,
Wi-Fi, HTTPS and FreeRTOS; str0m supplies WebRTC with a combined ESP-IDF and
RustCrypto provider.
Rust's thread builder creates the tasks. esp-idf-hal configures pthread
priorities, core affinity and internal stacks for the radio, analysis and metrics
tasks, with a Rust guard that restores the creator's settings after each spawn.
The native boundary contract defines their ownership rules.
Modules
| Module | Responsibility |
|---|---|
radio-core::music |
Validate catalogs and CRC; index and read packets from bounded storage |
radio-core::playback |
Advance song position and transport time against a supplied clock |
radio-core::protocol |
Parse commands and encode acknowledgments, telemetry and spectrum |
radio-core::spectrum |
Maintain PCM history, window samples, map FFT bins and validate result freshness |
radio-core::metrics |
Calculate per-core busy time and format measured telemetry |
radio-core::now_playing |
Track current-song metadata, position and duration from emitted packets |
radio-core::signaling |
Validate Worker responses and identify fatal signaling failures |
radio-webrtc |
Own str0m, UDP, timers, audio, negotiated data channels and bounded queues |
esp32-radio::app |
Create tasks and channels; coordinate one restart after fatal task errors |
esp32-radio::radio |
Own the peer, LED, playback state and packet buffers |
esp32-radio::signaling |
Establish sessions and send HTTPS heartbeats |
esp32-radio::analysis |
Decode packet copies and calculate spectra on core 1 |
esp32-radio::metrics |
Sample CPU, chip temperature and memory on a low-priority core-1 task |
esp32-radio::console |
Handle local USB ping/reboot commands |
esp32-radio::platform |
Wrap ESP-IDF HAL and the private C ABI with safe Rust interfaces |
esp32-radio::platform::crypto |
Adapt ESP-IDF AES/SHA/EC and RustCrypto ChaCha to str0m/dimpl |
platform/ |
Implement the C ABI for drivers, PSRAM, HTTPS, certificates, crypto and DSP |
All three crates are application components with publish = false. radio-core
and radio-webrtc forbid unsafe code and run on desktop Rust without the ESP
SDK. The device crate builds as a static library for xtensa-esp32s3-espidf,
linked by CMake. CMake supplies its SDK configuration and include paths to
esp-idf-sys, which generates the bindings used by the HAL. Host checks compile
the device crate with a task-configuration stub that returns an error; they do
not run its tasks or compile the ESP-IDF-specific HAL code. The firmware build
compiles that code, and device checks validate its runtime behavior.
Runtime
Task ownership
The radio task exclusively owns the peer, LED, playlist position and counters. It runs on core 0 at priority 5 with a 48 KiB internal stack. The signaling task owns its blocking HTTPS client and a 16 KiB stack; it can run on either core. They exchange typed requests and events through bounded channels. Each request includes a separate channel for its acknowledgment.
WebRTC transport
The radio task passes accepted UDP datagrams and expired timers to str0m through
handle_input. After every operation that can change protocol state, it calls
poll_output until str0m returns the next timeout. It sends each Transmit
output over UDP and processes connection or data-channel events before changing
protocol state again.
Incoming text commands enter a queue of at most 16 messages, each at most 512 bytes. The radio consumes at most one command per iteration. Malformed commands are rejected; queue overflow is counted in telemetry. Outgoing application data is capped at 2 KiB per message with an 8 KiB buffered limit per channel. Data-channel backpressure drops the attempted message and increments telemetry. Other send failures return to the recovery path; audio-send failures end the publisher session. SCTP receive limits apply during reassembly: 8 KiB per message, 32 KiB of retained DATA payload, 64 retained fragments and eight concurrently tracked stream states. Stream IDs can still be large. These limits bound retained DATA storage, not every SCTP control allocation. Exceeding a hard limit closes the association; the resulting transport failure restarts the board and creates a new session.
The str0m peer state is allocated on the heap so its construction and packet loop stay within the radio task's stack budget. Catalog validation yields to the scheduler between cache fills, which lets core 0's idle task service its watchdog. Allocations larger than 1 KiB prefer PSRAM; task stacks and the aligned FFT buffer explicitly use internal RAM. Keep this threshold when changing SDK configuration so transport buffers leave room for Wi-Fi and HTTPS.
Cryptography
The board generates a fresh ECDSA P-256 key and self-signed certificate after Wi-Fi and time synchronization. mbedTLS supplies certificate creation, AES-CTR, AES-GCM, SHA/HMAC, P-256 key exchange and P-256/P-384 signing/verification. RustCrypto supplies ChaCha20-Poly1305 and fallback primitives. str0m and dimpl remain responsible for protocol processing, nonce construction, replay protection and fingerprint checks.
The str0m crypto provider preserves upstream cipher-suite metadata and ordering, with P-256 key exchange selected for this board. Native crypto calls use ESP-IDF's shared peripheral locks and retain no Rust pointers. Adapter instances store AES keys, signing-key copies, ECDH scalars and derived secrets in zeroizing buffers. The DTLS certificate/key pair follows the owned buffer lifetimes defined by str0m and dimpl. For incremental SHA, Rust stores a snapshot of the native context's value fields instead of retaining a native handle. See the boundary contract for their SDK-specific requirements.
Startup checks cover GCM authentication failure, key derivation, multipart HMAC, and SHA snapshots. dimpl also runs its configured provider validation. The small provider patch supports application-supplied certificates and crypto selection without enabling the AWS-LC generator.
Data channels
Cloudflare assigns robot and spectrum IDs during signaling. Rust validates
their names and distinct IDs, then opens externally negotiated channels with
those IDs. robot is ordered and reliable; spectrum is unordered with zero
retransmits. Stream 0 establishes SCTP for SFU server events. Each listener has
its own connection and channel allocation. The transport uses IPv4 UDP to the
SFU; networks must allow outbound UDP. TURN relaying and TCP fallback are not
implemented.
Audio and spectrum analysis
Audio scheduling uses absolute 20 ms deadlines and skips stale frames after a stall. Each encoded Opus packet is passed to str0m's RTP writer. The writer creates an owned payload before returning, so the radio can reuse the music buffer. Sequence numbers and the 48 kHz transport clock continue across silence and track changes. str0m performs packet queuing, adds negotiated header extensions and applies SRTP protection.
Pause freezes song position while sending Opus silence. Restarting the song does not reset the transport clock. Spectrum is sent at 25 Hz, telemetry at 2 Hz. Network buffering makes the audio and graph approximately aligned.
The analysis task runs at priority 4 on core 1 with a 20 KiB internal stack. It owns the Opus decoder and ESP-DSP floating-point FFT. The radio task runs on the other core and sends packet copies through a bounded, nonblocking channel, so it never waits for analysis work.
Rust owns the PCM ring, periodic Hann window, 32 logarithmic bands and result timestamps; native adapters handle decoding and the 2,048-point FFT kernel.
PCM history lives in PSRAM, while the aligned FFT scratch buffer lives in internal RAM. Each analysis job and result carries the current playback epoch. Pause, resume, restart and track changes increment that epoch; the radio discards any result whose epoch no longer matches. Missing packets reset decoder and PCM-window continuity. Results delayed by more than 120 ms are also discarded. During pause the radio sends zero bands without waiting for analysis. Telemetry reports analysis timing and drop counts.
Music and playback metadata
Music stays in a 16 MiB flash partition. Rust reads it through a 32 KiB cache and indexes at most 180,000 packet offsets stored as 32-bit values. Every song's metadata, record boundaries, and CRC are checked at boot. The music format describes v4 catalogs and older single-song packs.
The transport clock continues across automatic advancement and Next commands. Automatic song changes commit at the next packet deadline; commands during the final packet still act on the announced song. A playback revision identifies one occurrence of a song within the current publisher session. It changes on restart and whenever playback advances to another song. Connected listeners receive the current metadata over the reliable data channel. Device HTTPS heartbeats send the same metadata to the Worker so listeners can see it before establishing WebRTC.
The shared Station stores the latest metadata in an immutable Arc. The
signaling task holds the mutex only long enough to clone that Arc, then
performs HTTPS after releasing the mutex. Metadata strings are copied only when
the radio publishes a new playback revision. HTTP response parsing borrows the
native client's single 24 KiB buffer; the parsed setup state owns its data before
the next request reuses that buffer.
Hardware metrics
A priority-2 task on core 1 samples once per second with an 8 KiB internal stack. It owns the chip temperature sensor; float conversion stays off the radio task. CPU readings use per-core idle-time differences from ESP-IDF's 64-bit, microsecond runtime counters. Waking the sampler also refreshes core 1's idle accounting while analysis is paused. These readings describe non-idle scheduled time, including the limitations of interrupt accounting; they are not power measurements.
The sampler reports separate internal/PSRAM free bytes and refreshes minimum-free
and largest-block diagnostics every five seconds. Radio, analysis and sampler
stack readings report the lowest unused stack space since task creation. They
refresh every five seconds; analysis refreshes only while processing packets.
Temperature is measured inside the chip in the configured −10–80 °C range.
Unavailable temperature/RSSI values, initial CPU windows and invalid counter
deltas are null.
The radio reads a one-item metrics queue without waiting. If no sample is
available or the latest is more than three seconds old, hardware and rssi
are null. If the sampler exits with an error, readings become unavailable;
audio and controls continue.
The hardware telemetry object carries
sampledAtMs (boot-relative milliseconds), two cpuBusyBps values (0–10000),
chipTemperatureMc (milli-Celsius), internal and psram memory statistics,
sampleCostUs and samplerStackFree. Sampling cost is elapsed time including
preemption.
Build and test
Complete the root setup, including the exported
SIGNALING_URL, then use the root Makefile:
make tooling-check
make check-rust
make build-firmwareFor extended on-device crypto tests, build with
python3 scripts/build_firmware.py --crypto-self-test, then flash and monitor.
This includes explicitly public test-key/signature fixtures, cross-library
P-256 signing and key agreement, negative verification, and buffer-boundary
cases before streaming. A normal build omits these fixtures.
For native AES/HMAC profiling, build with
python3 scripts/build_firmware.py --crypto-profile, then flash and run the image.
After five seconds of streaming, it measures calls and their mbedTLS allocation
requests for 30 seconds. It prints task-scoped aggregate counters over USB, with
no keys or payloads. These are requested allocations and elapsed call times, not
retained heap usage or CPU cycles. Normal builds omit the profiler and linker
wrappers.
C and header formatting follows .clang-format and covers
platform/. For editor formatting, use .tools/clang-format/bin/clang-format
from the repository root; make fmt-c and CI use the same pinned 23.1.1
executable.
The helper uses RADIO_SDK_ROOT or a local SDK under .tools/. Output goes to
<SDK root>/rust-radio-build; private Wi-Fi/device headers are generated there.
Rust uses its release profile with opt-level = "s". ESP-IDF and the C adapters
use -O2, with assertions enabled. The helper verifies these settings, the 1 KiB
PSRAM allocation threshold and native hardware crypto options before compilation.
Rust's release profile does not select C optimization.
Runtime accounting is enabled in sdkconfig.defaults. ESP-IDF keeps resolved
settings in generated firmware/sdkconfig; remove that file to reapply the
checked-in defaults when configuration changes.
Native revisions, patch provenance and tool versions live in
scripts/project.py, Cargo.lock, dependencies.lock
and the vendor/*/PATCH.md files. The Xtensa toolchain is named esp-radio.
The supported memory configuration and partition addresses are in
sdkconfig.defaults and partitions.csv.
Keep those settings for the N32R16V. The native C adapters in platform/ are
part of the Rust application's build.
See the root guide for backup and flashing and music replacement. The board uses autonomous HTTPS and exposes ping/reboot commands over USB.
For behavior or ABI changes, build for Xtensa and run the live browser test. Host tests use silence and synthetic values; they cover parsing, playback, and analysis ordering but cannot establish device timing, memory use, or Wi-Fi behavior. The transport tests additionally exercise real UDP, DTLS, negotiated channels, packet loss, and repeated teardown on the host.
Run make check-transport-patches after editing or upgrading the vendored
transport libraries. Their separate host test graph and patch provenance are
documented in str0m and
sctp-proto. CI runs both library suites and the
fixture-backed continuity, keyframe and packet-loss integration tests.