Step 2.2: config registry and NVS store, GET/POST /api/config
- aes67_web/cfg.c: cfg_register(group, defaults, validate, apply), cfg_override_defaults() for project defaults, cfg_get(). Stored values are loaded on register and merged over defaults (known keys, matching types). NVS keeps only keys that differ from defaults, one JSON string per group, so new firmware defaults still reach untouched settings. - POST /api/config: type check against defaults, per-group validation, nothing stored unless every group passes; 400 "group.key: reason". Unknown keys/groups are ignored. Returns the new config. - Core groups registered by their components with the UI's ranges: ptp (aes67_ptp), aes67 (aes67_tx, incl. multicast range and mono_sum => 1 channel), net/inet (aes67_net), log (aes67_syslog). - Project: PROJECT.def overrides (hostname p4-aes67, name P4 AES67) and the "source" group in main/project_cfg.c. - Config is stored only; nothing is applied live yet. - Verified on board: defaults, valid save, 8 rejected values (nothing stored), persistence across reboot, restore to defaults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
#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_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;
|
||||
}
|
||||
|
||||
/* ----- 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;
|
||||
}
|
||||
Reference in New Issue
Block a user