#include "aes67_cfg.h" #include #include #include #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_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; } 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; } 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, cJSON_Duplicate(s_groups[i].values, true)); } 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); ok = merge_known(cand[i], g->defaults, src, g->name, err, sizeof(err)); 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; }