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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
- 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.
|
- 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:
|
- [ ] 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.
|
- 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.
|
- 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.
|
- Mute at 0 % volume not yet confirmed.
|
||||||
- [ ] failover (auto mode) + /api/player
|
- [ ] failover (auto mode) + /api/player
|
||||||
- [ ] 8. Mono sum, gain, polish.
|
- [ ] 8. Mono sum, gain, polish.
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ typedef enum {
|
|||||||
typedef size_t (*spotify_pcm_cb_t)(const int16_t *pcm, size_t frames);
|
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);
|
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
|
// Registers the zeroconf endpoints (/spotify_info), starts the session task and applies the config.
|
||||||
// client credentials are set, advertises the device and starts the session task.
|
|
||||||
esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event);
|
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.
|
// Pause/resume the running session (the Spotify app follows). No-op without a session.
|
||||||
void spotify_pause(bool pause);
|
void spotify_pause(bool pause);
|
||||||
// Current state for status.spotify_state.
|
// Current state for status.spotify_state.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <exception>
|
#include <exception>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "BellLogger.h"
|
#include "BellLogger.h"
|
||||||
@@ -26,7 +27,11 @@ extern "C" {
|
|||||||
|
|
||||||
static const char *TAG = "spotify";
|
static const char *TAG = "spotify";
|
||||||
|
|
||||||
static std::shared_ptr<cspot::LoginBlob> s_blob;
|
static std::shared_ptr<cspot::LoginBlob> s_blob; // guarded by s_mutex
|
||||||
|
static std::mutex s_mutex;
|
||||||
|
static std::string s_name; // advertised device name ("" = not advertised)
|
||||||
|
static std::atomic<bool> s_enabled{false};
|
||||||
|
static std::atomic<bool> s_stop{false}; // end the running session
|
||||||
static std::atomic<bool> s_got_blob{false};
|
static std::atomic<bool> s_got_blob{false};
|
||||||
static std::atomic<const char *> s_state{"disabled"};
|
static std::atomic<const char *> s_state{"disabled"};
|
||||||
static std::atomic<uint64_t> s_pcm_bytes{0};
|
static std::atomic<uint64_t> s_pcm_bytes{0};
|
||||||
@@ -93,9 +98,19 @@ public:
|
|||||||
|
|
||||||
/* ----- zeroconf (Spotify Connect discovery) on our httpd ----- */
|
/* ----- zeroconf (Spotify Connect discovery) on our httpd ----- */
|
||||||
|
|
||||||
|
static std::shared_ptr<cspot::LoginBlob> blob()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(s_mutex);
|
||||||
|
return s_blob;
|
||||||
|
}
|
||||||
|
|
||||||
static esp_err_t info_get(httpd_req_t *req)
|
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");
|
httpd_resp_set_type(req, "application/json");
|
||||||
return httpd_resp_send(req, json.data(), json.size());
|
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=...).
|
// The Spotify app posts its login blob (action=addUser&userName=...&blob=...&clientKey=...).
|
||||||
static esp_err_t info_post(httpd_req_t *req)
|
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) {
|
if (req->content_len == 0 || req->content_len > BODY_MAX) {
|
||||||
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "bad body");
|
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;
|
pos = end + 1;
|
||||||
}
|
}
|
||||||
ESP_LOGI(TAG, "zeroconf %s from \"%s\"", query["action"].c_str(), query["userName"].c_str());
|
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;
|
s_got_blob = true;
|
||||||
static const char ok[] = "{\"status\":101,\"spotifyError\":0,\"statusString\":\"ERROR-OK\"}";
|
static const char ok[] = "{\"status\":101,\"spotifyError\":0,\"statusString\":\"ERROR-OK\"}";
|
||||||
httpd_resp_set_type(req, "application/json");
|
httpd_resp_set_type(req, "application/json");
|
||||||
@@ -178,14 +197,15 @@ static void session(void)
|
|||||||
int bitrate = (int)cJSON_GetObjectItemCaseSensitive(src, "spotify_bitrate")->valuedouble;
|
int bitrate = (int)cJSON_GetObjectItemCaseSensitive(src, "spotify_bitrate")->valuedouble;
|
||||||
cJSON_Delete(src);
|
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.clientId = client_id;
|
||||||
ctx->config.clientSecret = client_secret;
|
ctx->config.clientSecret = client_secret;
|
||||||
ctx->config.audioFormat = bitrate >= 320 ? AudioFormat_OGG_VORBIS_320
|
ctx->config.audioFormat = bitrate >= 320 ? AudioFormat_OGG_VORBIS_320
|
||||||
: bitrate >= 160 ? AudioFormat_OGG_VORBIS_160 : AudioFormat_OGG_VORBIS_96;
|
: bitrate >= 160 ? AudioFormat_OGG_VORBIS_160 : AudioFormat_OGG_VORBIS_96;
|
||||||
s_state = "connecting";
|
s_state = "connecting";
|
||||||
ctx->session->connectWithRandomAp();
|
ctx->session->connectWithRandomAp();
|
||||||
auto token = ctx->session->authenticate(s_blob);
|
auto token = ctx->session->authenticate(login);
|
||||||
if (token.empty()) {
|
if (token.empty()) {
|
||||||
s_state = "login failed";
|
s_state = "login failed";
|
||||||
ESP_LOGE(TAG, "login failed (check the client ID/secret and that the account is Premium)");
|
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_handler = handler;
|
||||||
s_state = "connected";
|
s_state = "connected";
|
||||||
ESP_LOGI(TAG, "connected as %s", ctx->config.username.c_str());
|
ESP_LOGI(TAG, "connected as %s", ctx->config.username.c_str());
|
||||||
while (true) {
|
while (!s_stop) {
|
||||||
ctx->session->handlePacket();
|
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)
|
static void spotify_task(void *arg)
|
||||||
{
|
{
|
||||||
while (true) {
|
while (true) {
|
||||||
|
while (!s_enabled || !s_got_blob) {
|
||||||
|
if (s_enabled) {
|
||||||
s_state = "waiting for Spotify app";
|
s_state = "waiting for Spotify app";
|
||||||
while (!s_got_blob) {
|
}
|
||||||
vTaskDelay(pdMS_TO_TICKS(500));
|
vTaskDelay(pdMS_TO_TICKS(500));
|
||||||
}
|
}
|
||||||
s_got_blob = false;
|
s_got_blob = false;
|
||||||
|
s_stop = false;
|
||||||
try {
|
try {
|
||||||
session();
|
session();
|
||||||
} catch (const std::exception &e) {
|
} 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<std::mutex> lock(s_mutex);
|
||||||
|
s_blob = std::make_shared<cspot::LoginBlob>(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)
|
extern "C" esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event)
|
||||||
{
|
{
|
||||||
s_pcm_cb = pcm;
|
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) {
|
if (!bell::bellGlobalLogger) {
|
||||||
bell::bellGlobalLogger = new EspLogger();
|
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<cspot::LoginBlob>(name);
|
|
||||||
static const httpd_uri_t get = { .uri = "/spotify_info", .method = HTTP_GET, .handler = info_get, .user_ctx = nullptr };
|
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 };
|
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(&get);
|
||||||
web_register_uri(&post);
|
web_register_uri(&post);
|
||||||
mdns_txt_item_t txt[] = { { "VERSION", "1.0" }, { "CPath", "/spotify_info" }, { "Stack", "SP" } };
|
if (xTaskCreate(spotify_task, "spotify", 32 * 1024, nullptr, 5, nullptr) != pdPASS) {
|
||||||
esp_err_t err = mdns_service_add(name.c_str(), "_spotify-connect", "_tcp", 80, txt, 3);
|
return ESP_ERR_NO_MEM;
|
||||||
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;
|
spotify_apply();
|
||||||
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ static size_t player_read(int32_t *buf, size_t frames)
|
|||||||
|
|
||||||
void player_apply(const cJSON *source)
|
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);
|
player_src_t src = mode_of(cJSON_GetObjectItemCaseSensitive(source, "mode")->valuestring);
|
||||||
if (src == s_src) {
|
if (src == s_src) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user