7bbda0a1e8
- Core config: cfg_mark_secret(group, key). GET /api/config returns ""
for secret keys; a POST with "" keeps the stored value, null clears it;
cfg_get() returns the real value. Documented in aes67-core-base.md.
- source.spotify_client_id / spotify_client_secret (secret write-only):
each user's own Spotify developer app, needed by the maintained cspot
fork (philippe44/cspot) since Spotify's 2025 API restrictions.
- UI: client ID field, password field for the secret ("leave empty to
keep"), link to developer.spotify.com; enabled for spotify/auto modes.
- status.spotify_state: "no client credentials" while either is missing.
- Verified: secret never appears in GET; UI-style re-save keeps it;
survives reboot; null clears it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
547 lines
17 KiB
C
547 lines
17 KiB
C
#include "aes67_cfg.h"
|
|
|
|
#include <math.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
|
|
#include "aes67_web.h"
|
|
#include "esp_log.h"
|
|
#include "freertos/FreeRTOS.h"
|
|
#include "freertos/semphr.h"
|
|
#include "lwip/inet.h"
|
|
#include "nvs.h"
|
|
#include "nvs_flash.h"
|
|
|
|
#define MAX_GROUPS 12
|
|
#define MAX_SECRETS 4
|
|
#define MAX_BODY 8192
|
|
#define NVS_NAMESPACE "cfg"
|
|
|
|
static const char *TAG = "cfg";
|
|
|
|
typedef struct {
|
|
char name[16]; // also the NVS key (max 15 chars)
|
|
cJSON *defaults;
|
|
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];
|
|
static int s_group_n;
|
|
static cJSON *s_overrides; // {group: {...}} from cfg_override_defaults
|
|
static SemaphoreHandle_t s_lock;
|
|
static bool s_nvs_ok;
|
|
|
|
static void cfg_init_once(void)
|
|
{
|
|
if (s_lock) {
|
|
return;
|
|
}
|
|
s_lock = xSemaphoreCreateMutex();
|
|
s_overrides = cJSON_CreateObject();
|
|
esp_err_t err = nvs_flash_init();
|
|
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
|
ESP_LOGW(TAG, "NVS needs erase (%s), config reset to defaults", esp_err_to_name(err));
|
|
nvs_flash_erase();
|
|
err = nvs_flash_init();
|
|
}
|
|
s_nvs_ok = err == ESP_OK;
|
|
if (!s_nvs_ok) {
|
|
ESP_LOGE(TAG, "NVS init failed: %s; config will not persist", esp_err_to_name(err));
|
|
}
|
|
}
|
|
|
|
static cfg_group_t *find(const char *name)
|
|
{
|
|
for (int i = 0; i < s_group_n; i++) {
|
|
if (strcmp(s_groups[i].name, name) == 0) {
|
|
return &s_groups[i];
|
|
}
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
// Same JSON kind for our purposes: number, string or bool.
|
|
static bool same_type(const cJSON *a, const cJSON *b)
|
|
{
|
|
if (cJSON_IsBool(a)) {
|
|
return cJSON_IsBool(b);
|
|
}
|
|
return (a->type & 0xFF) == (b->type & 0xFF);
|
|
}
|
|
|
|
static const char *type_name(const cJSON *v)
|
|
{
|
|
return cJSON_IsBool(v) ? "boolean" : cJSON_IsNumber(v) ? "number" : cJSON_IsString(v) ? "string" : "value";
|
|
}
|
|
|
|
// Copy known keys of src over dst (keys must exist in defs with the same type).
|
|
// With err set, a type mismatch is an error; otherwise it is skipped with a warning.
|
|
static bool merge_known(cJSON *dst, const cJSON *defs, const cJSON *src, const char *group,
|
|
char *err, size_t err_len)
|
|
{
|
|
const cJSON *v;
|
|
cJSON_ArrayForEach(v, src) {
|
|
const cJSON *d = cJSON_GetObjectItemCaseSensitive(defs, v->string);
|
|
if (!d) {
|
|
ESP_LOGW(TAG, "%s.%s: unknown key, ignored", group, v->string);
|
|
continue;
|
|
}
|
|
if (!same_type(d, v)) {
|
|
if (err) {
|
|
snprintf(err, err_len, "%s.%s: expected %s", group, v->string, type_name(d));
|
|
return false;
|
|
}
|
|
ESP_LOGW(TAG, "%s.%s: stored type differs from default, ignored", group, v->string);
|
|
continue;
|
|
}
|
|
cJSON_ReplaceItemInObjectCaseSensitive(dst, v->string, cJSON_Duplicate(v, true));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static void load_stored(cfg_group_t *g)
|
|
{
|
|
if (!s_nvs_ok) {
|
|
return;
|
|
}
|
|
nvs_handle_t h;
|
|
if (nvs_open(NVS_NAMESPACE, NVS_READONLY, &h) != ESP_OK) {
|
|
return; // nothing stored yet
|
|
}
|
|
size_t len = 0;
|
|
if (nvs_get_str(h, g->name, NULL, &len) == ESP_OK && len > 0) {
|
|
char *buf = malloc(len);
|
|
if (buf && nvs_get_str(h, g->name, buf, &len) == ESP_OK) {
|
|
cJSON *stored = cJSON_Parse(buf);
|
|
if (cJSON_IsObject(stored)) {
|
|
merge_known(g->values, g->defaults, stored, g->name, NULL, 0);
|
|
ESP_LOGI(TAG, "%s: loaded %d stored value(s)", g->name, cJSON_GetArraySize(stored));
|
|
} else {
|
|
ESP_LOGW(TAG, "%s: stored config unreadable, using defaults", g->name);
|
|
}
|
|
cJSON_Delete(stored);
|
|
}
|
|
free(buf);
|
|
}
|
|
nvs_close(h);
|
|
}
|
|
|
|
// Store only the keys that differ from the defaults, so new firmware defaults still apply
|
|
// to settings the user never changed.
|
|
static esp_err_t store(nvs_handle_t h, const cfg_group_t *g)
|
|
{
|
|
cJSON *diff = cJSON_CreateObject();
|
|
const cJSON *v;
|
|
cJSON_ArrayForEach(v, g->values) {
|
|
const cJSON *d = cJSON_GetObjectItemCaseSensitive(g->defaults, v->string);
|
|
if (!d || !cJSON_Compare(d, v, true)) {
|
|
cJSON_AddItemToObject(diff, v->string, cJSON_Duplicate(v, true));
|
|
}
|
|
}
|
|
esp_err_t err;
|
|
if (cJSON_GetArraySize(diff) == 0) {
|
|
err = nvs_erase_key(h, g->name);
|
|
if (err == ESP_ERR_NVS_NOT_FOUND) {
|
|
err = ESP_OK;
|
|
}
|
|
} else {
|
|
char *s = cJSON_PrintUnformatted(diff);
|
|
err = s ? nvs_set_str(h, g->name, s) : ESP_ERR_NO_MEM;
|
|
cJSON_free(s);
|
|
}
|
|
cJSON_Delete(diff);
|
|
return err;
|
|
}
|
|
|
|
esp_err_t cfg_override_defaults(const char *group, const char *json)
|
|
{
|
|
cfg_init_once();
|
|
cJSON *o = cJSON_Parse(json);
|
|
if (!cJSON_IsObject(o)) {
|
|
cJSON_Delete(o);
|
|
ESP_LOGE(TAG, "%s: bad override JSON", group);
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
xSemaphoreTake(s_lock, portMAX_DELAY);
|
|
cJSON *prev = cJSON_GetObjectItemCaseSensitive(s_overrides, group);
|
|
if (prev) {
|
|
const cJSON *v;
|
|
cJSON_ArrayForEach(v, o) {
|
|
cJSON_DeleteItemFromObjectCaseSensitive(prev, v->string);
|
|
cJSON_AddItemToObject(prev, v->string, cJSON_Duplicate(v, true));
|
|
}
|
|
cJSON_Delete(o);
|
|
} else {
|
|
cJSON_AddItemToObject(s_overrides, group, o);
|
|
}
|
|
bool late = find(group) != NULL;
|
|
xSemaphoreGive(s_lock);
|
|
if (late) {
|
|
ESP_LOGE(TAG, "%s: override after register has no effect", group);
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
return ESP_OK;
|
|
}
|
|
|
|
esp_err_t cfg_register(const char *group, const char *defaults_json,
|
|
cfg_validate_cb_t validate, cfg_apply_cb_t apply)
|
|
{
|
|
cfg_init_once();
|
|
if (strlen(group) >= sizeof(s_groups[0].name)) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
cJSON *defs = cJSON_Parse(defaults_json);
|
|
if (!cJSON_IsObject(defs)) {
|
|
cJSON_Delete(defs);
|
|
ESP_LOGE(TAG, "%s: bad defaults JSON", group);
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
xSemaphoreTake(s_lock, portMAX_DELAY);
|
|
if (find(group) || s_group_n >= MAX_GROUPS) {
|
|
xSemaphoreGive(s_lock);
|
|
cJSON_Delete(defs);
|
|
ESP_LOGE(TAG, "%s: already registered or registry full", group);
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
const cJSON *ov = cJSON_GetObjectItemCaseSensitive(s_overrides, group);
|
|
if (ov) {
|
|
merge_known(defs, defs, ov, group, NULL, 0);
|
|
}
|
|
cfg_group_t *g = &s_groups[s_group_n++];
|
|
strcpy(g->name, group);
|
|
g->defaults = defs;
|
|
g->values = cJSON_Duplicate(defs, true);
|
|
g->validate = validate;
|
|
g->apply = apply;
|
|
load_stored(g);
|
|
xSemaphoreGive(s_lock);
|
|
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) {
|
|
return NULL;
|
|
}
|
|
xSemaphoreTake(s_lock, portMAX_DELAY);
|
|
cfg_group_t *g = find(group);
|
|
cJSON *copy = g ? cJSON_Duplicate(g->values, true) : NULL;
|
|
xSemaphoreGive(s_lock);
|
|
return copy;
|
|
}
|
|
|
|
esp_err_t cfg_set_number(const char *group, const char *key, double value, bool apply)
|
|
{
|
|
if (!s_lock) {
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
xSemaphoreTake(s_lock, portMAX_DELAY);
|
|
cfg_group_t *g = find(group);
|
|
cJSON *cur = g ? cJSON_GetObjectItemCaseSensitive(g->values, key) : NULL;
|
|
esp_err_t err = ESP_OK;
|
|
if (!cur || !cJSON_IsNumber(cur)) {
|
|
err = ESP_ERR_NOT_FOUND;
|
|
} else {
|
|
cJSON_SetNumberValue(cur, value);
|
|
nvs_handle_t h;
|
|
if (s_nvs_ok && (err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h)) == ESP_OK) {
|
|
err = store(h, g);
|
|
if (err == ESP_OK) {
|
|
err = nvs_commit(h);
|
|
}
|
|
nvs_close(h);
|
|
}
|
|
}
|
|
cJSON *copy = err == ESP_OK && apply && g->apply ? cJSON_Duplicate(g->values, true) : NULL;
|
|
xSemaphoreGive(s_lock);
|
|
if (copy) {
|
|
g->apply(copy);
|
|
cJSON_Delete(copy);
|
|
}
|
|
return err;
|
|
}
|
|
|
|
/* ----- HTTP ----- */
|
|
|
|
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, public_values(&s_groups[i]));
|
|
}
|
|
xSemaphoreGive(s_lock);
|
|
esp_err_t err = web_send_json(req, all);
|
|
cJSON_Delete(all);
|
|
return err;
|
|
}
|
|
|
|
static esp_err_t bad_request(httpd_req_t *req, const char *msg)
|
|
{
|
|
ESP_LOGW(TAG, "POST /api/config rejected: %s", msg);
|
|
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, msg);
|
|
}
|
|
|
|
static esp_err_t config_post(httpd_req_t *req)
|
|
{
|
|
if (req->content_len == 0 || req->content_len > MAX_BODY) {
|
|
return bad_request(req, "body missing or too large");
|
|
}
|
|
char *body = malloc(req->content_len + 1);
|
|
if (!body) {
|
|
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "out of memory");
|
|
}
|
|
size_t got = 0;
|
|
while (got < req->content_len) {
|
|
int r = httpd_req_recv(req, body + got, req->content_len - got);
|
|
if (r == HTTPD_SOCK_ERR_TIMEOUT) {
|
|
continue;
|
|
}
|
|
if (r <= 0) {
|
|
free(body);
|
|
return ESP_FAIL;
|
|
}
|
|
got += r;
|
|
}
|
|
body[got] = 0;
|
|
cJSON *in = cJSON_Parse(body);
|
|
free(body);
|
|
if (!cJSON_IsObject(in)) {
|
|
cJSON_Delete(in);
|
|
return bad_request(req, "body is not a JSON object");
|
|
}
|
|
|
|
char err[128] = "";
|
|
cJSON *cand[MAX_GROUPS] = { 0 };
|
|
bool ok = true;
|
|
|
|
xSemaphoreTake(s_lock, portMAX_DELAY);
|
|
// Build and validate every candidate group before storing anything.
|
|
for (int i = 0; ok && i < s_group_n; i++) {
|
|
cfg_group_t *g = &s_groups[i];
|
|
const cJSON *src = cJSON_GetObjectItemCaseSensitive(in, g->name);
|
|
if (!src) {
|
|
continue;
|
|
}
|
|
if (!cJSON_IsObject(src)) {
|
|
snprintf(err, sizeof(err), "%s: expected object", g->name);
|
|
ok = false;
|
|
break;
|
|
}
|
|
cand[i] = cJSON_Duplicate(g->values, true);
|
|
// 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));
|
|
if (!ok) {
|
|
snprintf(err, sizeof(err), "%s.%s", g->name, verr);
|
|
}
|
|
}
|
|
}
|
|
const cJSON *v;
|
|
cJSON_ArrayForEach(v, in) {
|
|
if (!find(v->string)) {
|
|
ESP_LOGW(TAG, "%s: unknown group, ignored", v->string);
|
|
}
|
|
}
|
|
|
|
esp_err_t serr = ESP_OK;
|
|
int changed = 0;
|
|
if (ok && s_nvs_ok) {
|
|
nvs_handle_t h;
|
|
serr = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h);
|
|
for (int i = 0; serr == ESP_OK && i < s_group_n; i++) {
|
|
if (cand[i] && !cJSON_Compare(cand[i], s_groups[i].values, true)) {
|
|
cJSON *old = s_groups[i].values;
|
|
s_groups[i].values = cand[i];
|
|
serr = store(h, &s_groups[i]);
|
|
if (serr != ESP_OK) {
|
|
s_groups[i].values = old;
|
|
break;
|
|
}
|
|
cand[i] = old; // freed below
|
|
changed |= 1 << i;
|
|
}
|
|
}
|
|
if (serr == ESP_OK) {
|
|
serr = nvs_commit(h);
|
|
}
|
|
nvs_close(h);
|
|
}
|
|
for (int i = 0; i < s_group_n; i++) {
|
|
cJSON_Delete(cand[i]);
|
|
}
|
|
|
|
// Apply after the lock is released; callbacks may call cfg_get().
|
|
cJSON *applied[MAX_GROUPS] = { 0 };
|
|
for (int i = 0; i < s_group_n; i++) {
|
|
if ((changed & (1 << i)) && s_groups[i].apply) {
|
|
applied[i] = cJSON_Duplicate(s_groups[i].values, true);
|
|
}
|
|
}
|
|
int group_n = s_group_n;
|
|
xSemaphoreGive(s_lock);
|
|
cJSON_Delete(in);
|
|
|
|
if (!ok) {
|
|
return bad_request(req, err);
|
|
}
|
|
if (!s_nvs_ok || serr != ESP_OK) {
|
|
ESP_LOGE(TAG, "store failed: %s", s_nvs_ok ? esp_err_to_name(serr) : "NVS unavailable");
|
|
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "could not store config");
|
|
}
|
|
for (int i = 0; i < group_n; i++) {
|
|
if (changed & (1 << i)) {
|
|
ESP_LOGI(TAG, "%s: saved", s_groups[i].name);
|
|
}
|
|
if (applied[i]) {
|
|
s_groups[i].apply(applied[i]);
|
|
cJSON_Delete(applied[i]);
|
|
}
|
|
}
|
|
return config_get(req);
|
|
}
|
|
|
|
esp_err_t cfg_web_register(void)
|
|
{
|
|
static const httpd_uri_t uris[] = {
|
|
{ .uri = "/api/config", .method = HTTP_GET, .handler = config_get },
|
|
{ .uri = "/api/config", .method = HTTP_POST, .handler = config_post },
|
|
};
|
|
cfg_init_once();
|
|
for (int i = 0; i < 2; i++) {
|
|
esp_err_t err = web_register_uri(&uris[i]);
|
|
if (err != ESP_OK) {
|
|
return err;
|
|
}
|
|
}
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* ----- Validation helpers ----- */
|
|
|
|
static const cJSON *item(const cJSON *g, const char *key)
|
|
{
|
|
return cJSON_GetObjectItemCaseSensitive(g, key);
|
|
}
|
|
|
|
bool cfg_check_num(const cJSON *g, const char *key, double min, double max, char *err, size_t n)
|
|
{
|
|
const cJSON *v = item(g, key);
|
|
if (!cJSON_IsNumber(v) || v->valuedouble < min || v->valuedouble > max) {
|
|
snprintf(err, n, "%s: must be %g..%g", key, min, max);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool cfg_check_int(const cJSON *g, const char *key, double min, double max, char *err, size_t n)
|
|
{
|
|
if (!cfg_check_num(g, key, min, max, err, n)) {
|
|
return false;
|
|
}
|
|
double d = item(g, key)->valuedouble;
|
|
if (d != floor(d)) {
|
|
snprintf(err, n, "%s: must be a whole number", key);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool cfg_check_num_in(const cJSON *g, const char *key, const double *vals, int count, char *err, size_t n)
|
|
{
|
|
const cJSON *v = item(g, key);
|
|
for (int i = 0; cJSON_IsNumber(v) && i < count; i++) {
|
|
if (v->valuedouble == vals[i]) {
|
|
return true;
|
|
}
|
|
}
|
|
int off = snprintf(err, n, "%s: must be one of", key);
|
|
for (int i = 0; i < count && off > 0 && off < (int)n; i++) {
|
|
off += snprintf(err + off, n - off, " %g", vals[i]);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool cfg_check_enum(const cJSON *g, const char *key, const char *const *opts, char *err, size_t n)
|
|
{
|
|
const cJSON *v = item(g, key);
|
|
for (int i = 0; cJSON_IsString(v) && opts[i]; i++) {
|
|
if (strcmp(v->valuestring, opts[i]) == 0) {
|
|
return true;
|
|
}
|
|
}
|
|
int off = snprintf(err, n, "%s: must be one of", key);
|
|
for (int i = 0; opts[i] && off > 0 && off < (int)n; i++) {
|
|
off += snprintf(err + off, n - off, " %s", opts[i]);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool cfg_check_str(const cJSON *g, const char *key, size_t min_len, size_t max_len, char *err, size_t n)
|
|
{
|
|
const cJSON *v = item(g, key);
|
|
size_t len = cJSON_IsString(v) ? strlen(v->valuestring) : 0;
|
|
if (!cJSON_IsString(v) || len < min_len || len > max_len) {
|
|
snprintf(err, n, "%s: length must be %u..%u", key, (unsigned)min_len, (unsigned)max_len);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool cfg_check_ipv4(const cJSON *g, const char *key, bool allow_empty, char *err, size_t n)
|
|
{
|
|
const cJSON *v = item(g, key);
|
|
struct in_addr a;
|
|
if (cJSON_IsString(v) && ((allow_empty && !v->valuestring[0]) || inet_aton(v->valuestring, &a))) {
|
|
return true;
|
|
}
|
|
snprintf(err, n, "%s: not a valid IPv4 address", key);
|
|
return false;
|
|
}
|