Firmware
ESP32-S3 firmware for the minimal board (hardware/DESIGN.md), in C on ESP-IDF v5.5.
It speaks protocol v1 and mirrors the browser phone (apps/device-web):
the handset state machine (packages/core/src/device.ts → main/phone.c), the status strip
(strip.ts → strip.c), the status light (leds.ts → render() in main.c) and a first
part of the menu (menu.ts → menu.c). With a server it can’t talk to (protocol versions,
compat.c) it shows UPDATE NEEDED or SERVER TOO OLD and retries hourly; unknown fields and
messages from a newer server are ignored.
Status: sets up its Wi-Fi, pairs, signs in, and makes and takes calls with two-way audio (WebRTC, Opus wideband or G.711). Call
audio is verified end to end in the QEMU simulator with a test tone; the codec path (ES8311) needs a
real board (hardware/BRINGUP.md).
| Works | Stubbed / TODO |
|---|---|
| 12 keys + hook + jack detect (interrupts + debounce), piezo ring and beeps, status LED patterns (idle, pairing, offline, ringing, missed) | Voice prompts (the pairing code read out), AEC if the handset echoes; TURN over TCP/TLS (broken in esp_peer 1.5.6) |
WeAct 2.13” e-paper (SSD1680, 250 × 122; the 2.9” with CONFIG_OLP_EPD_213=n) behind a small display interface; the protocol’s strip model (≤ 2 lines × 16 chars, large type) |
Partial refresh uses the controller’s built-in mode (0x22 0xFC): check on a real panel |
Setup network (see below): “OpenLoungePhone-XXXX” + a captive setup page (Wi-Fi and server: your own, or the public hub to try it) when no Wi-Fi is saved; Wi-Fi also from the console (wifi) or Kconfig; server also from the console (server), MENU → 4, or Kconfig (default: none, see “Your keys, your server”) |
Bluetooth setup from the companion; the QR sticker |
WebSocket over TLS (the ESP-IDF certificate bundle), reconnects, keep-alive {"t":"ping"}, status every minute; updates (see below; off in source builds): signed manifest + image, two slots, rollback, overnight while idle, MENU → 9 |
Hardware Secure Boot V2 / flash encryption eFuses (documented, not burned) |
hello → pair.begin (alg: "p256", mbedTLS ECDSA) → pair.code on the strip + beeps → pair.done → reconnect → auth.challenge/auth.proof → config |
Flash encryption and hardware secure boot (eFuses; planned for production phones) |
deviceStep: hook, keys → button (in pages of ten: BACK with the handset up turns the speed-dial page, button.page), incoming ring (BACK silences it here), answer by lifting, hang up by the hook, rooms, busy decline; call waiting (WAITING: MOM / 1 TAKE 2 IGNORE; taking it ends the call you’re in: one call has audio at a time); MENU in a call: 1 hold / resume, 2 blind transfer, 3 mute (the codec’s mic off, silence sent; MUTED, the LED’s slow double blink), 9 volume; missed calls (MENU 5: call back, next, clear) and redial (MENU 8, kept in NVS); RECONNECTING when the call’s connection drops, CONNECTION LOST (call.hangup lost) if it isn’t back in 15 s; leaving a message after no answer (beep, record 8 kHz WAV, hang up to send); listening to voicemail (MENU 2 → 1 on a person’s own phone); extensions (MENU 6, and transfer to one from the call screen); Lounge phones (takeover code on the e-paper, PRESS KEY 7 proof, MENU 5 chat / 8 log out, who’s here, house-line keys) |
Adding a second caller, merge and attended transfer (one call has audio at a time), playing recorded greetings |
Call audio (see below and docs/audio.md): WebRTC with Espressif’s esp_peer (ICE with the server’s STUN and TURN over UDP, DTLS-SRTP), Opus 16 kHz wideband with G.711 µ-law as the fallback, the ES8311 over I2S at 16 kHz, the mic’s AGC, an adaptive jitter buffer with loss concealment, sidetone, the echo measured in every call and an echo canceller, a capped earpiece volume, call-progress tones in the earpiece |
First-run mode choice (always kind: "kids") |
wipe: erases the whole NVS partition (the device key, id and settings; Wi-Fi put back), reboots, pairs again; factory reset: MENU+BACK held 10 s at power-on; encrypted storage in release builds (see below) |
Rooms (group calls) have no media on the phone yet |
The phone’s screens, keys, LED, beeps and menu follow docs/phone-ux.md: MENU → 1 Volume (earpiece, ringer loudness and style, 6 sidetone, 7 echo canceller), 2 Voicemail (count, play, clear), 3 Network: Wi-Fi and server (→ 1 change: the setup network; kids’ phones ask twice, Lounge phones don’t offer it), 4 the house intercom or the chime (else Network), 5 Missed calls / 8 Redial (on a Lounge phone in use: 5 chat / 8 log out), 6 Extension, 7 Phone check (the mic’s live speech level with the handset up; → 1 self-test, 2 echo test), 9 Update now, 0 About (firmware version + the four fingerprint words); the clock on the ready screen; the daily check-in (CHECK IN BY 10:00 / PRESS 1: I'M OK); the status LED double-flashes in a recorded call |
Spoken prompts |
House intercom (intercom.c, lan.c, lan_crypto.c; see below): phones of one space on the same network call room to room with no server, off unless turned on in the app; control lines HMAC-signed with the space’s key, audio AES-128-CTR |
Tried in QEMU only: two boards on real Wi-Fi, the codec’s audio over it |
Wired network on hw v0.2 (eth.c, netsel.c; see below): the W5500 on the display’s SPI bus, DHCP, the cable used when it works and Wi-Fi off, Wi-Fi as the fallback with the cable’s PHY powered down and checked every 30 s; setup without Wi-Fi on a cable; eth on the console |
Run in QEMU with its Ethernet as the cable (tools/qemu_cable.py); the W5500 itself needs a v0.2 board (BRINGUP.md §8) |
Build and flash
Section titled “Build and flash”Install ESP-IDF v5.5 once (official installer, ESP32-S3 only). v5.5 is required: esp_peer’s
prebuilt libraries call esp_log(), which v5.4 doesn’t have (the build stops with a clear message
on older versions).
mkdir -p ~/esp && cd ~/espgit clone -b v5.5.5 --recursive https://github.com/espressif/esp-idf.git esp-idf-v5.5.5cd esp-idf-v5.5.5 && ./install.sh esp32s3Then, in each new shell: . ~/esp/esp-idf-v5.5.5/export.sh, and:
cd firmwareidf.py buildidf.py -p <port> flash monitor # e.g. /dev/cu.usbmodem1101; Ctrl-] quits the monitorBuilding for each board
Section titled “Building for each board”The board is a build setting (menuconfig → Open Lounge Phone → Board). It decides which pins the firmware uses and the board id that updates must be signed for.
| Board | Build | Board id |
|---|---|---|
| Minimal board, rev A (hw v0.1, Wi-Fi only): the default | idf.py build |
minimal-revA |
| hw v0.2 (rev A + RJ45 with a W5500, and PoE) | idf.py -B build-v02 -D SDKCONFIG=build-v02/sdkconfig -D "SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.v02" build |
minimal-v02 |
A rev A build has no wired network code at all: it never drives GPIO0 (the BOOT button there)
or GPIO45, never looks for a W5500, and links nothing from esp_eth (CI checks the image). The
board id isn’t settable by hand, so an update built for one board can’t install on the other.
Flash the build for the board you have. A v0.2 build on a rev A board would find no W5500 and
run on Wi-Fi, but it drives GPIO0 high as a chip select, and rev A’s BOOT button pulls GPIO0
straight to ground (v0.2 has 1 kΩ in series): don’t hold BOOT on such a board.
Flashing and the console use the board’s USB-C (native USB, USB-Serial-JTAG). If the board
doesn’t show up, hold BOOT, press RESET, release BOOT. idf.py menuconfig → Open Lounge Phone
picks the board (above), can bake in a default server and Wi-Fi (both empty by default),
Updates turns updates on with your key, and Call audio sets the earpiece cap, mic gain and ICE
options. CI builds both boards, the Wokwi and QEMU targets with the espressif/idf:v5.5.5 image and runs the host
unit tests (make -C firmware/test/host: G.711, tones, meters, ICE server choice, the audio
modules: AGC, jitter buffer, concealment, resamplers, Opus, sidetone, echo; they need libopus:
brew install opus or apt-get install libopus-dev pkg-config).
Your keys, your server
Section titled “Your keys, your server”Nothing in a phone you build depends on the project. A phone built from this repo with the defaults connects to no server and checks for no updates until you say so.
| Key | Where it lives | Who holds it |
|---|---|---|
| Device key (P-256): signs the phone in | made on the phone at first boot, in its (encrypted) NVS | the phone; the server only knows the public half |
| Server federation key (Ed25519): signs server-to-server requests | made on your server when you deploy it | whoever runs that server |
| Update key (RSA-3072, optional): signs firmware updates | firmware/keys/ on the machine that builds releases (gitignored) |
you, if you turn updates on |
| Storage key (eFuse HMAC, release builds) | burned into the chip at first boot, unreadable | the phone |
Server. The setup page asks for one: My own server (its address, e.g.
phone.example.com) or Public hub (hub.openloungephone.app, free, to try it). Change it
later with MENU → 4 → 1 (re-opens setup) or the console (server <address>, server hub,
server none); another server means pairing again. Until one is chosen the display says
SET UP: CHOOSE A SERVER. To bake your own into your builds, set CONFIG_OLP_SERVER_URL.
Servers talk to each other either way (federation): the server you pick decides nothing about
who you can call.
Updates. Source builds embed no update key and no update URL, so they never check. To
update your own phones: tools/release.sh keygen (your key pair in keys/, gitignored; back up
the private half offline), then tools/release.sh build --repo you/your-repo and publish
(your key, your fw-stable channel), or turn on CONFIG_OLP_OTA in menuconfig with your own
manifest URL (or set it later with ota url). The build stops with that instruction if updates
are on and no key exists. Official firmware releases (fw-v*, built with
sdkconfig.release by tools/release.sh build) are the only builds that embed the project’s
public key (main/ota_signing_pub.pem) and the official fw-stable channel: flashing an official
binary is how you opt in to the project’s updates. CI checks all of this
(tools/check_release_scheme.py).
Secure Boot. If you turn on hardware Secure Boot V2, its digest is your update key’s, burned into your phones’ eFuses; the project has no say in it.
Pin map. main/board.h is generated from hardware/build/main/gpio_map.json:
make -C hardware build, then python3 firmware/tools/gen_board.py (it also copies the
fingerprint word list from packages/core; --check fails on drift). The pin map is hw v0.2’s;
its wired network pins sit inside #if CONFIG_OLP_BOARD_V02 (BOARD_HAS_ETHERNET is 0 on rev
A, whose pins are otherwise the same).
House intercom
Section titled “House intercom”Moved in from the labs build in 0.9.4. The server’s config turns it on per phone
(config.intercom: the room’s name and the space’s 32-byte key, base64url); the phone keeps both
in NVS (olp / ic_room, ic_key) so it works without internet, and erases them when a config
comes without it (turned off, or the phone moved to another space) or on a wipe.
- Control: UDP broadcast on port 47100, one line per message (
intercom.c:HELLO,RING,ANSWER,BUSY,BYE,TAKEN,CHIME), each sealed asOLP2|<unix seconds>|<random 32-bit hex>|<line>|<tag>with tag = the first 16 bytes of HMAC-SHA256 over the rest, keyed with HMAC(space key,olp-intercom-control). Lines are refused when the tag is wrong, when they’re more than 60 s off this phone’s clock (if both know the time: it comes from the server’s config), or when their random number was seen among the last 32. Unsigned (labs 0.0.0,OLP1|…) lines are ignored. - Audio: UDP 47101 straight between the two phones:
[seq][AES-128-CTR(G.711 frame)][tag], per-call keys from HMAC(space key,olp-intercom-audio) and the call’s random id; the counter block holds the call id, the direction and the sequence number, so the two directions never share a keystream; the 8-byte tag (HMAC-SHA256) is checked before decrypting; old or repeated sequence numbers are dropped. - Not protected: who is on the network can see that phones talk (and how much), and a phone restarted with no internet can’t check messages’ age until it’s back (the replay list still works).
Wired network (hw v0.2)
Section titled “Wired network (hw v0.2)”Only in builds for hw v0.2 (sdkconfig.v02); a rev A build has none of it. The pure decisions
are main/netsel.c (host-tested); main/eth.c drives the chip and main/net.c applies the
choice. Screens and keys: docs/phone-ux.md “Wired network”.
- The chip. A WIZnet W5500 with ESP-IDF’s
esp_ethdriver (esp_eth_mac_new_w5500/esp_eth_phy_new_w5500) in polling mode: its INTn and RSTn have no GPIO, soint_gpio_num = -1with a 10 ms RX poll,reset_gpio_num = -1(its own power-on RC, then the driver’s soft reset), the link read every 500 ms. It shares the display’s SPI bus (SPI2: SCLK IO13, MOSI IO14) with its own chip select (IO0) and MISO (IO45, which only this build adds to the bus); ESP-IDF’s bus lock gives each device whole transactions, so a full e-paper refresh and network frames take turns (main/board_spi.c). The W5500 runs at 16 MHz, the e-paper at 10. It has no MAC address: the phone gives it the chip’s Ethernet one (esp_read_mac(ESP_MAC_ETH), the eFuse base + 3). A board whose W5500 doesn’t answer at power-on logs it and runs on Wi-Fi. The driver runs all the time: powering the PHY down (PHYCFGR power-down mode) drops its link bit and the driver reports the link down; waking it (all-capable autonegotiation, with the PHY reset pulse) brings the link and then DHCP back. Between, the driver reads the link once every 500 ms and polls nothing else. - The network.
esp_netifwith DHCP; DNS, TLS (the certificate bundle), the WebSocket, WebRTC and the house intercom run unchanged over it. The cable’s interface ranks above Wi-Fi for the default route, and the phone sets the default to whichever network it chose. - Choosing. At power-on the cable first, with the Wi-Fi radio off: a link within 5 s and an address within 15 s of the link means the cable is used, and Wi-Fi stays off. No cable (or no address): Wi-Fi, if one is saved; the W5500’s PHY is put in power-down (PHYCFGR) and woken every 30 s for a 6 s look (5 minutes after a link that got no address; no looks during a call); a look that finds a link and an address moves the phone to the cable. The cable pulled out: Wi-Fi at once, the PHY kept on 6 s more for a bumped cable. No Wi-Fi saved: the PHY stays on and the phone waits for the cable. Calls: a call on Wi-Fi stays on Wi-Fi until it ends, even if a cable comes back (the phone moves to the cable when it hangs up). When the network a call is on is lost, the call ends and the phone reconnects to the server on the other network at once rather than waiting for the old socket to time out (a PoE-powered phone is always on its cable, so this matters only when a cable is pulled). The PHY and the Wi-Fi radio are on together only during a cable check that finds a cable, while a call finishes on Wi-Fi after a cable came back, and while the setup network is open on a cable.
- Random numbers. The S3’s random number generator is truly random only while the radio or
the SAR ADC feeds it. With the radio off (on the cable) the firmware keeps the SAR ADC entropy
source on (
bootloader_random_enable), and swaps it for the radio when Wi-Fi starts (the two can’t run together). Device keys, TLS and DTLS keep true random numbers either way. - Setup. A working cable is a network: the setup network doesn’t open for lack of Wi-Fi,
and a setup network that opened only for lack of a network closes when the cable comes up.
Opened by hand (MENU 3/4 → 1,
setup), the page says Wi-Fi is optional and can save just a server (No Wi-Fi: use the cable). - PoE is power only: the firmware doesn’t know or care where its 5 V comes from. A v0.2 phone runs from PoE or a real USB-C charger; a computer’s USB port is for flashing and the console, with the PoE cable plugged in too (the moments above when the PHY and Wi-Fi are on together draw more than a computer port’s 500 mA; hardware/README.md “Power”).
Tested: host tests (test_netsel.c: power-on, fallback, looks, unplug and replug, no Wi-Fi,
no address, calls; test_prov_core.c: setup with a cable; test_menu.c: the Network screen) and,
in QEMU, tools/qemu_cable.py: a QEMU build for hw v0.2 uses QEMU’s Ethernet as the cable
(QEMU has no W5500), and the QEMU monitor’s set_link pulls it out and plugs it back in: the
cable used with no setup network, eth, MENU 4, unplug → CABLE UNPLUGGED, the PHY powered down
(in QEMU the driver is stopped instead: its PHY model can’t power down) and looked for every 30 s, replug found at the next look; with no Wi-Fi: the PHY kept on, the
setup page saving a server alone, setup opening with no network and closing when the cable
comes. Needs a v0.2 board (BRINGUP.md §8): the W5500 itself
(SPI at 16 MHz shared with the e-paper, the PHYCFGR power-down and wake-up, link LEDs), real
Wi-Fi fallback, a call on the cable, PoE-only power.
Console
Section titled “Console”At the olp> prompt (USB serial, 115200 in the simulator):
| Command | |
|---|---|
wifi <ssid> [password] |
save Wi-Fi in NVS and connect |
wifi forget |
forget the saved Wi-Fi (the setup network opens after reboot) |
setup |
open the setup network now (Wi-Fi and server) |
ota [check|now|url <url>|url default] |
updates: status, check, install now, the channel URL (only in builds with an update key) |
setup test [ssid [pass]] |
fetch the setup page from the phone itself and post a bad form (and, with an ssid, a real one): bring-up and Wokwi |
server [<address>|hub|none] |
show or set the server (phone.example.com, wss://…, ws://… for a local test server); another server means pairing again |
status |
phone state, connection, device id, Wi-Fi, the four words, the strip (hw v0.2: plus NET uplink=wired|wifi|none ip=… cable=…) |
key <0-9|menu|back>, hook <up|down> |
press a key or move the hook, as the real switches do |
drop [wifi] |
drop the WebSocket (or Wi-Fi) to test reconnects |
eth |
the wired network (hw v0.2): controller, PHY on/off, link, speed and duplex, address, MAC, the network in use; on rev A: no wired network on this board |
wipe |
forget the device key, id and settings (keeps Wi-Fi), reboot |
audio |
audio device, call counters (tx/rx frames, underruns, concealed and late frames, the jitter buffer’s target jb) and levels (miclevel, micpeak, the AGC’s gain and the speech it hears, rxlevel, the watched tone), the echo (erl, erle) and the sidetone |
audio tone <dialtone|ringback|busy|hold|test|none> |
play a tone in the earpiece, even on the hook (bring-up) |
audio loop <ms>|off |
mic → earpiece after a delay: an echo test for bring-up (the mic is on only while it runs) |
audio watch <hz> |
which frequency the received-audio meter watches (default 440 Hz) |
audio gain <pga 0-30> [scale-up 0-42] |
the ES8311 mic gain in dB, live and not saved (tuning on a board: watch miclevel, or MENU 7’s MIC row with the handset up; then set CONFIG_OLP_MIC_PGA_DB / CONFIG_OLP_MIC_SCALE_DB, see hardware/BRINGUP.md) |
audio agc on|off |
the mic’s automatic gain control (on by default) |
audio codec opus|pcmu |
calls with Opus (default) or PCMU alone as in 0.9.5, for comparing on a board; not saved |
rtc dtls auto |
calls this phone places offer actpass again (the default offers active: the phone is the DTLS client); for comparing, not saved |
audio sidetone off|low|normal |
your own voice in the earpiece in a call (saved; also MENU → 1 → 6) |
audio echo on|off|test |
the echo canceller from the next call (saved; also MENU → 1 → 7), or the echo test (as MENU 7 → 2) |
audio source <hz> |
the test device’s mic tone (0 = silent; simulators and e2e) |
volume [0-10] |
earpiece volume (10 = the cap, 3 dB steps; also MENU → 1) |
rtc [log on|off] |
the call’s media state: ICE servers in use, connected, setup time |
ice <all|udp|tcp|tls> |
which ICE servers the next call uses (tcp/tls = TURN over TCP/TLS only: experimental, see Call audio) |
screen |
print the display’s framebuffer (tools/fb2png.py turns it into a PNG) |
reboot |
restart |
The log shows every protocol message (-> …, <- …), STATE <kind>, STRIP [line|line],
SIG led=…/SIG ring=…, TONE …, RTC state …/RTC CONNECTED …, CODEC send=… recv=…,
AUDIO tx=… rx=… every 5 s in a call (with the codecs, CPU per frame and the echo), ECHOTEST …, and the pairing code as PAIRING CODE: 123456.
Updates (OTA)
Section titled “Updates (OTA)”Releases and tags. Hardware releases are hw-vX.Y (fab files; hw-v0.1 = the minimal
board, rev A); firmware releases are fw-vX.Y.Z (the signed image and its manifest), created with
--latest=false. Phones never use /releases/latest (that is whatever release is newest, a
hardware one included): they read one fixed channel, the fw-stable release, one manifest per
board (CONFIG_OLP_OTA_MANIFEST_URL): firmware-manifest.json for rev A,
firmware-manifest-minimal-v02.json for hw v0.2. tools/release.sh build signs an image and
a manifest for every board (--board reva|v02 for one) and publish --release replaces
them. CI checks this scheme (tools/check_release_scheme.py). Updates are off
in source builds (no key, no URL: “Your keys, your server”); official releases use the project’s
channel, your own releases yours (--repo).
The manifest (firmware-manifest.json):
{"board","version","url","size","sha256","signature"}, the signature being RSA-PSS/SHA-256 by the
release key over olp-ota-v1\n<board>\n<version>\n<url>\n<sha256>\n<size>\n
(tools/ota_manifest.py makes and verifies it; main/ota_core.c parses it, host-tested). The
phone installs only a manifest that (1) is signed by the update key built into it
(main/ota_signing_pub.pem in official releases, yours in your builds), (2) names its board (CONFIG_OLP_BOARD_ID: minimal-revA or minimal-v02, from the board choice), and
(3) is newer than what runs. It then downloads the image over HTTPS (certificate bundle; GitHub’s
redirects followed), hashes it while writing the other slot, refuses a size or SHA-256 that differs
from the manifest, and lets esp_ota_end check the image; in release builds that includes the
image’s own Secure Boot V2 signature (see below). It checks the new image’s version too,
then restarts into it only when the phone is idle.
Rollback. Two app slots (partitions.csv, 1.875 MB each on 4 MB flash) and
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE: a new image boots on trial and is kept
(esp_ota_mark_app_valid_cancel_rollback) once it reaches the server (a pairing code or a signed-in
config). If it crashes before that, the bootloader boots the old slot; if it runs but can’t reach
the server for 5 minutes, it rolls itself back. The log says OTA TRIAL, OTA VALID,
OTA ROLLED BACK. Changing partitions.csv needs a USB flash (older phones with the one-slot table
must be flashed over USB once).
When. Checked a minute after going online and every 6 hours. An automatic update installs
only while the phone is hung up and idle for 10 minutes, and overnight (02:00-05:00 local:
SNTP plus the space’s utcOffsetMin from config; no clock, no automatic install;
CONFIG_OLP_OTA_NIGHT_ONLY). MENU → 9 updates now (not during a call); the display shows
UPDATING 42% / DON'T UNPLUG. Console: ota, ota check, ota now, ota url <url>|default.
Signing without secure boot eFuses. Release builds (sdkconfig.release) use ESP-IDF’s
“signed app images without hardware secure boot” (CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT, RSA-3072,
the Secure Boot V2 scheme): the running firmware accepts an update only if it is signed with the key
that signed itself. Nothing is burned, so dev boards stay reflashable over USB; the protection is
against the network, not someone with the board in hand. Options: (a) this (the default);
(b) real Secure Boot V2: burn the same key’s digest into eFuses so the ROM and bootloader
check every boot (one-time, irreversible; espsecure/espefuse, ESP-IDF’s secure boot guide) — for
production phones, together with flash encryption; (c) dev builds (sdkconfig.defaults): no
image signature check (it would abort an unsigned running image), but the manifest signature and
SHA-256 still authenticate every update, so a dev board only installs release-key-signed images.
Keys and releases. The project’s release key lives with the maintainer, outside the repo
(only its public half, main/ota_signing_pub.pem, is committed). tools/release.sh keygen makes
your key in firmware/keys/ (gitignored, never committed or printed: back it up offline;
losing it means every phone needs a USB flash to trust a new key). tools/release.sh build [--repo you/your-repo] signs a build of PROJECT_VER and writes build-release/dist/;
publish --draft (the default) makes a draft fw-vX.Y.Z that no phone sees; publish --release
asks for confirmation, publishes, and updates fw-stable in that repo.
Tested in simulation (QEMU e2e step 11): against a test pre-release (deleted after the run),
with throwaway keys (the test builds turn updates on with their own key): a manifest signed by another key, one for another board, a wrong SHA-256, an
image signed by another key (refused by esp_ota_end), an older version: all refused; a signed image
that crashes at boot: installed, tried, rolled back to the old slot; a good image: trial boot, kept
after reaching the server, still running after a restart. QEMU caveat: Espressif’s QEMU
hangs on the bootloader’s very first flash write, the one that records ota_0 in a blank otadata
(it is the emulator: its GDB stub stops answering; builds 20250817 and 20260417), so
tools/qemu.sh writes that first entry into the flash image itself (tools/qemu_otadata.py seed,
byte for byte what the bootloader writes). The later writes (NEW → PENDING_VERIFY on the trial boot,
PENDING_VERIFY → ABORTED after the crash) were made by the bootloader itself in the test runs; the
test would make them on the stopped emulator if it ever stalled there, and says which happened.
Needs the real board: a power cut mid-update, and the first boot of a blank board’s bootloader.
Which build it runs (hello.build)
Section titled “Which build it runs (hello.build)”Every hello carries the running image’s SHA-256 (ESP-IDF’s app ELF hash), its project name and
version, and official (build.c, pure parts in build_core.c). When an update has checked out and
its slot is selected, ota.c records that image’s hash in NVS (namespace build, key v_<slot>);
at boot the running image is compared with its slot’s record. Only a match is official: true: a
build from source, a USB flash, or an image in a slot the phone never updated itself is false. The
records survive both wipes. The server uses this to tell a kids’ phone’s guardians about firmware
that isn’t an official update (docs/security-model.md “Firmware changes”). Firmware someone wrote
can report anything; only hardware Secure Boot (planned) rules that out.
Encrypted storage
Section titled “Encrypted storage”Everything the phone keeps lives in NVS: its device key (the P-256 key that signs in to the
server), its id, and the Wi-Fi name and password. Release builds encrypt NVS at rest
(CONFIG_OLP_STORAGE_ENCRYPTED, set in sdkconfig.release; off in development and simulator
builds): ESP-IDF’s HMAC-based NVS encryption. The keys that encrypt NVS (XTS-AES) are derived by
the chip’s HMAC peripheral from a 256-bit key in an eFuse block (KEY5), which software can use but
never read. No flash encryption is needed for it, and nothing else about the phone changes.
First boot with it (one-time, irreversible): when the eFuse block is still empty, the firmware
generates a random key and burns it into eFuse KEY5 with the purpose HMAC_UP. That can’t be
undone or changed: that block is used up for good, and the phone can only ever decrypt NVS written
under that key. If the board already ran a development build, the old (plain) NVS can’t be read as
encrypted data, so the phone starts fresh: a new device key, and Wi-Fi setup and pairing again.
The log says FIRST ENCRYPTED BOOT: burning a new NVS HMAC key into eFuse KEY5, later
STORAGE encrypted (… existing). Before flashing a release build on a dev board, decide whether
that board should stay a development board: the eFuse can’t be un-burned.
What it protects: someone who reads the flash chip (desoldered, or over USB with esptool) sees only ciphertext. What it doesn’t: someone who can run their own firmware on the same chip can use the HMAC peripheral too; that needs hardware Secure Boot V2 (see “Updates”), planned for production phones.
Wipes erase for real. A remote removal (wipe from the server) or the console wipe erases
the whole NVS partition (every flash sector, not “mark deleted”), then writes back only the
Wi-Fi, server and update URL; a factory reset (MENU+BACK 10 s) erases it all. The old device key is
gone from the chip, not just unreachable. The HMAC key stays in eFuse (it can’t be erased), which is
harmless: there is no old data left for it to decrypt.
Tested in simulation (QEMU, which emulates the S3’s eFuses and HMAC peripheral; e2e step 12):
a build with encryption on burns KEY5 on the first boot (and only then), pairs and signs in; with
the emulator stopped, the device id, a Wi-Fi name and its password don’t appear in the NVS flash
and the eFuse file has changed; after a restart the storage decrypts (same Wi-Fi, same phone signs
in); a removal erases it, generates a new key and keeps the Wi-Fi. Step 6 (a plain build) checks
that the old device id is gone from the flash after a wipe; with the old namespace-only erase it
fails (mutation-tested). Needs the real board: espefuse.py summary after the first boot
(KEY5 read-protected with purpose HMAC_UP), hardware/BRINGUP.md §7.
Wi-Fi setup network
Section titled “Wi-Fi setup network”The setup page asks two things: the Wi-Fi and the server (“My own server” with its address, or “Public hub (free, to try it)”; nothing is pre-selected on a new phone). Opened again later, it keeps the saved Wi-Fi unless you pick another, and shows the saved server.
With no Wi-Fi saved (a new phone, or after a factory reset) the phone opens “OpenLoungePhone-XXXX”
(the last MAC bytes) and shows the steps on its display: the network name, its 8-digit password
(WPA2, fresh each time, shown only there: joining needs someone at the phone) and
OPEN 192.168.4.1. Phones usually open the page by themselves: every DNS name answers with the
phone’s address and the OS connectivity checks (/generate_204, /hotspot-detect.html, …) are
redirected to the page. The page lists nearby networks (strongest first) or takes a typed name,
and a password (checked: 8-63 characters or 64 hex); a typed name holds 32 bytes, like the
phone stores it. Anything that doesn’t fit is refused with a message on the page, never cut or
dropped, and an address typed in the server box counts as choosing “My own server”. Save and
restart stores it and the phone restarts onto it. The page answers only on the setup network, never on the home LAN.
It also opens when the saved Wi-Fi has been unreachable for 3 minutes (it closes again if the
saved one comes back), from MENU → 3 Wi-Fi → 1 or MENU → 4 Server → 1, from the console (setup), and with MENU+BACK
held at power-on: release after 3 s for setup, keep holding to 10 s for a factory reset (the
display says which). While it is open the phone retries its saved Wi-Fi only once a minute (a
station scanning for a missing network hops channels and drops the setup network’s clients).
The decisions are pure code (main/prov_core.c: when to open/close, form decoding, input checks,
scan list, the captive DNS answer) with host tests; main/prov.c is the ESP side.
Tested: host unit tests; QEMU e2e step 10 (no radio in QEMU: the setup site runs on its
Ethernet through a forwarded port) — wifi forget → reboot → the setup screen → the page in
Chromium: captive redirect, a short password refused, save → restart with the saved network.
Not tested in a simulator: the SoftAP itself (QEMU has no Wi-Fi; Wokwi has no Wi-Fi clients
and its CI minutes were used up; wokwi/setup.yaml covers the MENU+BACK hold and the phone’s own
setup test when they’re back), the captive-portal pop-up on real phones (iOS, Android), and
the physical MENU+BACK hold. Bring-up: hardware/BRINGUP.md §6.
Call audio
Section titled “Call audio”One WebRTC peer connection per call, with Espressif’s esp_peer (esp-webrtc-solution, from the
ESP component registry, espressif/esp_peer ~1.5.6): ICE, DTLS-SRTP and RTP. main/media.c runs it
on its own task; main/audio.c moves 20 ms frames between it and the audio device.
- Signaling follows the protocol:
rtc.config(the call’s ICE servers) starts the peer connection at once, so STUN/TURN gathering overlaps the signaling; the party that placed the call sends the offer (the phone sends it once the call isconnecting), the other answers.esp_peerputs the phone’s own candidates in its SDP (no trickle from the phone); the other side’s trickledrtc.icecandidates are fed in (queued until its SDP is set). If the media fails to connect or drops, the phone hangs up (call.hangup, busy tone) rather than sit in silence. - ICE: STUN plus TURN over UDP from
rtc.config(main/ice.c, host-tested picks one of each: Cloudflare’sstun:…:3478andturn:…:3478?transport=udp). TURN over TCP/TLS doesn’t work inesp_peer1.5.6 (found in simulation, TESTLOG q4-d4): over TLS (turns:…:443) the agent gives up ~10 ms after starting its non-blocking connect, before any handshake, even with certificate checks off (and its API has no way to pass a CA, so it could not verify the TURN server anyway); over plain TCP it allocates, binds a channel and pairs, then every DTLS read fails (agent_recv error: -1); and with itstcp_supporton, a TCP/TLS server in the list stalls gathering for ordinary calls. So the phone uses UDP only: a network that blocks outgoing UDP (some guest and corporate Wi-Fi) can’t carry the phone’s calls yet.CONFIG_OLP_ICE_TCP_RELAYandice tcp/ice tlstry it again with a neweresp_peer(e2e steps 8 and 9, opt-in). TURN credentials are never logged (rtc.configis logged without them, andesp_peer’s agent, which prints them at INFO, stays at WARN unlessrtc log on). - Codecs (
main/voice.c, host-tested with the system’s libopus): Opus at 16 kHz mono, about 24 kbit/s, 20 ms frames, complexity 1, in-band FEC (libopus from Espressif’sespressif/esp_audio_codec), and G.711 µ-law (PCMU) at 8 kHz as the fallback. The phone offers and accepts both; an offer without Opus opens the peer again sending PCMU before it answers. Its own SDP asks the other side for FEC and at most 16 kHz (main/sdp.c).esp_peersends the codec it was opened with: a call the phone places sends Opus, so a peer that answers PCMU only (as a phone on 0.9.5 or older would) can’t decode it. The log’sCODEC send=… recv=…says what a call uses;CONFIG_OLP_CALL_OPUS=nbuilds a PCMU-only phone. - The audio path runs at 16 kHz. G.711 calls, the house intercom, voicemail and greetings
stay 8 kHz (WAV everywhere) through 2:1 half-band resamplers (
main/resample.c: flat to 3.4 kHz, at least 70 dB down from 4.6 kHz). - Two audio tasks. A pump (
audio_io) reads and writes the codec in 1 ms DMA blocks (CONFIG_OLP_AUDIO_BLOCK) and mixes in the sidetone; the audio task does the 20 ms work (the codecs, AGC, echo canceller, tones, voicemail) through lock-free FIFOs (main/fifo.c), keeping about 15 ms queued for the earpiece. Opus encodes on the audio task’s stack (36 KB); its states and the echo canceller’s are in PSRAM. TheAUDIOline every 5 s gives the encode, decode and echo canceller time per frame (average/worst, µs), the stack left and the internal heap. - Audio device (
main/audio_dev.h): the ES8311 on I2S0 (16 kHz, 16-bit mono, MCLK 4.096 MHz from the S3; registers set over I2C,main/audio_es8311.c), or a test device (main/audio_test.c: the “mic” is a tone at -15 dBFS, 1 kHz unlessaudio source, the “earpiece” measures level and a watched frequency). The simulators always use the test device; a board without a codec falls back to it.audioprints the counters and levels of the current call. - Earpiece: plays only with the handset lifted (the DAC is muted on the hook); call-progress
tones (
soundForinpackages/core: dial 350+440 Hz, ringback 440+480 Hz 2 s/4 s, busy 480+620 Hz 0.5 s/0.5 s, hold beep) are mixed in locally. Volume 0-10 (MENU → 1,volume), 3 dB steps below a cap of -6 dBFS (CONFIG_OLP_EARPIECE_MAX_DB; hardware owner question 8: full scale may reach ~98 dB SPL until the receiver is measured). Sidetone (MENU → 1 → 6): your voice at -20 dB (normal, the default) or -26 dB (low), only in a connected call and never while muted; about 4 ms mic to ear by design (a block in, two queued out, the codec’s filters). - Mic: captured only while a call’s media is connected and not muted (MENU 3), while leaving a
message, while the phone check (MENU 7) is open with the handset up (to show its level, or for
the echo test; nothing is sent or kept), or during the
audio loopbring-up test; otherwise the ADC is muted and I2S RX is off. The PGA is at 30 dB plus 6 dB of ADC scale-up by default, then an AGC (main/agc.c: speech to -24 dBFS, gain -6 to +18 dB, only on frames above a gate over the noise floor, a -3 dBFS peak limiter). - Receiving (
main/jbuf.c,main/plc.c): an adaptive jitter buffer (starts at 60 ms, then 40-120 ms from the measured jitter) behindesp_peer’s own; lost frames come from Opus FEC or Opus’s concealment, or for PCMU a G.711 Appendix I-style concealment (pitch repetition, fading to silence over 60 ms). - Echo (
main/echo.c, SpeexDSP incomponents/speexdsp): the echo return loss is measured in every call (AUDIO erl=); the phone check’s echo test (MENU 7 → 2) measures it and the delay with a sweep. The canceller (SpeexDSP’s, 64 ms tail, with its residual echo suppressor) is off by default (CONFIG_OLP_ECHO_CANCEL), on per phone with MENU → 1 → 7, and turned on by an echo test that finds a poor loss (under 30 dB).
Verified in simulation (QEMU, tests/e2e/live/firmware.test.ts against a live server, see
TESTLOG.md): ICE through Cloudflare TURN over UDP (relay to relay) and directly, DTLS-SRTP, RTP both ways with
test tones (the companion finds the phone’s 1 kHz with WebAudio; the phone measures the companion’s
440 Hz), both call directions, hang-up turns the mic off. Entirely local
(tests/e2e/live/codec.test.ts: QEMU, a self-hosted server on this machine, the companion in
Chromium): a call from the companion negotiates Opus and carries 5 and 6 kHz tones both ways, and
PCMU (telephone band only) when Opus is taken out of its offer; a call the phone places offers both
(its media can’t connect behind QEMU’s NAT without TURN); the echo test runs. Needs the real
board: the ES8311 register setup and I2S timing at 16 kHz with 1 ms blocks, levels and the mic
gain default, the volume cap in dB SPL, the sidetone’s delay and level, Opus’s sound and CPU time,
echo from each handset, and call quality over real Wi-Fi:
hardware/BRINGUP.md.
Pairing a phone
Section titled “Pairing a phone”- Power it. A new phone opens its setup network first (above): choose its Wi-Fi and its server.
Once online it shows
PAIR 123 456on the strip (and printsPAIRING CODE:). - In the companion app: Home → + Pair a phone, type the code, pick the mode and name.
- The phone reconnects, signs the challenge and shows
READYwith its owner line. MENU → 0 shows its four words; the app shows the same four for that phone.
QEMU simulator
Section titled “QEMU simulator”Espressif’s QEMU runs the same firmware on an emulated ESP32-S3 (with octal PSRAM like the board),
with no time limit. QEMU has no Wi-Fi: the QEMU build (sdkconfig.qemu) uses QEMU’s OpenCores
Ethernet with user-mode NAT instead; everything above the link (DHCP, DNS, TLS with the full
certificate bundle, UDP for WebRTC) is the board’s code. There is no display (screen still dumps
the framebuffer) and no keys (use the console’s key and hook).
# Espressif's x86_64 macOS build esp-develop-9.2.2-20250817 (the newer "x86_64" archive holds an# arm64 binary); Linux: python $IDF_PATH/tools/idf_tools.py install qemu-xtensabrew install libgcryptfirmware/tools/qemu.sh run # build, fresh flash + eFuse files, console on this terminalfirmware/tools/qemu.sh resume # run again on the same flash (NVS, OTA state kept)python3 firmware/tools/qemu_smoke.py # the CI boot smoke test (a default build: no server)firmware/tools/qemu_cable.py builds QEMU for hw v0.2 (build-qemu-v02) and runs the wired
network checks above (QEMU_MONITOR=<port> puts QEMU’s monitor on a local port, with the phone’s
network as the netdev cable).
The smoke test boots a default build (no OLP_SIM_SERVER): it checks SET UP: CHOOSE A SERVER,
MENU → 4, that updates are off, and, from a packet capture of QEMU’s network (QEMU_PCAP), that
the phone sent nothing but DHCP: no DNS, no TCP, no UDP to anyone. OLP_SIM_SERVER=wss://host
builds for a server (the e2e tests use their own). The e2e test uses QEMU by default (OLP_SIM=qemu; OLP_SIM=wokwi for Wokwi).
Wokwi simulator
Section titled “Wokwi simulator”wokwi/diagram.json is an ESP32-S3 devkit with 12 key buttons, the hook button (held = on the
hook), the status LED, the buzzer and a display. The simulator build (sdkconfig.sim) joins
Wokwi-GUEST, connects to the server you build it for (OLP_SIM_SERVER=wss://your-test-server;
none otherwise), prints the console
on UART0 (so keys 6 and 7 move from IO43/IO44 to IO35/IO36) and has no PSRAM.
curl -L https://wokwi.com/ci/install.sh | sh # or the release binary into ~/.local/binecho "<token>" > firmware/.wokwi-token # gitignored; never commit itOLP_SIM_SERVER=wss://your-test-server firmware/tools/sim.sh smoke # build + wokwi/smoke.yaml → build-sim/serial.log, *.pngfirmware/tools/sim.sh interactive # the console on your terminal (hook, keys, status…)smoke.yaml boots, joins Wi-Fi, opens the WebSocket, waits for pair.code and the strip, moves
the hook, walks MENU → About → BACK, presses keys, and saves build-sim/screenshot.png (the
simulated display) and build-sim/display.png (the firmware’s own framebuffer). To finish
pairing live, run sim.sh interactive and type the printed code into the companion on that
server (Home → + Pair a phone). Another server: server <address> at the olp> prompt.
Simulator notes (all only in the sim build):
- Display stand-in: Wokwi has no supported e-paper part. The community chip
(
board-epaper-2in9, bonnyr/wokwi-ws29v2) takes one byte per chip select, animates every refresh line by line (the whole simulation ran at a fraction of real time) and dropped the simulator’s connection. The sim uses Wokwi’s ILI9341 SPI TFT on the same pins (main/display_ili9341.c), drawing the same screen image. - TLS: Wokwi emulates the S3’s software ECC about 7× slower than silicon (a P-256 verify
takes 1.4 s of simulated time, P-384 2.4 s), so a full handshake with the certificate bundle
outlasted Cloudflare’s handshake timeout. The sim trusts the servers’ intermediate (Google
Trust Services WE1,
main/sim_ca_we1.pem, valid until 2029-02) and uses P-256 for ECDHE. Hardware builds verify the full chain against the bundle. - A simulation runs at most 5 minutes (the Wokwi plan), at roughly 0.4–0.8× real time.
Requirements already decided
Section titled “Requirements already decided”- Media is WebRTC peer to peer with the ICE servers the server sends in
rtc.config. The browser apps use Opus DTX (usedtx=1,packages/clientwithOpusDtx) so silence costs almost nothing on a relay; the phone uses G.711 (no DTX) for now: see “Call audio” for why. - The board is the minimal board (
hardware/DESIGN.md, GPIO map inhardware/schematic/pin_table.yaml). The handset is analog, on a 3.5 mm TRRS jack (CTIA): the ES8311 (I2C 0x18: SDA IO18, SCL IO17; I2S MCLK IO16, BCLK IO6, WS IO5, DOUT IO15, DIN IO7) records its mic on MIC1 and drives its earpiece from OUTP. Nothing is switched in hardware: firmware reads HOOK (IO2, an MX switch, low = on hook, internal pull-up) and JACK_DET (IO4, high = plug in, internal pull-up), mutes the earpiece on hook and only captures audio in a call. No inline-button detect (a press may show as a transient on the mic ADC). No speaker: the piezo ringer is IO8 (PWM, ~4 kHz resonance) and voice prompts play in the earpiece. If EVT shows receiver-to-mic echo, run AEC with the playback stream as the reference. - Keys: 12 GPIOs with internal pull-ups, active low, debounce 5-10 ms (
pin_table.yaml: KEY_1 … KEY_0, KEY_MENU, KEY_BACK). The status LED is IO1 (the recording light too). The display module is on SPI: DIN IO14, CLK IO13, CS IO47, DC IO21, RST IO38, BUSY IO48. Key 6 is on IO43 (TXD0): ignore it until the ROM boot log is over. - Flashing and the console use the ESP32-S3’s native USB (USB-Serial-JTAG) on the USB-C port.
Modes and remove = wipe (docs/device-lifecycle.md)
Section titled “Modes and remove = wipe (docs/device-lifecycle.md)”- The first-run choice goes in
pair.begin.kind:kids,personalorlounge; whoever claims the phone may pick another mode, andconfig.owner.modeis the truth afterwards. - The strip’s trust line comes from
config.owner(“KIDS: SMITH HOME”, “JESSE’S PHONE”, “LOUNGE: OFFICE”); seeownerLineinapps/device-web/src/strip.ts. - On
wipe: forget the device id, owner and settings, erase the device key and generate a new one, keep Wi-Fi, and go back to “Set me up”. The server sends it to a connected phone when it’s removed, or after a removed phone reconnects and signs the challenge with its old key. - MENU → About shows the device fingerprint as four words:
FINGERPRINT_WORDS[b]for the first four bytes of SHA-256(raw public key), list inpackages/core/src/fingerprint.ts(copy it verbatim), two words per strip line. - An idle Lounge phone may get
config.houseLine(its keys call as the space) andconfig.here(“who’s here”); both are off unless the space turns them on.
USB power
Section titled “USB power”- The minimal board has no CC sensing and draws under 500 mA: it runs fully on any USB source.
Report
status.poweras{ source: "default", reduced: false }(or omit it).