diff --git a/components/aes67_web/cfg.c b/components/aes67_web/cfg.c index c42d22f..91c5887 100644 --- a/components/aes67_web/cfg.c +++ b/components/aes67_web/cfg.c @@ -13,6 +13,7 @@ #include "nvs_flash.h" #define MAX_GROUPS 12 +#define MAX_SECRETS 4 #define MAX_BODY 8192 #define NVS_NAMESPACE "cfg" @@ -24,6 +25,8 @@ typedef struct { cJSON *values; cfg_validate_cb_t validate; cfg_apply_cb_t apply; + char secrets[MAX_SECRETS][24]; // write-only keys + int n_secrets; } cfg_group_t; static cfg_group_t s_groups[MAX_GROUPS]; @@ -220,6 +223,35 @@ esp_err_t cfg_register(const char *group, const char *defaults_json, return ESP_OK; } +esp_err_t cfg_mark_secret(const char *group, const char *key) +{ + if (!s_lock) { + return ESP_ERR_INVALID_STATE; + } + xSemaphoreTake(s_lock, portMAX_DELAY); + cfg_group_t *g = find(group); + esp_err_t err = ESP_OK; + if (!g || !cJSON_IsString(cJSON_GetObjectItemCaseSensitive(g->defaults, key))) { + err = ESP_ERR_NOT_FOUND; + } else if (g->n_secrets >= MAX_SECRETS || strlen(key) >= sizeof(g->secrets[0])) { + err = ESP_ERR_NO_MEM; + } else { + strcpy(g->secrets[g->n_secrets++], key); + } + xSemaphoreGive(s_lock); + return err; +} + +// Group values for the API: secrets blanked. +static cJSON *public_values(const cfg_group_t *g) +{ + cJSON *v = cJSON_Duplicate(g->values, true); + for (int i = 0; i < g->n_secrets; i++) { + cJSON_ReplaceItemInObjectCaseSensitive(v, g->secrets[i], cJSON_CreateString("")); + } + return v; +} + cJSON *cfg_get(const char *group) { if (!s_lock) { @@ -270,7 +302,7 @@ static esp_err_t config_get(httpd_req_t *req) cJSON *all = cJSON_CreateObject(); xSemaphoreTake(s_lock, portMAX_DELAY); for (int i = 0; i < s_group_n; i++) { - cJSON_AddItemToObject(all, s_groups[i].name, cJSON_Duplicate(s_groups[i].values, true)); + cJSON_AddItemToObject(all, s_groups[i].name, public_values(&s_groups[i])); } xSemaphoreGive(s_lock); esp_err_t err = web_send_json(req, all); @@ -331,7 +363,18 @@ static esp_err_t config_post(httpd_req_t *req) break; } cand[i] = cJSON_Duplicate(g->values, true); - ok = merge_known(cand[i], g->defaults, src, g->name, err, sizeof(err)); + // Secrets: "" = unchanged (the API never returns the stored value), null = clear. + cJSON *in_g = cJSON_Duplicate(src, true); + for (int k = 0; k < g->n_secrets; k++) { + const cJSON *sv = cJSON_GetObjectItemCaseSensitive(in_g, g->secrets[k]); + if (cJSON_IsString(sv) && !sv->valuestring[0]) { + cJSON_DeleteItemFromObjectCaseSensitive(in_g, g->secrets[k]); + } else if (cJSON_IsNull(sv)) { + cJSON_ReplaceItemInObjectCaseSensitive(in_g, g->secrets[k], cJSON_CreateString("")); + } + } + ok = merge_known(cand[i], g->defaults, in_g, g->name, err, sizeof(err)); + cJSON_Delete(in_g); if (ok && g->validate) { char verr[96] = ""; ok = g->validate(cand[i], verr, sizeof(verr)); diff --git a/components/aes67_web/include/aes67_cfg.h b/components/aes67_web/include/aes67_cfg.h index 5adfac0..c32f392 100644 --- a/components/aes67_web/include/aes67_cfg.h +++ b/components/aes67_web/include/aes67_cfg.h @@ -18,6 +18,9 @@ esp_err_t cfg_override_defaults(const char *group, const char *json); // Register a group. Stored values (NVS) are loaded immediately, merged over the defaults. esp_err_t cfg_register(const char *group, const char *defaults_json, cfg_validate_cb_t validate, cfg_apply_cb_t apply); +// Write-only key (e.g. a password): GET /api/config returns "" for it; a POST with "" keeps the +// stored value, null clears it. cfg_get() still returns the real value. Call after cfg_register(). +esp_err_t cfg_mark_secret(const char *group, const char *key); // Copy of a group's current values; caller frees with cJSON_Delete. NULL if not registered. cJSON *cfg_get(const char *group); // Firmware-side change of one number (e.g. aes67.session_ver): stored like a POST, no validation. diff --git a/docs/aes67-core-base.md b/docs/aes67-core-base.md index c22a460..8ca17cc 100644 --- a/docs/aes67-core-base.md +++ b/docs/aes67-core-base.md @@ -25,6 +25,7 @@ Reference devices for UI and defaults: Riedel Bolero (PTP status), Riedel Direct ## Core API - GET/POST /api/config (full JSON; POST validates, stores to NVS, applies live where possible, returns 4xx with message on bad values) + - Write-only keys (`cfg_mark_secret`, e.g. passwords/client secrets): GET returns "" for them; a POST with "" keeps the stored value, null clears it. Stored in NVS in plain text (NVS encryption is not enabled). - GET /api/status -> {model?, fw?, power?, ip, aes67_ip, inet_ip, inet_vlan, mac, link, tx_packets, underruns, uptime_s, heap_free, psram_free, temps:[…], ptp:{…}, …project fields} - model, fw, power are optional; rows only appear when present (power e.g. "PoE" / "USB" if the board can detect it). - GET /stream.sdp, POST /api/reboot, POST /api/log/test diff --git a/docs/hardware-and-design-notes.md b/docs/hardware-and-design-notes.md index c14fdd0..e5467fc 100644 --- a/docs/hardware-and-design-notes.md +++ b/docs/hardware-and-design-notes.md @@ -30,7 +30,8 @@ source (cspot Spotify Connect | HLS player) -> decode (Vorbis/AAC/MP3) -> SRC 44 - Firmware needs Spotify events: cspot connect, disconnect, play, pause. ## Project config group -- source: {mode: spotify|hls|auto|tone|off, spotify_name, spotify_bitrate, hls_url, autoplay, gain_db, failover_delay_s, failover_on_pause} +- source: {mode: spotify|hls|auto|tone|off, spotify_name, spotify_bitrate, hls_url, autoplay, gain_db, failover_delay_s, failover_on_pause, spotify_client_id, spotify_client_secret} +- spotify_client_id / spotify_client_secret: each user's own Spotify developer app (developer.spotify.com, Premium account), needed by the maintained cspot fork (philippe44/cspot) since Spotify's 2025 API restrictions. The secret is write-only (never returned by GET /api/config). spotify_state reports "no client credentials" while either is missing. - Project status fields: active_source, source_state, spotify_state, buffer_ms - mode "tone": the core's 1 kHz / -18 dBFS test tone, phase-locked to PTP (commissioning, e.g. Riedel import tests). "off": silence. diff --git a/main/player.c b/main/player.c index 128f1bf..a3e3f83 100644 --- a/main/player.c +++ b/main/player.c @@ -95,7 +95,11 @@ 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_AddStringToObject(st, "spotify_state", "not implemented"); + 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_AddNumberToObject(st, "buffer_ms", (double)(audio_ring_level() * 1000 / RATE)); } diff --git a/main/project_cfg.c b/main/project_cfg.c index b7ab14e..fc11178 100644 --- a/main/project_cfg.c +++ b/main/project_cfg.c @@ -7,7 +7,8 @@ static const char SOURCE_DEFAULTS[] = "{\"mode\":\"spotify\",\"spotify_name\":\"P4 AES67\",\"spotify_bitrate\":320,\"hls_url\":\"\"," - "\"autoplay\":false,\"gain_db\":0,\"failover_delay_s\":5,\"failover_on_pause\":false}"; + "\"autoplay\":false,\"gain_db\":0,\"failover_delay_s\":5,\"failover_on_pause\":false," + "\"spotify_client_id\":\"\",\"spotify_client_secret\":\"\"}"; static bool source_validate(const cJSON *g, char *err, size_t n) { @@ -17,6 +18,8 @@ static bool source_validate(const cJSON *g, char *err, size_t n) cfg_check_str(g, "spotify_name", 1, 63, err, n) && cfg_check_num_in(g, "spotify_bitrate", bitrates, 3, err, n) && cfg_check_str(g, "hls_url", 0, 255, err, n) && + cfg_check_str(g, "spotify_client_id", 0, 64, err, n) && + cfg_check_str(g, "spotify_client_secret", 0, 64, err, n) && cfg_check_num(g, "gain_db", -60, 12, err, n) && cfg_check_int(g, "failover_delay_s", 0, 3600, err, n); } @@ -31,4 +34,6 @@ void project_cfg_defaults(void) void project_cfg_register(void) { ESP_ERROR_CHECK(cfg_register("source", SOURCE_DEFAULTS, source_validate, player_apply)); + // Each user's own Spotify developer app (developer.spotify.com); the secret is write-only. + ESP_ERROR_CHECK(cfg_mark_secret("source", "spotify_client_secret")); } diff --git a/web/index.html b/web/index.html index c5dd92a..592cf45 100644 --- a/web/index.html +++ b/web/index.html @@ -71,6 +71,11 @@ e.g. curl -X POST http://p4-aes67.local/api/player/next + + +Your own app from developer.spotify.com + (Premium account). The secret is never shown again once saved. @@ -208,7 +213,7 @@ const PROJECT = { title: 'P4 AES67 Streamer', def: { source:{mode:'spotify',spotify_name:'P4 AES67',spotify_bitrate:'320',hls_url:'',autoplay:false,gain_db:0, - failover_delay_s:5,failover_on_pause:false}, + failover_delay_s:5,failover_on_pause:false,spotify_client_id:'',spotify_client_secret:''}, aes67:{name:'P4 AES67'}, net:{hostname:'p4-aes67'} }, @@ -216,6 +221,7 @@ const PROJECT = { const m = f['source.mode'].value; const sp = m === 'spotify' || m === 'auto', hl = m === 'hls' || m === 'auto'; f['source.spotify_name'].disabled = f['source.spotify_bitrate'].disabled = !sp; + f['source.spotify_client_id'].disabled = f['source.spotify_client_secret'].disabled = !sp; f['source.hls_url'].disabled = !hl; f['source.autoplay'].disabled = m !== 'hls'; f['source.failover_delay_s'].disabled = f['source.failover_on_pause'].disabled = m !== 'auto';