diff --git a/components/aes67_net/CMakeLists.txt b/components/aes67_net/CMakeLists.txt index 775ca4b..4eef1e2 100644 --- a/components/aes67_net/CMakeLists.txt +++ b/components/aes67_net/CMakeLists.txt @@ -1,4 +1,4 @@ idf_component_register(SRCS "aes67_net.c" INCLUDE_DIRS "include" REQUIRES esp_eth esp_netif esp_event - PRIV_REQUIRES aes67_web) + PRIV_REQUIRES aes67_web lwip) diff --git a/components/aes67_net/aes67_net.c b/components/aes67_net/aes67_net.c index 11acfb8..917d575 100644 --- a/components/aes67_net/aes67_net.c +++ b/components/aes67_net/aes67_net.c @@ -1,7 +1,10 @@ #include "aes67_net.h" +#include #include +#include +#include "aes67_cfg.h" #include "aes67_web.h" #include "esp_eth.h" #include "esp_event.h" @@ -10,6 +13,14 @@ 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 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)); } +// 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) { 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) { + // 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_err_t err = esp_event_loop_create_default(); if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { diff --git a/components/aes67_ptp/CMakeLists.txt b/components/aes67_ptp/CMakeLists.txt index 57efdd3..df17558 100644 --- a/components/aes67_ptp/CMakeLists.txt +++ b/components/aes67_ptp/CMakeLists.txt @@ -1,2 +1,3 @@ idf_component_register(SRCS "aes67_ptp.c" - INCLUDE_DIRS "include") + INCLUDE_DIRS "include" + PRIV_REQUIRES aes67_web) diff --git a/components/aes67_ptp/aes67_ptp.c b/components/aes67_ptp/aes67_ptp.c index eda0d2b..898d72a 100644 --- a/components/aes67_ptp/aes67_ptp.c +++ b/components/aes67_ptp/aes67_ptp.c @@ -1,7 +1,30 @@ #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) { - return ESP_OK; + return cfg_register("ptp", PTP_DEFAULTS, ptp_validate, NULL); } diff --git a/components/aes67_ptp/include/aes67_ptp.h b/components/aes67_ptp/include/aes67_ptp.h index 0033153..f997ae7 100644 --- a/components/aes67_ptp/include/aes67_ptp.h +++ b/components/aes67_ptp/include/aes67_ptp.h @@ -4,4 +4,5 @@ #include "esp_err.h" +// Registers the "ptp" config group. (Clock itself: step 3.) esp_err_t aes67_ptp_init(void); diff --git a/components/aes67_syslog/CMakeLists.txt b/components/aes67_syslog/CMakeLists.txt index da5b3c7..a4b1dba 100644 --- a/components/aes67_syslog/CMakeLists.txt +++ b/components/aes67_syslog/CMakeLists.txt @@ -1,2 +1,3 @@ idf_component_register(SRCS "aes67_syslog.c" - INCLUDE_DIRS "include") + INCLUDE_DIRS "include" + PRIV_REQUIRES aes67_web) diff --git a/components/aes67_syslog/aes67_syslog.c b/components/aes67_syslog/aes67_syslog.c index 99d6139..0d1d23a 100644 --- a/components/aes67_syslog/aes67_syslog.c +++ b/components/aes67_syslog/aes67_syslog.c @@ -1,7 +1,28 @@ #include "aes67_syslog.h" -// Stub (build step 0). +#include + +#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) { - return ESP_OK; + return cfg_register("log", LOG_DEFAULTS, log_validate, NULL); } diff --git a/components/aes67_syslog/include/aes67_syslog.h b/components/aes67_syslog/include/aes67_syslog.h index c7f97aa..12d3731 100644 --- a/components/aes67_syslog/include/aes67_syslog.h +++ b/components/aes67_syslog/include/aes67_syslog.h @@ -4,4 +4,5 @@ #include "esp_err.h" +// Registers the "log" config group. (Sender itself: step 5.) esp_err_t aes67_syslog_init(void); diff --git a/components/aes67_tx/CMakeLists.txt b/components/aes67_tx/CMakeLists.txt index 78a1bb6..b1e32e0 100644 --- a/components/aes67_tx/CMakeLists.txt +++ b/components/aes67_tx/CMakeLists.txt @@ -1,3 +1,3 @@ idf_component_register(SRCS "aes67_tx.c" INCLUDE_DIRS "include" - PRIV_REQUIRES aes67_web) + PRIV_REQUIRES aes67_web lwip) diff --git a/components/aes67_tx/aes67_tx.c b/components/aes67_tx/aes67_tx.c index 400ee63..781c334 100644 --- a/components/aes67_tx/aes67_tx.c +++ b/components/aes67_tx/aes67_tx.c @@ -1,6 +1,62 @@ #include "aes67_tx.h" +#include + +#include "aes67_cfg.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). 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 err = cfg_register("aes67", AES67_DEFAULTS, aes67_validate, NULL); + if (err != ESP_OK) { + return err; + } return status_register(tx_status); } diff --git a/components/aes67_tx/include/aes67_tx.h b/components/aes67_tx/include/aes67_tx.h index c52f051..7e630d3 100644 --- a/components/aes67_tx/include/aes67_tx.h +++ b/components/aes67_tx/include/aes67_tx.h @@ -4,4 +4,5 @@ #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); diff --git a/components/aes67_web/CMakeLists.txt b/components/aes67_web/CMakeLists.txt index 8d00110..250dc88 100644 --- a/components/aes67_web/CMakeLists.txt +++ b/components/aes67_web/CMakeLists.txt @@ -1,5 +1,5 @@ -idf_component_register(SRCS "aes67_web.c" +idf_component_register(SRCS "aes67_web.c" "cfg.c" INCLUDE_DIRS "include" REQUIRES esp_http_server json - PRIV_REQUIRES esp_timer + PRIV_REQUIRES esp_timer nvs_flash lwip EMBED_TXTFILES "${PROJECT_DIR}/web/index.html") diff --git a/components/aes67_web/aes67_web.c b/components/aes67_web/aes67_web.c index 6415018..a6bff5d 100644 --- a/components/aes67_web/aes67_web.c +++ b/components/aes67_web/aes67_web.c @@ -1,5 +1,7 @@ #include "aes67_web.h" +#include "aes67_cfg.h" + #include "esp_log.h" #include "esp_system.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_end[] asm("_binary_index_html_end"); +esp_err_t cfg_web_register(void); // cfg.c + static httpd_handle_t s_httpd; static status_cb_t s_status_cbs[MAX_STATUS_CBS]; 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 err = cfg_web_register(); + if (err != ESP_OK) { + return err; + } + httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); cfg.max_uri_handlers = MAX_URIS; cfg.stack_size = 8192; cfg.lru_purge_enable = true; 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) { ESP_LOGE(TAG, "httpd start failed: %s", esp_err_to_name(err)); return err; @@ -116,7 +125,7 @@ esp_err_t aes67_web_start(void) 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); return ESP_OK; } diff --git a/components/aes67_web/cfg.c b/components/aes67_web/cfg.c new file mode 100644 index 0000000..143d204 --- /dev/null +++ b/components/aes67_web/cfg.c @@ -0,0 +1,472 @@ +#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; +} + +/* ----- 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; +} diff --git a/components/aes67_web/include/aes67_cfg.h b/components/aes67_web/include/aes67_cfg.h new file mode 100644 index 0000000..6f464ea --- /dev/null +++ b/components/aes67_web/include/aes67_cfg.h @@ -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 +#include + +#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); diff --git a/components/aes67_web/include/aes67_web.h b/components/aes67_web/include/aes67_web.h index f782ac1..db2ac71 100644 --- a/components/aes67_web/include/aes67_web.h +++ b/components/aes67_web/include/aes67_web.h @@ -13,7 +13,7 @@ typedef void (*status_cb_t)(cJSON *status); esp_err_t status_register(status_cb_t cb); 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); // Send a cJSON object as the response body (application/json). diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 22726e7..4053113 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,4 +1,5 @@ -idf_component_register(SRCS "main.c" +idf_component_register(SRCS "main.c" "project_cfg.c" INCLUDE_DIRS "." 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) diff --git a/main/main.c b/main/main.c index 371ef8d..5c617eb 100644 --- a/main/main.c +++ b/main/main.c @@ -1,11 +1,14 @@ #include "aes67_board.h" #include "aes67_health.h" #include "aes67_net.h" +#include "aes67_ptp.h" +#include "aes67_syslog.h" #include "aes67_tx.h" #include "aes67_web.h" #include "esp_app_desc.h" #include "esp_chip_info.h" #include "esp_log.h" +#include "project_cfg.h" static const char *TAG = "main"; @@ -20,10 +23,15 @@ void app_main(void) ESP_LOGI(TAG, "chip rev v%d.%d, %d cores", chip.revision / 100, chip.revision % 100, chip.cores); + project_cfg_defaults(); + esp_eth_handle_t eth; ESP_ERROR_CHECK(aes67_board_eth_init(ð)); ESP_ERROR_CHECK(aes67_net_init(eth)); ESP_ERROR_CHECK(aes67_health_init()); + ESP_ERROR_CHECK(aes67_ptp_init()); ESP_ERROR_CHECK(aes67_tx_init()); + ESP_ERROR_CHECK(aes67_syslog_init()); + project_cfg_register(); ESP_ERROR_CHECK(aes67_web_start()); } diff --git a/main/project_cfg.c b/main/project_cfg.c new file mode 100644 index 0000000..8cc561d --- /dev/null +++ b/main/project_cfg.c @@ -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)); +} diff --git a/main/project_cfg.h b/main/project_cfg.h new file mode 100644 index 0000000..8d41e95 --- /dev/null +++ b/main/project_cfg.h @@ -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);