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 <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>
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
# Project component (not part of the reusable aes67_* core).
|
# Project component (not part of the reusable aes67_* core).
|
||||||
idf_component_register(SRCS "spotify.cpp"
|
idf_component_register(SRCS "spotify.cpp"
|
||||||
INCLUDE_DIRS "include"
|
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).
|
# bell: only what cspot needs (Tremor Vorbis inside; no codec wrapper, sinks, MQTT, web server).
|
||||||
set(BELL_DISABLE_CODECS ON CACHE BOOL "" FORCE)
|
set(BELL_DISABLE_CODECS ON CACHE BOOL "" FORCE)
|
||||||
|
|||||||
@@ -7,7 +7,11 @@
|
|||||||
extern "C" {
|
extern "C" {
|
||||||
#endif
|
#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
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,253 @@
|
|||||||
#include "spotify.h"
|
#include "spotify.h"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstring>
|
||||||
|
#include <exception>
|
||||||
|
#include <map>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "BellLogger.h"
|
||||||
|
#include "CSpotContext.h"
|
||||||
#include "LoginBlob.h"
|
#include "LoginBlob.h"
|
||||||
|
#include "SpircHandler.h"
|
||||||
|
#include "TrackPlayer.h"
|
||||||
#include "esp_log.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";
|
static const char *TAG = "spotify";
|
||||||
|
|
||||||
// Step 7.5b1: link check only. Zeroconf, login and audio follow in b2/b3.
|
static std::shared_ptr<cspot::LoginBlob> s_blob;
|
||||||
extern "C" esp_err_t spotify_init(const char *device_name)
|
static std::atomic<bool> s_got_blob{false};
|
||||||
|
static std::atomic<const char *> s_state{"disabled"};
|
||||||
|
static std::atomic<uint64_t> s_pcm_bytes{0};
|
||||||
|
|
||||||
|
const char *spotify_state(void)
|
||||||
{
|
{
|
||||||
auto blob = std::make_shared<cspot::LoginBlob>(device_name);
|
return s_state.load();
|
||||||
std::string info = blob->buildZeroconfInfo();
|
}
|
||||||
ESP_LOGI(TAG, "cspot linked: device \"%s\", zeroconf info %u bytes", device_name, (unsigned)info.size());
|
|
||||||
|
/* ----- 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<std::string, std::string> 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<cspot::SpircHandler>(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<cspot::SpircHandler::Event> ev) {
|
||||||
|
if (ev->eventType == cspot::SpircHandler::EventType::TRACK_INFO) {
|
||||||
|
auto &ti = std::get<cspot::TrackInfo>(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;
|
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<cspot::LoginBlob>(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;
|
||||||
|
}
|
||||||
|
|||||||
+2
-8
@@ -96,11 +96,7 @@ static void player_status(cJSON *st)
|
|||||||
s_src == SRC_SPOTIFY ? "not implemented" : s_playing ? "playing" : "buffering";
|
s_src == SRC_SPOTIFY ? "not implemented" : s_playing ? "playing" : "buffering";
|
||||||
cJSON_AddStringToObject(st, "active_source", SRC_NAME[s_src]);
|
cJSON_AddStringToObject(st, "active_source", SRC_NAME[s_src]);
|
||||||
cJSON_AddStringToObject(st, "source_state", state);
|
cJSON_AddStringToObject(st, "source_state", state);
|
||||||
cJSON *src = cfg_get("source");
|
cJSON_AddStringToObject(st, "spotify_state", spotify_state());
|
||||||
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_AddNumberToObject(st, "buffer_ms", (double)(audio_ring_level() * 1000 / RATE));
|
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) {
|
if (err != ESP_OK) {
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
cJSON *sc = cfg_get("source");
|
spotify_init();
|
||||||
spotify_init(cJSON_GetObjectItemCaseSensitive(sc, "spotify_name")->valuestring);
|
|
||||||
cJSON_Delete(sc);
|
|
||||||
err = hls_start(decoder_feed);
|
err = hls_start(decoder_feed);
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
return err;
|
return err;
|
||||||
|
|||||||
@@ -31,3 +31,8 @@ CONFIG_SPIRAM=y
|
|||||||
|
|
||||||
# cspot (Spotify Connect, step 7.5) needs C++ exceptions.
|
# cspot (Spotify Connect, step 7.5) needs C++ exceptions.
|
||||||
CONFIG_COMPILER_CXX_EXCEPTIONS=y
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user