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:
2026-09-24 23:00:55 +10:00
parent 35275b8ccf
commit c2621f192a
20 changed files with 744 additions and 15 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
idf_component_register(SRCS "aes67_net.c" idf_component_register(SRCS "aes67_net.c"
INCLUDE_DIRS "include" INCLUDE_DIRS "include"
REQUIRES esp_eth esp_netif esp_event REQUIRES esp_eth esp_netif esp_event
PRIV_REQUIRES aes67_web) PRIV_REQUIRES aes67_web lwip)
+61
View File
@@ -1,7 +1,10 @@
#include "aes67_net.h" #include "aes67_net.h"
#include <ctype.h>
#include <stdio.h> #include <stdio.h>
#include <string.h>
#include "aes67_cfg.h"
#include "aes67_web.h" #include "aes67_web.h"
#include "esp_eth.h" #include "esp_eth.h"
#include "esp_event.h" #include "esp_event.h"
@@ -10,6 +13,14 @@
static const char *TAG = "net"; static const char *TAG = "net";
// AES67 / untagged interface.
static const char NET_DEFAULTS[] =
"{\"hostname\":\"aes67\",\"vlan\":false,\"web_on\":\"both\",\"dhcp\":true,"
"\"ip\":\"\",\"mask\":\"\",\"gw\":\"\",\"dns\":\"\"}";
// Internet / tagged interface, only used with net.vlan.
static const char INET_DEFAULTS[] =
"{\"vlan_id\":10,\"pcp\":0,\"dhcp\":true,\"ip\":\"\",\"mask\":\"\",\"gw\":\"\",\"dns\":\"\"}";
static esp_netif_t *s_netif; static esp_netif_t *s_netif;
static char s_link[24] = "down"; static char s_link[24] = "down";
@@ -48,6 +59,52 @@ static void ip_event(void *arg, esp_event_base_t base, int32_t id, void *data)
IP2STR(&ev->ip_info.ip), IP2STR(&ev->ip_info.netmask), IP2STR(&ev->ip_info.gw)); IP2STR(&ev->ip_info.ip), IP2STR(&ev->ip_info.netmask), IP2STR(&ev->ip_info.gw));
} }
// Static addressing needs ip + mask; gw/dns are optional (none on the AES67 side with VLAN split).
static bool check_addr(const cJSON *g, char *err, size_t n)
{
bool dhcp = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(g, "dhcp"));
return cfg_check_ipv4(g, "ip", dhcp, err, n) &&
cfg_check_ipv4(g, "mask", dhcp, err, n) &&
cfg_check_ipv4(g, "gw", true, err, n) &&
cfg_check_ipv4(g, "dns", true, err, n);
}
// RFC 1123 label: letters, digits, hyphen; not starting or ending with a hyphen.
static bool check_hostname(const cJSON *g, char *err, size_t n)
{
if (!cfg_check_str(g, "hostname", 1, 63, err, n)) {
return false;
}
const char *h = cJSON_GetObjectItemCaseSensitive(g, "hostname")->valuestring;
size_t len = strlen(h);
for (size_t i = 0; i < len; i++) {
if (!isalnum((unsigned char)h[i]) && h[i] != '-') {
snprintf(err, n, "hostname: only letters, digits and '-'");
return false;
}
}
if (h[0] == '-' || h[len - 1] == '-') {
snprintf(err, n, "hostname: must not start or end with '-'");
return false;
}
return true;
}
static bool net_validate(const cJSON *g, char *err, size_t n)
{
static const char *const web_on[] = { "both", "inet", "aes67", NULL };
return check_hostname(g, err, n) &&
cfg_check_enum(g, "web_on", web_on, err, n) &&
check_addr(g, err, n);
}
static bool inet_validate(const cJSON *g, char *err, size_t n)
{
return cfg_check_int(g, "vlan_id", 1, 4094, err, n) &&
cfg_check_int(g, "pcp", 0, 7, err, n) &&
check_addr(g, err, n);
}
static void net_status(cJSON *st) static void net_status(cJSON *st)
{ {
char ip[16] = "", mac_s[18] = ""; char ip[16] = "", mac_s[18] = "";
@@ -69,6 +126,10 @@ static void net_status(cJSON *st)
esp_err_t aes67_net_init(esp_eth_handle_t eth) esp_err_t aes67_net_init(esp_eth_handle_t eth)
{ {
// Config is stored only for now; applying it (hostname, static IP, VLAN) comes later.
ESP_ERROR_CHECK(cfg_register("net", NET_DEFAULTS, net_validate, NULL));
ESP_ERROR_CHECK(cfg_register("inet", INET_DEFAULTS, inet_validate, NULL));
ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_netif_init());
esp_err_t err = esp_event_loop_create_default(); esp_err_t err = esp_event_loop_create_default();
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
+2 -1
View File
@@ -1,2 +1,3 @@
idf_component_register(SRCS "aes67_ptp.c" idf_component_register(SRCS "aes67_ptp.c"
INCLUDE_DIRS "include") INCLUDE_DIRS "include"
PRIV_REQUIRES aes67_web)
+25 -2
View File
@@ -1,7 +1,30 @@
#include "aes67_ptp.h" #include "aes67_ptp.h"
// Stub (build step 0). #include "aes67_cfg.h"
// Core defaults: Riedel SIC intervals, role auto with fallback priorities (docs/aes67-core-base.md).
static const char PTP_DEFAULTS[] =
"{\"mode\":\"multicast\",\"role\":\"auto\",\"domain\":0,\"priority1\":250,\"priority2\":250,"
"\"log_sync\":0,\"log_announce\":1,\"announce_timeout\":3,\"log_delay_req\":0,"
"\"dscp\":46,\"hw_ts\":true}";
static bool ptp_validate(const cJSON *g, char *err, size_t n)
{
static const char *const modes[] = { "multicast", "hybrid", NULL };
static const char *const roles[] = { "slave", "auto", "master", NULL };
return cfg_check_enum(g, "mode", modes, err, n) &&
cfg_check_enum(g, "role", roles, err, n) &&
cfg_check_int(g, "domain", 0, 127, err, n) &&
cfg_check_int(g, "priority1", 0, 255, err, n) &&
cfg_check_int(g, "priority2", 0, 255, err, n) &&
cfg_check_int(g, "log_sync", -4, 1, err, n) &&
cfg_check_int(g, "log_announce", 0, 4, err, n) &&
cfg_check_int(g, "announce_timeout", 2, 10, err, n) &&
cfg_check_int(g, "log_delay_req", -4, 1, err, n) &&
cfg_check_int(g, "dscp", 0, 63, err, n);
}
esp_err_t aes67_ptp_init(void) esp_err_t aes67_ptp_init(void)
{ {
return ESP_OK; return cfg_register("ptp", PTP_DEFAULTS, ptp_validate, NULL);
} }
+1
View File
@@ -4,4 +4,5 @@
#include "esp_err.h" #include "esp_err.h"
// Registers the "ptp" config group. (Clock itself: step 3.)
esp_err_t aes67_ptp_init(void); esp_err_t aes67_ptp_init(void);
+2 -1
View File
@@ -1,2 +1,3 @@
idf_component_register(SRCS "aes67_syslog.c" idf_component_register(SRCS "aes67_syslog.c"
INCLUDE_DIRS "include") INCLUDE_DIRS "include"
PRIV_REQUIRES aes67_web)
+23 -2
View File
@@ -1,7 +1,28 @@
#include "aes67_syslog.h" #include "aes67_syslog.h"
// Stub (build step 0). #include <stdio.h>
#include "aes67_cfg.h"
// facility: 16 = local0 (PRI = facility * 8 + severity).
static const char LOG_DEFAULTS[] =
"{\"syslog\":false,\"host\":\"\",\"port\":514,\"level\":\"info\",\"facility\":16,\"format\":\"rfc5424\"}";
static bool log_validate(const cJSON *g, char *err, size_t n)
{
static const char *const levels[] = { "error", "warn", "info", "debug", NULL };
static const char *const formats[] = { "rfc5424", "rfc3164", NULL };
static const double facilities[] = { 1, 3, 16, 17, 18, 19, 20, 21, 22, 23 };
bool on = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(g, "syslog"));
return cfg_check_str(g, "host", on ? 1 : 0, 63, err, n) &&
cfg_check_int(g, "port", 1, 65535, err, n) &&
cfg_check_enum(g, "level", levels, err, n) &&
cfg_check_num_in(g, "facility", facilities, 10, err, n) &&
cfg_check_enum(g, "format", formats, err, n);
}
esp_err_t aes67_syslog_init(void) esp_err_t aes67_syslog_init(void)
{ {
return ESP_OK; return cfg_register("log", LOG_DEFAULTS, log_validate, NULL);
} }
@@ -4,4 +4,5 @@
#include "esp_err.h" #include "esp_err.h"
// Registers the "log" config group. (Sender itself: step 5.)
esp_err_t aes67_syslog_init(void); esp_err_t aes67_syslog_init(void);
+1 -1
View File
@@ -1,3 +1,3 @@
idf_component_register(SRCS "aes67_tx.c" idf_component_register(SRCS "aes67_tx.c"
INCLUDE_DIRS "include" INCLUDE_DIRS "include"
PRIV_REQUIRES aes67_web) PRIV_REQUIRES aes67_web lwip)
+60
View File
@@ -1,6 +1,62 @@
#include "aes67_tx.h" #include "aes67_tx.h"
#include <stdio.h>
#include "aes67_cfg.h"
#include "aes67_web.h" #include "aes67_web.h"
#include "lwip/inet.h"
// Defaults follow the Riedel Director 4-wire AES67 output; channels 2 (core default).
static const char AES67_DEFAULTS[] =
"{\"name\":\"AES67\",\"enabled\":true,\"discovery\":\"sap\",\"mcast\":\"239.69.1.10\","
"\"port\":5004,\"ttl\":32,\"dscp\":34,\"channels\":2,\"mono_sum\":false,\"encoding\":\"L24\","
"\"rate\":48000,\"ptime\":1,\"pt\":96,\"ssrc\":0,\"clk_offset\":0,"
"\"session_id\":1,\"session_ver\":1}";
// RTP multicast range 224.0.2.0 - 239.255.255.255 (keeps clear of 224.0.0.x/224.0.1.x, where PTP lives).
static bool check_mcast(const cJSON *g, char *err, size_t n)
{
const cJSON *v = cJSON_GetObjectItemCaseSensitive(g, "mcast");
struct in_addr a;
if (cJSON_IsString(v) && inet_aton(v->valuestring, &a)) {
uint32_t ip = ntohl(a.s_addr);
if (ip >= 0xE0000200 && ip <= 0xEFFFFFFF) {
return true;
}
}
snprintf(err, n, "mcast: must be 224.0.2.0 - 239.255.255.255");
return false;
}
static bool aes67_validate(const cJSON *g, char *err, size_t n)
{
static const char *const discovery[] = { "manual", "sap", NULL };
static const char *const encodings[] = { "L16", "L24", NULL };
static const double rates[] = { 48000, 96000 };
static const double ptimes[] = { 0.125, 0.25, 0.333, 1, 4 };
bool ok = cfg_check_str(g, "name", 1, 63, err, n) &&
cfg_check_enum(g, "discovery", discovery, err, n) &&
check_mcast(g, err, n) &&
cfg_check_int(g, "port", 1024, 65535, err, n) &&
cfg_check_int(g, "ttl", 1, 255, err, n) &&
cfg_check_int(g, "dscp", 0, 63, err, n) &&
cfg_check_int(g, "channels", 1, 2, err, n) &&
cfg_check_enum(g, "encoding", encodings, err, n) &&
cfg_check_num_in(g, "rate", rates, 2, err, n) &&
cfg_check_num_in(g, "ptime", ptimes, 5, err, n) &&
cfg_check_int(g, "pt", 96, 127, err, n) &&
cfg_check_int(g, "ssrc", 0, 4294967295.0, err, n) &&
cfg_check_int(g, "clk_offset", 0, 4294967295.0, err, n) &&
cfg_check_int(g, "session_id", 0, 4294967295.0, err, n) &&
cfg_check_int(g, "session_ver", 0, 4294967295.0, err, n);
if (ok && cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(g, "mono_sum")) &&
cJSON_GetObjectItemCaseSensitive(g, "channels")->valuedouble != 1) {
snprintf(err, n, "channels: must be 1 with mono_sum");
ok = false;
}
return ok;
}
// Stub (build step 2): counters stay 0 until the RTP sender exists (step 4). // Stub (build step 2): counters stay 0 until the RTP sender exists (step 4).
static void tx_status(cJSON *st) static void tx_status(cJSON *st)
@@ -11,5 +67,9 @@ static void tx_status(cJSON *st)
esp_err_t aes67_tx_init(void) esp_err_t aes67_tx_init(void)
{ {
esp_err_t err = cfg_register("aes67", AES67_DEFAULTS, aes67_validate, NULL);
if (err != ESP_OK) {
return err;
}
return status_register(tx_status); return status_register(tx_status);
} }
+1
View File
@@ -4,4 +4,5 @@
#include "esp_err.h" #include "esp_err.h"
// Registers the "aes67" config group and the TX status fields. (Sender itself: step 4.)
esp_err_t aes67_tx_init(void); esp_err_t aes67_tx_init(void);
+2 -2
View File
@@ -1,5 +1,5 @@
idf_component_register(SRCS "aes67_web.c" idf_component_register(SRCS "aes67_web.c" "cfg.c"
INCLUDE_DIRS "include" INCLUDE_DIRS "include"
REQUIRES esp_http_server json REQUIRES esp_http_server json
PRIV_REQUIRES esp_timer PRIV_REQUIRES esp_timer nvs_flash lwip
EMBED_TXTFILES "${PROJECT_DIR}/web/index.html") EMBED_TXTFILES "${PROJECT_DIR}/web/index.html")
+11 -2
View File
@@ -1,5 +1,7 @@
#include "aes67_web.h" #include "aes67_web.h"
#include "aes67_cfg.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_system.h" #include "esp_system.h"
#include "esp_timer.h" #include "esp_timer.h"
@@ -13,6 +15,8 @@ static const char *TAG = "web";
extern const char index_html_start[] asm("_binary_index_html_start"); extern const char index_html_start[] asm("_binary_index_html_start");
extern const char index_html_end[] asm("_binary_index_html_end"); extern const char index_html_end[] asm("_binary_index_html_end");
esp_err_t cfg_web_register(void); // cfg.c
static httpd_handle_t s_httpd; static httpd_handle_t s_httpd;
static status_cb_t s_status_cbs[MAX_STATUS_CBS]; static status_cb_t s_status_cbs[MAX_STATUS_CBS];
static int s_status_n; static int s_status_n;
@@ -91,13 +95,18 @@ static esp_err_t reboot_post(httpd_req_t *req)
esp_err_t aes67_web_start(void) esp_err_t aes67_web_start(void)
{ {
esp_err_t err = cfg_web_register();
if (err != ESP_OK) {
return err;
}
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
cfg.max_uri_handlers = MAX_URIS; cfg.max_uri_handlers = MAX_URIS;
cfg.stack_size = 8192; cfg.stack_size = 8192;
cfg.lru_purge_enable = true; cfg.lru_purge_enable = true;
cfg.uri_match_fn = httpd_uri_match_wildcard; cfg.uri_match_fn = httpd_uri_match_wildcard;
esp_err_t err = httpd_start(&s_httpd, &cfg); err = httpd_start(&s_httpd, &cfg);
if (err != ESP_OK) { if (err != ESP_OK) {
ESP_LOGE(TAG, "httpd start failed: %s", esp_err_to_name(err)); ESP_LOGE(TAG, "httpd start failed: %s", esp_err_to_name(err));
return err; return err;
@@ -116,7 +125,7 @@ esp_err_t aes67_web_start(void)
httpd_register_uri_handler(s_httpd, &s_early_uris[i]); httpd_register_uri_handler(s_httpd, &s_early_uris[i]);
} }
ESP_LOGI(TAG, "httpd started, index.html %d bytes, %d project routes", ESP_LOGI(TAG, "httpd started, index.html %d bytes, %d extra routes",
(int)(index_html_end - index_html_start - 1), s_early_n); (int)(index_html_end - index_html_start - 1), s_early_n);
return ESP_OK; return ESP_OK;
} }
+472
View File
@@ -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;
}
+30
View File
@@ -0,0 +1,30 @@
// Config registry and store (cJSON in NVS), part of aes67_web.
// Each component registers its group; /api/config is built from the registry.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include "cJSON.h"
#include "esp_err.h"
// Validate a candidate group object. On failure write "key: reason" to err and return false.
typedef bool (*cfg_validate_cb_t)(const cJSON *group, char *err, size_t err_len);
// Called with the new group values after a successful save (apply live where possible).
typedef void (*cfg_apply_cb_t)(const cJSON *group);
// Project defaults merged over a group's core defaults. Call before that group is registered.
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);
// Copy of a group's current values; caller frees with cJSON_Delete. NULL if not registered.
cJSON *cfg_get(const char *group);
// Validation helpers for validate callbacks. Each returns false and fills err on failure.
bool cfg_check_num(const cJSON *g, const char *key, double min, double max, char *err, size_t n);
bool cfg_check_int(const cJSON *g, const char *key, double min, double max, char *err, size_t n);
bool cfg_check_num_in(const cJSON *g, const char *key, const double *vals, int count, char *err, size_t n);
bool cfg_check_enum(const cJSON *g, const char *key, const char *const *opts, char *err, size_t n);
bool cfg_check_str(const cJSON *g, const char *key, size_t min_len, size_t max_len, char *err, size_t n);
bool cfg_check_ipv4(const cJSON *g, const char *key, bool allow_empty, char *err, size_t n);
+1 -1
View File
@@ -13,7 +13,7 @@ typedef void (*status_cb_t)(cJSON *status);
esp_err_t status_register(status_cb_t cb); esp_err_t status_register(status_cb_t cb);
esp_err_t web_register_uri(const httpd_uri_t *uri); esp_err_t web_register_uri(const httpd_uri_t *uri);
// Start httpd on port 80 with the core routes (/, /api/status, /api/reboot). // Start httpd on port 80 with the core routes (/, /api/status, /api/config, /api/reboot).
esp_err_t aes67_web_start(void); esp_err_t aes67_web_start(void);
// Send a cJSON object as the response body (application/json). // Send a cJSON object as the response body (application/json).
+3 -2
View File
@@ -1,4 +1,5 @@
idf_component_register(SRCS "main.c" idf_component_register(SRCS "main.c" "project_cfg.c"
INCLUDE_DIRS "." INCLUDE_DIRS "."
REQUIRES esp_app_format esp_hw_support REQUIRES esp_app_format esp_hw_support
aes67_board aes67_net aes67_health aes67_tx aes67_web) aes67_board aes67_health aes67_net aes67_ptp
aes67_syslog aes67_tx aes67_web)
+8
View File
@@ -1,11 +1,14 @@
#include "aes67_board.h" #include "aes67_board.h"
#include "aes67_health.h" #include "aes67_health.h"
#include "aes67_net.h" #include "aes67_net.h"
#include "aes67_ptp.h"
#include "aes67_syslog.h"
#include "aes67_tx.h" #include "aes67_tx.h"
#include "aes67_web.h" #include "aes67_web.h"
#include "esp_app_desc.h" #include "esp_app_desc.h"
#include "esp_chip_info.h" #include "esp_chip_info.h"
#include "esp_log.h" #include "esp_log.h"
#include "project_cfg.h"
static const char *TAG = "main"; static const char *TAG = "main";
@@ -20,10 +23,15 @@ void app_main(void)
ESP_LOGI(TAG, "chip rev v%d.%d, %d cores", ESP_LOGI(TAG, "chip rev v%d.%d, %d cores",
chip.revision / 100, chip.revision % 100, chip.cores); chip.revision / 100, chip.revision % 100, chip.cores);
project_cfg_defaults();
esp_eth_handle_t eth; esp_eth_handle_t eth;
ESP_ERROR_CHECK(aes67_board_eth_init(&eth)); ESP_ERROR_CHECK(aes67_board_eth_init(&eth));
ESP_ERROR_CHECK(aes67_net_init(eth)); ESP_ERROR_CHECK(aes67_net_init(eth));
ESP_ERROR_CHECK(aes67_health_init()); ESP_ERROR_CHECK(aes67_health_init());
ESP_ERROR_CHECK(aes67_ptp_init());
ESP_ERROR_CHECK(aes67_tx_init()); ESP_ERROR_CHECK(aes67_tx_init());
ESP_ERROR_CHECK(aes67_syslog_init());
project_cfg_register();
ESP_ERROR_CHECK(aes67_web_start()); ESP_ERROR_CHECK(aes67_web_start());
} }
+33
View File
@@ -0,0 +1,33 @@
// Project config: defaults over the core groups and the "source" group.
#include "project_cfg.h"
#include "aes67_cfg.h"
#include "esp_err.h"
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}";
static bool source_validate(const cJSON *g, char *err, size_t n)
{
static const char *const modes[] = { "spotify", "hls", "auto", "off", NULL };
static const double bitrates[] = { 96, 160, 320 };
return cfg_check_enum(g, "mode", modes, err, 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_num(g, "gain_db", -60, 12, err, n) &&
cfg_check_int(g, "failover_delay_s", 0, 3600, err, n);
}
void project_cfg_defaults(void)
{
// Same as PROJECT.def in web/index.html.
ESP_ERROR_CHECK(cfg_override_defaults("aes67", "{\"name\":\"P4 AES67\"}"));
ESP_ERROR_CHECK(cfg_override_defaults("net", "{\"hostname\":\"p4-aes67\"}"));
}
void project_cfg_register(void)
{
ESP_ERROR_CHECK(cfg_register("source", SOURCE_DEFAULTS, source_validate, NULL));
}
+6
View File
@@ -0,0 +1,6 @@
#pragma once
// Call before the core components register their groups.
void project_cfg_defaults(void);
// Registers the project's own config groups.
void project_cfg_register(void);