Files
aes67-ESP32-P4/components/aes67_web/cfg.c
T
bsncubed dcea39b216 Step 6.2: role hand-over, session_ver bump on GM change
- aes67_cfg: cfg_set_number(group, key, value, apply) for firmware-side
  changes, stored in NVS like a POST; apply can be skipped.
- aes67_sdp_sap: once a second, a change of the PTP GM (ts-refclk) bumps
  aes67.session_ver without re-applying the aes67 group (no stream
  restart); SAP then re-announces. Runs whether or not SAP is on.
- Verified with ptp4l as a normal clock (p1 128): board auto (p1 250)
  steps down to SLAVE (session_ver 1->2); role master p1 100 via API ->
  board MASTER at once (->3); back to auto p1 250 -> ptp4l takes over
  within ~1 s, board SLAVE again (->4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:08:00 +10:00

504 lines
15 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_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;
}