The version is now set by hand in version.txt, so it's readable and only
changes on purpose. git describe gave commit hashes, and always "-dirty"
because the build applies patches to the cspot submodule. Editing the file
is enough; the next build picks it up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All sources (player, test signals) are 48 kHz; at 96 kHz Spotify and HLS
played at the wrong speed. Validation, the page and the docs allow 48000
only, and a 96000 stored before is reset to 48000 at boot (stored values
aren't re-validated, and the SDP would still have said 96 kHz).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/user-guide.md covers setup, the web page, sources and failover, the
Spotify developer app, AES67 output and SDP/SAP, PTP presets, network and
static IP, logging, firmware updates (OTA and USB), the player API,
troubleshooting and known limitations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picks the port, checks ESP32-P4 / chip revision against the image header /
32 MB flash, optionally erases, writes the images from build/flash_args,
reads the boot log for the IP and sets hostname, stream/Spotify name and
multicast address over the API. All questions also work as options.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page starts dark regardless of the system setting; the toggle at the top
right switches to light and is remembered in the browser. [hidden] now
always wins: label{display:block} had overridden it, so the hidden VLAN
split checkbox still showed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The firmware doesn't apply the VLAN split yet (phase 2). The checkbox is
hidden, which also keeps the internet-interface fields and "Web UI / API on"
hidden; the markup stays for phase 2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
net.dhcp = false applies ip/mask/gw/dns on link up (DHCP client stopped).
PTP/TX/SAP sockets are bound to the address, so a changed IP setup reboots
the device; with DHCP on, the static fields (UI fills them with the lease)
don't count as a change. Validation: contiguous netmask, not the network or
broadcast address, gateway in the subnet. The page follows the device to the
new address after saving. Checked: DHCP -> static .245 (PTP, TX, SAP, SDP
origin, mDNS on the new address) -> DHCP.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With DHCP on, the greyed-out IP/netmask/gateway/DNS fields show the
addresses in use. Unticking DHCP keeps them as the starting point for a
static setup. (Static addressing itself is not applied yet.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core: aes67_tx_pink_noise(), a ready-made TX source: xorshift32 white noise
through Paul Kellet's pink filter, -18 dBFS RMS (peaks about -6 dBFS), same
on L and R. The player uses it like the tone (straight to TX, no volume or
gain). Checked on the board: -18 dBFS RMS, octave bands 63 Hz-4 kHz flat
within +-0.4 dB, L = R.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It was in the config and UI but never applied. The gain now saturates at
full scale (the trim boosts up to +12 dB). Checked on HLS: -12 dB lowers
the peak by exactly 12 dB, +12 dB clips cleanly at 0 dBFS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pull callback's contract said "stream channels", but the player always
delivers stereo, so a 1-channel stream went out as L,R,L,R at half speed.
Sources now always deliver AES67_TX_SRC_CHANNELS (2); TX maps to the stream:
2 ch as is, 1 ch = (L+R)/2 in 64 bit with mono_sum (can't clip), else L.
Checked: mono packets 156 B, 1000/s, 48 samples each, SDP L24/48000/1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Save/Revert/Reboot and the status message stay at the bottom of the window
while the settings form is in view. The runtime source override stays in
the API (listed in the page's API box) but is no longer on the page, where
it duplicated the saved source mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The player now owns the effective mode (config or override) and passes it
down: spotify_apply(enable) and hls_set_url(url) instead of both reading
source.mode from the config. /api/player/source forces spotify|hls|off
(config returns to the saved mode), /api/player/url plays an m3u8 now;
neither is saved. GET reports it in `forced`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follows /api/player's position, moves on locally between polls while
playing, seeks on release; disabled when the source can't seek.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
seek {ms} runs on the Spotify session task like a seek from the app (buffer
flushed, app notified). volume {value}|{delta} sets the player volume and
pushes it to the session, so the app's slider follows; setRemoteVolume now
also runs on the session task.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
play/pause/toggle/stop/next/prev. Spotify commands run on the session task
(same thread as cspot's frame handling) and the POST waits until they ran,
so it answers the new state. HLS pause stops fetching and resume rejoins at
the live edge; next/prev on HLS and anything on tone/off return 409.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Source, state, artist/title/album, position and duration (from cspot's
playback state, so it matches the app), volume and `can`. cspot patch 0004
adds read getters for the playback state and context.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No Spotify session (or paused, with failover_on_pause) for failover_delay_s
switches to HLS; Spotify playing switches back within ~1 s. Switches fade
out/in over 30 ms and flush the ring. HLS is suspended while Spotify plays.
cspot is held back instead of drained while it isn't Spotify's turn: it keeps
decoding on pause, so dropping its frames let it race through the queue.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Mac dropped the device soon after the first track change: we never
called SpircHandler::notifyAudioReachedPlayback(), so cspot's queue did
not advance and the state sent to Spotify stalled.
- The data callback records a boundary (output write position, track
id) when the track id changes; the session loop (<= 200 ms) calls
notifyAudioReachedPlayback(id) once playback (ring read position)
passes it, and notifyAudioEnded() after DEPLETED once the buffer is
empty. PLAYBACK_START clears boundaries (next data is a new one),
seek/flush drop pending ones.
- audio_ring exposes its write/read frame counters (wrap-safe compare).
- Verified: 10 min on the Mac without a disconnect, every track change
notified ("track audible, Spotify notified"), app shows the playing
track (switches up to ~3-5 s early), 0 underruns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Spotify AP reset the connection (recv -1, errno 104 ECONNRESET) on
the third Ping, every 6 minutes, like clockwork. cspot answered each
Ping at once; librespot (core/src/session.rs) waits 60 s:
Ping -> 60 s -> Pong -> PongAck -> 60 s -> Ping.
- 0003-delayed-pong: record the Ping, send the Pong 60 s later from
triggerTimeout() (called on every 3 s receive timeout), through the
PR #3 connection snapshot; dropped on reconnect.
- 0002-diag-log-recv-errors: log recv's return value/errno before
"Error in read" (error path only); this is what showed the RST.
Verified: over 8+ minutes Ping/delayed Pong/PongAck every 2 min, no
reset at the 6-minute mark.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MercurySession::reconnect() nulls conn/shanConn (for 5 s per failed
retry) while other tasks keep sending through them: a NULL dereference,
not a catchable exception. Our Spotify sessions are dropped by the
server and reconnect every ~6 minutes, so this race is hit regularly;
likely behind the sporadic resets during long sessions/OTA.
Patch applies cleanly to the pinned 3010349; builds, boots, confirmed.
Drop it once the fix is in the pinned cspot commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- OTA: the player now ends the Spotify session (spotify_suspend: stop
flag, waits until the session's tasks are gone, refuses new ones)
instead of pausing it, and suspends HLS. Pausing was not enough (an
upload still failed once with a paused session). Verified twice with a
session running for minutes: "Disconnecting mercury session / session
ended / suspended", installed in ~26 s, clean reboot.
- Volume: createFromBlob() starts cspot at volume 0, which the app
showed while we played at 100 %. The session now starts with the
player's volume (spotify_set_volume); volumes coming from the app are
stored exactly, without echo. Verified: slider starts at 100 %.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- spotify_init() sets up the logger, zeroconf routes and session task
once; spotify_apply() (at boot and on every source save) enables or
disables: enabled = mode spotify/auto + credentials. Disable removes
the mDNS entry, zeroconf answers 404 and a running session ends
(loop exits within 200 ms, SpircHandler::disconnect stops the queue
and player tasks). A new device name ends the session and
re-advertises. The login blob is swapped under a mutex.
- Verified: hls/spotify/rename switch the mDNS entry and /spotify_info
(404/200) live; switching to hls during Spotify playback ended the
session in < 2 s, HLS played after ~5 s, internal heap 221 -> 365 KB
(cspot frees everything).
- CLAUDE.md: OTA-with-session findings (intermittent, flash-write stalls
seen as TX resyncs during uploads), serial-port reset caveat.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An upload during an active Spotify session crawled (~10 kB/s, 197 s)
and ended in a reset (seen twice). Workaround:
- aes67_ota_on_update(cb): cb(true) right before an accepted upload
writes flash, cb(false) if it then fails.
- The player pauses Spotify (spotify_pause -> SpircHandler::setPause, the
app follows) and suspends HLS fetching (hls_suspend); resumed if the
upload fails. AES67 TX keeps running (silence).
- Verified: upload during Spotify playback paused the session and
installed in 25.8 s (normal for 2 MB), clean reboot and self-test.
Root cause still open (noted in CLAUDE.md with the other cspot items).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- player: volume 0..100 %, amplitude = (v/100)^3 (about -18 dB at 50 %,
0 = mute), applied in the TX pull callback after the ring so a change
is heard at once (the ring holds 4 s); ramped over one packet to avoid
zipper noise. Spotify VOLUME events (0..65535) set it.
- Docs: pipeline order ring -> volume/gain, curve described.
- Verified from a Mac: RMS followed the slider immediately (100 % about
-10 dBFS; steps to about -24.5 and -28 dBFS match the cubic curve for
~57 % / ~50 %); user: "sounds about right". Mute at 0 % not yet
confirmed (check via /api/player/volume later).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- spotify_init(pcm_cb, event_cb): cspot's data callback hands 44.1 kHz
stereo s16 PCM to the player and returns what was taken; the player's
blocking ring write paces decoding to playback speed.
- Events: FLUSH/SEEK/PLAYBACK_START empty the ring (skip/seek sound at
once); PLAY_PAUSE pauses output (silence, buffer kept, no underrun);
VOLUME logged (applied in b4).
- player: Spotify via its own audio_out converter (44.1 -> 48 kHz);
source_state playing / paused / buffering.
- Verified from a Mac: music on the AES67 stream (10 s: 0 gaps, no
silent packets, RMS -9.6 dBFS, peak 0.0 dBFS), buffer 4.0 s full,
0 underruns, pause/play/skip/seek events.
Seen: first 3 connects "Can't connect to spotify servers", 4th worked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- main/audio_out: one converter instance per source (own rate converter
state): s16 any rate/channels -> 48 kHz stereo int32 -> ring.
- player_write(src, ...) only writes for the active source, drops the
rest; player_src_t is public in player.h.
- HLS decoder uses audio_out (no behaviour change).
- Verified: HLS still sample exact (441344 -> 480375 frames per
10.008 s segment), buffer ~4 s, 0 underruns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Zeroconf on our httpd (GET/POST /spotify_info, form body URL-decoded)
and _spotify-connect._tcp via our mDNS (VERSION, CPath, Stack TXT).
- Session task: waits for the app's login blob, connects with the
user's client ID/secret and the configured bitrate (96/160/320 ->
OGG_VORBIS_*), SpircHandler, events logged; cspot exceptions caught.
Starts when source.mode is spotify/auto and credentials are set.
- bell::bellGlobalLogger was NULL: every CSPOT_LOG crashed the board
right after addUser (Load access fault in Session.cpp:67 /
LoginBlob.cpp:58). cspot/bell logs now go to esp_log (UART + syslog);
signed CDN URL tokens are cut from the log.
- esp_audio_codec's own Vorbis decoder clashed with bell's Tremor
symbols: CONFIG_AUDIO_DECODER_VORBIS_SUPPORT / SIMPLE_DEC_OGG off.
- status.spotify_state from the session (waiting / connecting /
connected / login failed / error / disabled / no client credentials).
- Verified from a Mac: device appears, "connected as <user>", access
token fetched with the user's client credentials, track info, audio
key, CDN URL (the step broken upstream), PCM decoded. Without
backpressure a track decodes in ~26 s (b3 feeds the ring).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Core config: cfg_mark_secret(group, key). GET /api/config returns ""
for secret keys; a POST with "" keeps the stored value, null clears it;
cfg_get() returns the real value. Documented in aes67-core-base.md.
- source.spotify_client_id / spotify_client_secret (secret write-only):
each user's own Spotify developer app, needed by the maintained cspot
fork (philippe44/cspot) since Spotify's 2025 API restrictions.
- UI: client ID field, password field for the secret ("leave empty to
keep"), link to developer.spotify.com; enabled for spotify/auto modes.
- status.spotify_state: "no client credentials" while either is missing.
- Verified: secret never appears in GET; UI-style re-save keeps it;
survives reboot; null clears it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HLS plays on AES67; list what to come back to (clock drift vs PTP,
download speed, untested cases) before moving on to cspot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- decoder: s16 PCM -> stereo int32 -> esp_ae_rate_cvt 44.1 -> 48 kHz
(32-bit, complexity 3; bypassed at 48 kHz; mono duplicated) -> ring.
esp_audio_effects pinned to ~1.3.0 (1.4+ needs P4 rev >= 3).
- hls: each segment is downloaded completely into PSRAM (max 4 MB), then
decoded; the connection is not held open while the decoder waits for
ring space at playback speed.
- player: player_write() blocks while the ring is full and gives up when
the source changes; the temporary 440 Hz producer is removed.
- Verified with Triple J Hottest: 441344 -> 480375 frames (10.008 s) and
440320 -> 479260 (9.985 s) per segment; RTP 15000 packets, 0 gaps,
peak -10 dBFS / RMS -22 dBFS; ring ~4.0 s, 0 underruns over ~50 s;
heap 422 KB, PSRAM 27.5 MB free.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- main/decoder: MPEG-TS demux (packets reassembled across HTTP chunks,
PAT -> PMT -> first ADTS-AAC PID, PES headers stripped) feeding the
esp_audio_codec simple AAC decoder (ADTS, AAC-Plus enabled for HE-AAC
v1/v2 variants). 7.3 only counts and logs PCM per segment.
- esp_audio_codec pinned to ~2.5.0: 2.6+ needs P4 rev >= 3 (this board
is rev 1.3). Noted in CLAUDE.md, also for esp_audio_effects < 1.4.
- The library's combined TS decoder lost ~8% of the frames (segments
decoded to 7.9-9.6 s, "decode error -1"); with the own demux every
segment is sample exact: 441344 / 440320 frames = 10.008 / 9.985 s,
matching EXTINF 10.0078 / 9.9846 (431 / 430 AAC frames), no errors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- main/hls: task active while source.mode is hls/auto and hls_url is
set. esp_http_client over HTTPS (IDF certificate bundle), redirects
followed (max 5), streamed reads in 4 KB chunks to a sink callback.
- Master playlist: highest BANDWIDTH variant; URL resolution for
absolute, host-relative and path-relative references; CRLF tolerant.
- Media playlist: TARGETDURATION, MEDIA-SEQUENCE, segments; start 3
segments behind the live edge, fetch each new one in order, skip ahead
if the window moved past us, reload after target/2 when nothing is new.
- Verified with Triple J Hottest (ABC, Akamai): 252 kbit/s AAC-LC
variant chosen, 3 back-fill segments then one new ~10 s segment at a
time, ~303 KB in ~1.25 s each; heap 439 KB, PSRAM 31.9 MB free.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- main/audio_ring: SPSC ring of interleaved int32 frames in PSRAM
(4 s at 48 kHz stereo), lock-free with acquire/release counters; the
TX pull callback never blocks.
- main/player: source selection from source.mode and the pull callback
for aes67_tx. tone -> the core's PTP-phased 1 kHz tone; off -> silence;
hls/spotify -> ring with 1 s prefill (silence while buffering is not an
underrun; running dry is, and prefills again). Status fields
active_source, source_state, spotify_state, buffer_ms. source config
applies live.
- New source.mode "tone" (validation, UI dropdown, doc) for commissioning.
- Temporary 440 Hz producer in hls mode (until the HLS player exists).
- Verified: tone 999.7 Hz -18 dBFS; off silent; hls 440.0 Hz with max
sample step 60816 (ideal sine 60825, i.e. no discontinuities), buffer
3983 ms, 0 underruns; spotify silent/not implemented.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No official Tailscale client exists for ESP32: evaluate a Tailscale
subnet router on the LAN (no firmware change) or WireGuard on the device.
API authentication is a prerequisite before remote exposure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Planned SNTP client: servers as names or IPs, several allowed, resolved
via DNS. Seeds the PTP clock with real time before becoming GM (today it
starts at 1970) and gives syslog timestamps. Added to the phase 2 list
in CLAUDE.md and as a section in aes67-core-base.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- As TimeReceiver in ptp.mode hybrid: Delay_Req unicast to the GM (IP
from its Announce, MAC recorded by the RX hook from its Sync frames),
unicastFlag set.
- As TimeTransmitter: a Delay_Req with the unicastFlag gets a unicast
Delay_Resp; multicast requests get multicast replies.
- EMAC timestamps every received frame (en_ts4all): its PTP filter left
unicast Delay_Req unstamped ("no HW RX timestamp").
- A unicast Delay_Resp's logMessageInterval 0x7F is ignored (use
ptp.log_delay_req); log_us() clamps to -7..6 (the shift was undefined).
- Verified vs ptp4l --hybrid_e2e 1: board hybrid TimeReceiver 18
Delay_Req in 20 s, all answered, locked; board TimeTransmitter
(p1 100): tcpdump shows Sync/Follow_Up/Announce to 224.0.1.129,
Delay_Req 192.168.192.233 -> .244 [unicast] and Delay_Resp .244 ->
.233 [unicast] within 0.4 ms; ptp4l s2.
- Docs: TimeTransmitter/hybrid notes; step 6 ticked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- aes67_cfg: cfg_set_number(group, key, value, apply) for firmware-side
changes, stored in NVS like a POST; apply can be skipped.
- aes67_sdp_sap: once a second, a change of the PTP GM (ts-refclk) bumps
aes67.session_ver without re-applying the aes67 group (no stream
restart); SAP then re-announces. Runs whether or not SAP is on.
- Verified with ptp4l as a normal clock (p1 128): board auto (p1 250)
steps down to SLAVE (session_ver 1->2); role master p1 100 via API ->
board MASTER at once (->3); back to auto p1 250 -> ptp4l takes over
within ~1 s, board SLAVE again (->4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Own dataset from config: slave -> class 255, never TimeTransmitter;
auto/master -> class 248 with ptp.priority1/2; accuracy 0xFE, variance
0xFFFF, timeSource 0xA0, clockIdentity EUI-64 from MAC.
- BMCA: a foreign TimeTransmitter is followed only if its Announce beats
our dataset (always for slave-only); if the followed one turns worse we
take over; a better one makes us step down. LISTENING -> MASTER after
announceReceiptTimeout x our announce interval.
- MASTER: Announce (PTP timescale flag) every 2^log_announce, two-step
Sync every 2^log_sync (raw frame, HW TX time in Follow_Up), Delay_Resp
for each Delay_Req with its HW RX time and logMessageInterval =
log_delay_req. Frequency correction kept (holdover).
- ptp config applies live (role, priorities, intervals, domain, DSCP).
- status.ptp: state MASTER, gm_id = own, TX Sync/Announce intervals,
Delay_Req/Resp counters; locked is true as master so AES67 TX keeps
running; SDP ts-refclk and SAP follow.
- Verified: with ptp4l GM p1 128 the board (auto, p1 250) stays SLAVE;
ptp4l stopped -> board MASTER; ptp4l -s --priority1 255 locks to it
(s2) in ~4 s, offset within +-320 ns, path delay 10.3 us.
Known: Sync send times jitter ~10 ms (FreeRTOS tick); accuracy is not
affected (two-step Follow_Up carries the exact HW time).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the default 10, PTP (2), AES67 TX, SAP, syslog and httpd's
listen/control sockets left ~3 for web clients although httpd allows 7.
A browser's keep-alive connections then made new requests fail with a
reset for seconds at a time (the intermittent 'outages').
Reproduced: with 1 idle connection held, new requests were reset.
After: 6 idle connections held, new requests answered in 6-7 ms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SAP, syslog and health/temperatures are done and verified. The VLAN
split needs a tagged VLAN with DHCP on the switch and is only needed
for the internet sources, so it moves to a phase 2 section. Step 4
notes what is still open (Riedel import, Wireshark).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- aes67_health: status.temps = [{name, c, min_c, max_c, warn_c}]
(0.1 °C), sampled every 2 s by a health task. On-die "SoC" sensor via
driver/temperature_sensor.h, range 20..100 °C, warn at 85 °C.
health_temp_register(name, read_cb, warn_c) for board sensors.
Warning logged when crossing warn_c, cleared below warn_c - 5 °C.
- Verified: SoC 26-28 °C at idle with min/max since boot; with a
temporary 27 °C threshold the warning was logged and arrived via
syslog as <132>, status carried warn_c 27.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
httpd_txrx warned on every client reset (errno 104) and httpd_uri on
every 404 (the UI polls /api/player every 2 s until step 7), flooding
syslog. Both set to error level; our handlers log the 4xx that matter.
Remaining known line: 'httpd_resp_send_err: error calling setsockopt :
22' (error level) when a client closes right after a 4xx; a harmless
race in IDF's CONFIG_HTTPD_ERR_RESP_NO_DELAY handling, the response is
already delivered.
Verified via syslog: 10 status + 10 /api/player requests, no httpd lines.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a clock step, status.ptp showed the whole step (~1.79e18 ns) as
offset_ns until the next Sync, and the 60 s summary reported it as
max |offset|. Offset is reset to 0 and the summary restarts on a step.
Verified: status goes 0 -> pull-in values; first summary max 24960 ns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- aes67_syslog: esp_log_set_vprintf hook chained to the UART; formats
into a static buffer under a zero-timeout mutex (drop and count when
busy), strips ANSI codes, parses level/tag, filters by log.level and
queues (48 messages, non-blocking). Low-priority sender task: RFC 5424
(<PRI>1 - HOST APP - - - MSG, no timestamp yet) or RFC 3164
(<PRI>HOST TAG: MSG), PRI = facility*8 + severity; log.host as IP or
resolved name; config applies live. Messages wait in the queue until
the interface has an IP. POST /api/log/test sends an info message
regardless of the level filter.
- Initialised first in app_main so boot messages are captured; the UDP
socket is created only once the interface is up (creating it before
esp_netif_init crashed at boot and the bootloader rolled back).
- Verified with a UDP listener: 5424 and 3164 formats, PRI 134/132,
boot messages delivered after the IP came up, shutdown messages sent
before reboot, level filter, test message, host by DNS name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- aes67_sdp_sap: SAP (RFC 2974) announcer task. SAPv1 to
239.255.255.255:9875 every 30 s and within 1 s of any SDP change
(config save, GM change), with the old hash deleted first. Active while
aes67.discovery = sap, the stream is enabled, the AES67 interface has
an IP and a PTP GM is known. Deletion when that stops and at shutdown
(reboot/OTA) via a shutdown handler. TTL = aes67.ttl.
- Docs: SAP implementation notes; session_ver bump on GM change still open.
- Verified with a SAP listener: 30 s repeats with a stable hash; rename
-> delete old + announce new (o= version follows); manual -> delete;
sap -> announce; reboot and OTA -> delete at shutdown, first announce
after boot only once the GM is known.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The periodic TX timer had an arbitrary phase against packet boundaries,
so packets waited up to one packet time (different after every restart).
- One-shot esp_timer aimed at the next packet's due time from PTP, with
the clock re-read after sending.
- 1 kHz tone from a per-rate lookup table (one period = rate/1000
samples) instead of sinf() per sample.
Measured (RTP time - arrival, 4 ms vs 1 ms packets, expected -3.07 ms
incl. wire time): -3.3 ms extra before, now -3.14 ms. Tone within
1.0 LSB of the ideal PTP-phased sine, no gaps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>