From 0aac7bc3390087b59b2179b2561e3e0aec954749 Mon Sep 17 00:00:00 2001 From: Ben Nicholson Date: Fri, 25 Sep 2026 19:43:16 +1000 Subject: [PATCH] Spotify follows source mode, name and credentials live - 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 --- CLAUDE.md | 4 +- components/spotify/include/spotify.h | 7 +- components/spotify/spotify.cpp | 112 ++++++++++++++++++++------- main/player.c | 3 + 4 files changed, 92 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e3baee3..9ef6bca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,10 +60,10 @@ Repo: https://gitea.apointless.space/bsncubed/aes67-ESP32-P4 - Not yet tested: HE-AAC variant (140k), other stations, fMP4/ADTS-only playlists, discontinuities (#EXT-X-DISCONTINUITY), network loss and recovery, long runs. - Audio starts only after PTP lock (~20 s after boot): intended, TX needs PTP. - [ ] cspot (Spotify Connect): login, audio, pause/skip/seek and app volume work (7.5b). Open: - - OTA during an active Spotify session made the upload crawl (~10 kB/s) and ended in a reset; worked around by pausing Spotify / suspending HLS for the upload (aes67_ota_on_update). Root cause not found (suspect: flash writes stall PSRAM-heavy tasks). Watch for similar stalls. + - OTA during an active Spotify session made the upload crawl (~10 kB/s) and ended in a reset. With Spotify paused for the upload (aes67_ota_on_update) 2 of 3 later tries worked, 1 still failed: intermittent, root cause not found. Serial log of a good upload: flash writes stall the TX task > 20 ms about 20x/s (509 'aes67_tx: resync' in 26 s, stream stutters during OTA; none during normal playback). Likely the same stalls hit cspot's network/TLS tasks. Ideas: end the Spotify session (not just pause) for OTA; rate-limit the resync warning. + - Opening /dev/ttyACM0 resets the board even with DTR/RTS held low: start serial captures before setting up a test. - First connects sometimes fail ("Can't connect to spotify servers"), a retry works. - Internal heap drops from ~408 KB to ~232 KB with a session; check what can move to PSRAM. - - source.mode changes only start/stop Spotify at boot; in hls mode a running session's audio is dropped (it races through the queue). - Mute at 0 % volume not yet confirmed. - [ ] failover (auto mode) + /api/player - [ ] 8. Mono sum, gain, polish. diff --git a/components/spotify/include/spotify.h b/components/spotify/include/spotify.h index cf76032..308fa31 100644 --- a/components/spotify/include/spotify.h +++ b/components/spotify/include/spotify.h @@ -22,9 +22,12 @@ typedef enum { typedef size_t (*spotify_pcm_cb_t)(const int16_t *pcm, size_t frames); typedef void (*spotify_event_cb_t)(spotify_event_t ev, int value); -// Registers the zeroconf endpoints (/spotify_info) and, when source.mode is spotify/auto and the -// client credentials are set, advertises the device and starts the session task. +// Registers the zeroconf endpoints (/spotify_info), starts the session task and applies the config. esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event); +// Re-read the "source" config: enabled (advertised, sessions accepted) when mode is spotify/auto and +// the client credentials are set; otherwise not advertised and a running session is ended. +// A new device name ends the session and re-advertises. +void spotify_apply(void); // Pause/resume the running session (the Spotify app follows). No-op without a session. void spotify_pause(bool pause); // Current state for status.spotify_state. diff --git a/components/spotify/spotify.cpp b/components/spotify/spotify.cpp index d1dc881..ecaa47e 100644 --- a/components/spotify/spotify.cpp +++ b/components/spotify/spotify.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "BellLogger.h" @@ -26,7 +27,11 @@ extern "C" { static const char *TAG = "spotify"; -static std::shared_ptr s_blob; +static std::shared_ptr s_blob; // guarded by s_mutex +static std::mutex s_mutex; +static std::string s_name; // advertised device name ("" = not advertised) +static std::atomic s_enabled{false}; +static std::atomic s_stop{false}; // end the running session static std::atomic s_got_blob{false}; static std::atomic s_state{"disabled"}; static std::atomic s_pcm_bytes{0}; @@ -93,9 +98,19 @@ public: /* ----- zeroconf (Spotify Connect discovery) on our httpd ----- */ +static std::shared_ptr blob() +{ + std::lock_guard lock(s_mutex); + return s_blob; +} + static esp_err_t info_get(httpd_req_t *req) { - std::string json = s_blob->buildZeroconfInfo(); + auto b = blob(); + if (!s_enabled || !b) { + return httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Spotify Connect is disabled"); + } + std::string json = b->buildZeroconfInfo(); httpd_resp_set_type(req, "application/json"); return httpd_resp_send(req, json.data(), json.size()); } @@ -120,6 +135,10 @@ static std::string url_decode(const char *s, size_t n) // The Spotify app posts its login blob (action=addUser&userName=...&blob=...&clientKey=...). static esp_err_t info_post(httpd_req_t *req) { + auto b = blob(); + if (!s_enabled || !b) { + return httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Spotify Connect is disabled"); + } if (req->content_len == 0 || req->content_len > BODY_MAX) { return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "bad body"); } @@ -143,7 +162,7 @@ static esp_err_t info_post(httpd_req_t *req) pos = end + 1; } ESP_LOGI(TAG, "zeroconf %s from \"%s\"", query["action"].c_str(), query["userName"].c_str()); - s_blob->loadZeroconfQuery(query); + b->loadZeroconfQuery(query); s_got_blob = true; static const char ok[] = "{\"status\":101,\"spotifyError\":0,\"statusString\":\"ERROR-OK\"}"; httpd_resp_set_type(req, "application/json"); @@ -178,14 +197,15 @@ static void session(void) int bitrate = (int)cJSON_GetObjectItemCaseSensitive(src, "spotify_bitrate")->valuedouble; cJSON_Delete(src); - auto ctx = cspot::Context::createFromBlob(s_blob); + auto login = blob(); + auto ctx = cspot::Context::createFromBlob(login); ctx->config.clientId = client_id; ctx->config.clientSecret = client_secret; ctx->config.audioFormat = bitrate >= 320 ? AudioFormat_OGG_VORBIS_320 : bitrate >= 160 ? AudioFormat_OGG_VORBIS_160 : AudioFormat_OGG_VORBIS_96; s_state = "connecting"; ctx->session->connectWithRandomAp(); - auto token = ctx->session->authenticate(s_blob); + auto token = ctx->session->authenticate(login); if (token.empty()) { s_state = "login failed"; ESP_LOGE(TAG, "login failed (check the client ID/secret and that the account is Premium)"); @@ -238,19 +258,26 @@ static void session(void) s_handler = handler; s_state = "connected"; ESP_LOGI(TAG, "connected as %s", ctx->config.username.c_str()); - while (true) { - ctx->session->handlePacket(); + while (!s_stop) { + ctx->session->handlePacket(); // waits at most 200 ms } + // Stops the queue and player tasks (waits for them), then closes the connection. + s_handler.reset(); + handler->disconnect(); + ESP_LOGI(TAG, "session ended"); } static void spotify_task(void *arg) { while (true) { - s_state = "waiting for Spotify app"; - while (!s_got_blob) { + while (!s_enabled || !s_got_blob) { + if (s_enabled) { + s_state = "waiting for Spotify app"; + } vTaskDelay(pdMS_TO_TICKS(500)); } s_got_blob = false; + s_stop = false; try { session(); } catch (const std::exception &e) { @@ -262,6 +289,47 @@ static void spotify_task(void *arg) } } +extern "C" void spotify_apply(void) +{ + cJSON *src = cfg_get("source"); + std::string mode = cJSON_GetObjectItemCaseSensitive(src, "mode")->valuestring; + std::string name = cJSON_GetObjectItemCaseSensitive(src, "spotify_name")->valuestring; + bool creds = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_id")->valuestring[0] && + cJSON_GetObjectItemCaseSensitive(src, "spotify_client_secret")->valuestring[0]; + cJSON_Delete(src); + bool want = (mode == "spotify" || mode == "auto") && creds; + + if (!want || name != s_name) { + // Disable, or re-advertise under a new name: end any session, drop the old entry. + s_enabled = false; + s_stop = true; + s_got_blob = false; + if (!s_name.empty()) { + mdns_service_remove("_spotify-connect", "_tcp"); + ESP_LOGI(TAG, "Spotify Connect \"%s\" withdrawn", s_name.c_str()); + s_name.clear(); + } + } + if (!want) { + s_state = !creds ? "no client credentials" : "disabled (source mode)"; + if (!creds && (mode == "spotify" || mode == "auto")) { + ESP_LOGW(TAG, "not started: set the Spotify client ID and secret (web UI, Source)"); + } + return; + } + if (s_name.empty()) { + { + std::lock_guard lock(s_mutex); + s_blob = std::make_shared(name); + } + mdns_txt_item_t txt[] = { { "VERSION", "1.0" }, { "CPath", "/spotify_info" }, { "Stack", "SP" } }; + esp_err_t err = mdns_service_add(name.c_str(), "_spotify-connect", "_tcp", 80, txt, 3); + s_name = name; + ESP_LOGI(TAG, "Spotify Connect \"%s\" advertised (%s)", name.c_str(), esp_err_to_name(err)); + } + s_enabled = true; +} + extern "C" esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event) { s_pcm_cb = pcm; @@ -269,29 +337,13 @@ extern "C" esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event if (!bell::bellGlobalLogger) { bell::bellGlobalLogger = new EspLogger(); } - cJSON *src = cfg_get("source"); - std::string mode = cJSON_GetObjectItemCaseSensitive(src, "mode")->valuestring; - std::string name = cJSON_GetObjectItemCaseSensitive(src, "spotify_name")->valuestring; - bool creds = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_id")->valuestring[0] && - cJSON_GetObjectItemCaseSensitive(src, "spotify_client_secret")->valuestring[0]; - cJSON_Delete(src); - - if (mode != "spotify" && mode != "auto") { - s_state = "disabled (source mode)"; - return ESP_OK; - } - if (!creds) { - s_state = "no client credentials"; - ESP_LOGW(TAG, "not started: set the Spotify client ID and secret (web UI, Source)"); - return ESP_OK; - } - s_blob = std::make_shared(name); static const httpd_uri_t get = { .uri = "/spotify_info", .method = HTTP_GET, .handler = info_get, .user_ctx = nullptr }; static const httpd_uri_t post = { .uri = "/spotify_info", .method = HTTP_POST, .handler = info_post, .user_ctx = nullptr }; web_register_uri(&get); web_register_uri(&post); - mdns_txt_item_t txt[] = { { "VERSION", "1.0" }, { "CPath", "/spotify_info" }, { "Stack", "SP" } }; - esp_err_t err = mdns_service_add(name.c_str(), "_spotify-connect", "_tcp", 80, txt, 3); - ESP_LOGI(TAG, "Spotify Connect \"%s\" advertised (%s)", name.c_str(), esp_err_to_name(err)); - return xTaskCreate(spotify_task, "spotify", 32 * 1024, nullptr, 5, nullptr) == pdPASS ? ESP_OK : ESP_ERR_NO_MEM; + if (xTaskCreate(spotify_task, "spotify", 32 * 1024, nullptr, 5, nullptr) != pdPASS) { + return ESP_ERR_NO_MEM; + } + spotify_apply(); + return ESP_OK; } diff --git a/main/player.c b/main/player.c index e11b1cf..43df7ae 100644 --- a/main/player.c +++ b/main/player.c @@ -95,6 +95,9 @@ static size_t player_read(int32_t *buf, size_t frames) void player_apply(const cJSON *source) { + if (s_spotify_conv) { // after player_init: Spotify follows mode / name / credentials live + spotify_apply(); + } player_src_t src = mode_of(cJSON_GetObjectItemCaseSensitive(source, "mode")->valuestring); if (src == s_src) { return;