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';