From c240ac9a3cba4b8721121f8ba056c3bb08db2c7d Mon Sep 17 00:00:00 2001 From: Ben Nicholson Date: Fri, 25 Sep 2026 18:26:36 +1000 Subject: [PATCH] Step 7.5b2: Spotify Connect zeroconf and login work (PCM counted only) - 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 ", 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 --- components/spotify/CMakeLists.txt | 2 +- components/spotify/include/spotify.h | 6 +- components/spotify/spotify.cpp | 248 ++++++++++++++++++++++++++- main/player.c | 10 +- sdkconfig.defaults | 5 + 5 files changed, 255 insertions(+), 16 deletions(-) diff --git a/components/spotify/CMakeLists.txt b/components/spotify/CMakeLists.txt index f804fb2..7bcd2ce 100644 --- a/components/spotify/CMakeLists.txt +++ b/components/spotify/CMakeLists.txt @@ -2,7 +2,7 @@ # Project component (not part of the reusable aes67_* core). idf_component_register(SRCS "spotify.cpp" INCLUDE_DIRS "include" - PRIV_REQUIRES espressif__mdns mbedtls json pthread lwip esp_timer) + PRIV_REQUIRES aes67_web espressif__mdns mbedtls json pthread lwip esp_timer esp_http_server) # bell: only what cspot needs (Tremor Vorbis inside; no codec wrapper, sinks, MQTT, web server). set(BELL_DISABLE_CODECS ON CACHE BOOL "" FORCE) diff --git a/components/spotify/include/spotify.h b/components/spotify/include/spotify.h index 38be433..e42f6dd 100644 --- a/components/spotify/include/spotify.h +++ b/components/spotify/include/spotify.h @@ -7,7 +7,11 @@ extern "C" { #endif -esp_err_t spotify_init(const char *device_name); +// 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. +esp_err_t spotify_init(void); +// Current state for status.spotify_state. +const char *spotify_state(void); #ifdef __cplusplus } diff --git a/components/spotify/spotify.cpp b/components/spotify/spotify.cpp index fd2dae0..8abe502 100644 --- a/components/spotify/spotify.cpp +++ b/components/spotify/spotify.cpp @@ -1,17 +1,253 @@ #include "spotify.h" +#include +#include +#include +#include #include +#include +#include "BellLogger.h" +#include "CSpotContext.h" #include "LoginBlob.h" +#include "SpircHandler.h" +#include "TrackPlayer.h" #include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "mdns.h" + +extern "C" { +#include "aes67_cfg.h" +#include "aes67_web.h" +} + +#define BODY_MAX 8192 static const char *TAG = "spotify"; -// Step 7.5b1: link check only. Zeroconf, login and audio follow in b2/b3. -extern "C" esp_err_t spotify_init(const char *device_name) +static std::shared_ptr s_blob; +static std::atomic s_got_blob{false}; +static std::atomic s_state{"disabled"}; +static std::atomic s_pcm_bytes{0}; + +const char *spotify_state(void) { - auto blob = std::make_shared(device_name); - std::string info = blob->buildZeroconfInfo(); - ESP_LOGI(TAG, "cspot linked: device \"%s\", zeroconf info %u bytes", device_name, (unsigned)info.size()); - return ESP_OK; + return s_state.load(); +} + +/* ----- cspot/bell logging -> esp_log (UART + syslog) ----- */ + +// cspot logs through bell::bellGlobalLogger; without one, CSPOT_LOG dereferences NULL. +class EspLogger : public bell::AbstractLogger { + static void out(esp_log_level_t lvl, const std::string &file, int line, const std::string &sub, + const char *fmt, va_list ap) + { + char msg[256]; + vsnprintf(msg, sizeof(msg), fmt, ap); + // CDN URLs carry signed tokens: keep them out of the log (syslog may leave the box). + char *q = strstr(msg, "?__token__="); + if (q) { + strcpy(q, "?__token__=..."); + } + size_t slash = file.find_last_of('/'); + const char *base = file.c_str() + (slash == std::string::npos ? 0 : slash + 1); + ESP_LOG_LEVEL(lvl, sub.empty() ? "cspot" : sub.c_str(), "%s:%d: %s", base, line, msg); + } + +public: + void debug(std::string file, int line, std::string sub, const char *fmt, ...) override + { + va_list ap; + va_start(ap, fmt); + out(ESP_LOG_DEBUG, file, line, sub, fmt, ap); + va_end(ap); + } + void info(std::string file, int line, std::string sub, const char *fmt, ...) override + { + va_list ap; + va_start(ap, fmt); + out(ESP_LOG_INFO, file, line, sub, fmt, ap); + va_end(ap); + } + void error(std::string file, int line, std::string sub, const char *fmt, ...) override + { + va_list ap; + va_start(ap, fmt); + out(ESP_LOG_ERROR, file, line, sub, fmt, ap); + va_end(ap); + } +}; + +/* ----- zeroconf (Spotify Connect discovery) on our httpd ----- */ + +static esp_err_t info_get(httpd_req_t *req) +{ + std::string json = s_blob->buildZeroconfInfo(); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.data(), json.size()); +} + +static std::string url_decode(const char *s, size_t n) +{ + std::string out; + for (size_t i = 0; i < n; i++) { + if (s[i] == '+') { + out += ' '; + } else if (s[i] == '%' && i + 2 < n && isxdigit((unsigned char)s[i + 1]) && isxdigit((unsigned char)s[i + 2])) { + char hex[3] = { s[i + 1], s[i + 2], 0 }; + out += (char)strtol(hex, nullptr, 16); + i += 2; + } else { + out += s[i]; + } + } + return out; +} + +// The Spotify app posts its login blob (action=addUser&userName=...&blob=...&clientKey=...). +static esp_err_t info_post(httpd_req_t *req) +{ + if (req->content_len == 0 || req->content_len > BODY_MAX) { + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "bad body"); + } + std::string body(req->content_len, '\0'); + size_t got = 0; + while (got < body.size()) { + int r = httpd_req_recv(req, body.data() + got, body.size() - got); + if (r <= 0) { + return ESP_FAIL; + } + got += r; + } + std::map query; + size_t pos = 0; + while (pos < body.size()) { + size_t amp = body.find('&', pos), end = amp == std::string::npos ? body.size() : amp; + size_t eq = body.find('=', pos); + if (eq != std::string::npos && eq < end) { + query[url_decode(&body[pos], eq - pos)] = url_decode(&body[eq + 1], end - eq - 1); + } + pos = end + 1; + } + ESP_LOGI(TAG, "zeroconf %s from \"%s\"", query["action"].c_str(), query["userName"].c_str()); + s_blob->loadZeroconfQuery(query); + s_got_blob = true; + static const char ok[] = "{\"status\":101,\"spotifyError\":0,\"statusString\":\"ERROR-OK\"}"; + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, ok, sizeof(ok) - 1); +} + +/* ----- session ----- */ + +static const char *event_name(cspot::SpircHandler::EventType t) +{ + using E = cspot::SpircHandler::EventType; + switch (t) { + case E::PLAY_PAUSE: return "PLAY_PAUSE"; + case E::VOLUME: return "VOLUME"; + case E::TRACK_INFO: return "TRACK_INFO"; + case E::DISC: return "DISC"; + case E::NEXT: return "NEXT"; + case E::PREV: return "PREV"; + case E::SEEK: return "SEEK"; + case E::DEPLETED: return "DEPLETED"; + case E::FLUSH: return "FLUSH"; + case E::PLAYBACK_START: return "PLAYBACK_START"; + } + return "?"; +} + +static void session(void) +{ + cJSON *src = cfg_get("source"); + std::string client_id = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_id")->valuestring; + std::string client_secret = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_secret")->valuestring; + int bitrate = (int)cJSON_GetObjectItemCaseSensitive(src, "spotify_bitrate")->valuedouble; + cJSON_Delete(src); + + auto ctx = cspot::Context::createFromBlob(s_blob); + 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); + if (token.empty()) { + s_state = "login failed"; + ESP_LOGE(TAG, "login failed (check the client ID/secret and that the account is Premium)"); + return; + } + ctx->session->startTask(); + auto handler = std::make_shared(ctx); + handler->subscribeToMercury(); + // 7.5b2: count PCM only; 7.5b3 feeds it to the AES67 ring. + handler->getTrackPlayer()->setDataCallback([](uint8_t *data, size_t len, std::string_view id) { + s_pcm_bytes += len; + return len; + }); + handler->setEventHandler([](std::unique_ptr ev) { + if (ev->eventType == cspot::SpircHandler::EventType::TRACK_INFO) { + auto &ti = std::get(ev->data); + ESP_LOGI(TAG, "track: %s - %s (%lu ms)", ti.artist.c_str(), ti.name.c_str(), (unsigned long)ti.duration); + } else { + ESP_LOGI(TAG, "event %s (PCM so far %llu bytes)", event_name(ev->eventType), s_pcm_bytes.load()); + } + }); + s_state = "connected"; + ESP_LOGI(TAG, "connected as %s", ctx->config.username.c_str()); + while (true) { + ctx->session->handlePacket(); + } +} + +static void spotify_task(void *arg) +{ + while (true) { + s_state = "waiting for Spotify app"; + while (!s_got_blob) { + vTaskDelay(pdMS_TO_TICKS(500)); + } + s_got_blob = false; + try { + session(); + } catch (const std::exception &e) { + s_state = "error"; + ESP_LOGE(TAG, "session ended: %s", e.what()); + } + vTaskDelay(pdMS_TO_TICKS(2000)); + } +} + +extern "C" esp_err_t spotify_init(void) +{ + 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 }; + static const httpd_uri_t post = { .uri = "/spotify_info", .method = HTTP_POST, .handler = info_post }; + 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; } diff --git a/main/player.c b/main/player.c index 9238a31..a780b6d 100644 --- a/main/player.c +++ b/main/player.c @@ -96,11 +96,7 @@ static void player_status(cJSON *st) s_src == SRC_SPOTIFY ? "not implemented" : s_playing ? "playing" : "buffering"; cJSON_AddStringToObject(st, "active_source", SRC_NAME[s_src]); cJSON_AddStringToObject(st, "source_state", state); - cJSON *src = cfg_get("source"); - bool creds = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_id")->valuestring[0] && - cJSON_GetObjectItemCaseSensitive(src, "spotify_client_secret")->valuestring[0]; - cJSON_Delete(src); - cJSON_AddStringToObject(st, "spotify_state", creds ? "not implemented" : "no client credentials"); + cJSON_AddStringToObject(st, "spotify_state", spotify_state()); cJSON_AddNumberToObject(st, "buffer_ms", (double)(audio_ring_level() * 1000 / RATE)); } @@ -119,9 +115,7 @@ esp_err_t player_init(void) if (err != ESP_OK) { return err; } - cJSON *sc = cfg_get("source"); - spotify_init(cJSON_GetObjectItemCaseSensitive(sc, "spotify_name")->valuestring); - cJSON_Delete(sc); + spotify_init(); err = hls_start(decoder_feed); if (err != ESP_OK) { return err; diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 9694236..befdaa3 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -31,3 +31,8 @@ CONFIG_SPIRAM=y # cspot (Spotify Connect, step 7.5) needs C++ exceptions. CONFIG_COMPILER_CXX_EXCEPTIONS=y + +# esp_audio_codec's Vorbis decoder exports the same symbols (vorbis_info_init, ...) as cspot's +# Tremor (bell): keep it out of the link. HLS only needs AAC. +# CONFIG_AUDIO_DECODER_VORBIS_SUPPORT is not set +# CONFIG_AUDIO_SIMPLE_DEC_OGG_SUPPORT is not set