Compare commits
20 Commits
221b95a950
...
004cc6e212
| Author | SHA1 | Date | |
|---|---|---|---|
| 004cc6e212 | |||
| 9af6c53294 | |||
| 685060e12c | |||
| e2f4d81326 | |||
| 880437948d | |||
| 6090ceb239 | |||
| f259c5d554 | |||
| ad5a8b7545 | |||
| bbc5729cc1 | |||
| 2403a6b64b | |||
| cbfc37cde6 | |||
| 07e16e6163 | |||
| 30c32e702b | |||
| 979420a6bb | |||
| 8e52401e9c | |||
| c2621f192a | |||
| 35275b8ccf | |||
| 69b4e18118 | |||
| b391b962a7 | |||
| bbfd3506bf |
@@ -0,0 +1,5 @@
|
|||||||
|
build/
|
||||||
|
sdkconfig
|
||||||
|
sdkconfig.old
|
||||||
|
managed_components/
|
||||||
|
build-*/
|
||||||
@@ -23,8 +23,15 @@ Repo: https://gitea.apointless.space/bsncubed/aes67-ESP32-P4
|
|||||||
- `idf.py set-target esp32p4`, `idf.py build`, `idf.py -p <PORT> flash monitor`
|
- `idf.py set-target esp32p4`, `idf.py build`, `idf.py -p <PORT> flash monitor`
|
||||||
- Before first build: run `esptool.py chip_id` and `esptool.py flash_id`.
|
- Before first build: run `esptool.py chip_id` and `esptool.py flash_id`.
|
||||||
- Chip revision < v3.0 (engineering silicon, seen on some of these boards) needs `CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y`.
|
- Chip revision < v3.0 (engineering silicon, seen on some of these boards) needs `CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y`.
|
||||||
- Flash size is unconfirmed (Waveshare says 32 MB, ESPHome says 16 MB). Set it from the flash_id result.
|
Our board: **v1.3** (set in sdkconfig.defaults, min rev v1.0).
|
||||||
|
- Our board: **32 MB** flash (GigaDevice c8/4019). App slots must stay below 16 MB (cache mapping above 16 MB is experimental in IDF).
|
||||||
- Embed `web/index.html` via `EMBED_TXTFILES` in `aes67_web`.
|
- Embed `web/index.html` via `EMBED_TXTFILES` in `aes67_web`.
|
||||||
|
- Flash over the network (normal way since step 2b; keep USB for recovery):
|
||||||
|
`curl -f --data-binary @build/aes67_p4.bin -H 'Content-Type: application/octet-stream' http://p4-aes67/api/ota`
|
||||||
|
The board reboots into the new image on trial; check `GET /api/ota` shows the new version with `pending_verify: false`.
|
||||||
|
- Serial: opening /dev/ttyACM0 can reset the board. Don't open it while an OTA image is on trial (a reset then counts as a failed boot and rolls back).
|
||||||
|
- Rollback test build (self-test always fails), in its own build dir:
|
||||||
|
`idf.py -B build-selftest-fail -DSDKCONFIG=build-selftest-fail/sdkconfig -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.selftest_fail" build`
|
||||||
|
|
||||||
## Board quick reference (full details in docs)
|
## Board quick reference (full details in docs)
|
||||||
- Ethernet: IP101GRI, RMII, PHY addr 1, ref clock from PHY into GPIO50 (EMAC_CLK_EXT_IN).
|
- Ethernet: IP101GRI, RMII, PHY addr 1, ref clock from PHY into GPIO50 (EMAC_CLK_EXT_IN).
|
||||||
@@ -33,11 +40,12 @@ Repo: https://gitea.apointless.space/bsncubed/aes67-ESP32-P4
|
|||||||
- On-die temperature sensor only (driver/temperature_sensor.h).
|
- On-die temperature sensor only (driver/temperature_sensor.h).
|
||||||
|
|
||||||
## Build order / status
|
## Build order / status
|
||||||
- [ ] 0. Check chip revision and flash size; create IDF project and empty component stubs (layout in aes67-core-base.md). Set up the OTA partition table (two app slots, no factory) and PROJECT_VER from git now, so the layout never changes later.
|
- [x] 0. Check chip revision and flash size; create IDF project and empty component stubs (layout in aes67-core-base.md). Set up the OTA partition table (two app slots, no factory) and PROJECT_VER from git now, so the layout never changes later.
|
||||||
- [ ] 1. Ethernet: IP101 up, DHCP, IP logged. Ping works.
|
- [x] 1. Ethernet: IP101 up, DHCP, IP logged. Ping works.
|
||||||
- [ ] 2. Web server + config store (cJSON in NVS) + embedded index.html; /api/config, /api/status (stub values), /api/reboot.
|
- [x] 2. Web server + config store (cJSON in NVS) + embedded index.html; /api/config, /api/status (stub values), /api/reboot.
|
||||||
- [ ] 2b. Firmware update: /api/ota upload + rollback self-test. Test: update to a new build, then deliberately flash a build that fails its self-test and confirm it rolls back. After this, flash over the network; keep USB for recovery.
|
- [x] 2a. Finding the device: mDNS (hostname.local + _http._tcp), then LLDP (switch shows name + IP).
|
||||||
- [ ] 3. PTP TimeReceiver: lock to an existing GM (Riedel), fill `status.ptp`. Confirm EMAC hardware timestamps work. First check whether the installed ESP-IDF has a PTP example/component for the P4 before writing one.
|
- [x] 2b. Firmware update: /api/ota upload + rollback self-test. Test: update to a new build, then deliberately flash a build that fails its self-test and confirm it rolls back. After this, flash over the network; keep USB for recovery.
|
||||||
|
- [x] 3. PTP TimeReceiver: lock to an existing GM (Riedel), fill `status.ptp`. Confirm EMAC hardware timestamps work. First check whether the installed ESP-IDF has a PTP example/component for the P4 before writing one.
|
||||||
- [ ] 4. AES67 TX with a 1 kHz test tone, PTP-paced; /stream.sdp. Verify: import SDP on a Riedel Artist 4-wire AES67 port, and check packets/timestamps in Wireshark.
|
- [ ] 4. AES67 TX with a 1 kHz test tone, PTP-paced; /stream.sdp. Verify: import SDP on a Riedel Artist 4-wire AES67 port, and check packets/timestamps in Wireshark.
|
||||||
- [ ] 5. SAP discovery, then syslog, then health/temperatures, then VLAN split (one at a time).
|
- [ ] 5. SAP discovery, then syslog, then health/temperatures, then VLAN split (one at a time).
|
||||||
- [ ] 6. PTP TimeTransmitter: BMCA roles (auto/master), hybrid mode.
|
- [ ] 6. PTP TimeTransmitter: BMCA roles (auto/master), hybrid mode.
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
|
||||||
|
# Firmware version = git describe (tags, dirty flag). Reported by esp_app_get_description()
|
||||||
|
# and in status.fw / GET /api/ota. CMake re-runs when git's HEAD log or index changes (commit,
|
||||||
|
# checkout, add), so the version follows. A new tag or unstaged edits need `idf.py reconfigure`.
|
||||||
|
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
||||||
|
${CMAKE_CURRENT_LIST_DIR}/.git/logs/HEAD
|
||||||
|
${CMAKE_CURRENT_LIST_DIR}/.git/index)
|
||||||
|
execute_process(
|
||||||
|
COMMAND git describe --tags --always --dirty
|
||||||
|
WORKING_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}
|
||||||
|
OUTPUT_VARIABLE PROJECT_VER
|
||||||
|
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||||
|
ERROR_QUIET)
|
||||||
|
if(NOT PROJECT_VER)
|
||||||
|
set(PROJECT_VER "0.0.0-nogit")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
project(aes67_p4)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
idf_component_register(SRCS "aes67_board.c"
|
||||||
|
INCLUDE_DIRS "include"
|
||||||
|
REQUIRES esp_eth)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#include "aes67_board.h"
|
||||||
|
|
||||||
|
#include "esp_eth.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "boards/waveshare_esp32p4_eth.h"
|
||||||
|
|
||||||
|
static const char *TAG = "board";
|
||||||
|
|
||||||
|
esp_err_t aes67_board_eth_init(esp_eth_handle_t *out_eth)
|
||||||
|
{
|
||||||
|
eth_mac_config_t mac_cfg = ETH_MAC_DEFAULT_CONFIG();
|
||||||
|
eth_esp32_emac_config_t emac_cfg = ETH_ESP32_EMAC_DEFAULT_CONFIG();
|
||||||
|
emac_cfg.smi_gpio.mdc_num = BOARD_ETH_MDC;
|
||||||
|
emac_cfg.smi_gpio.mdio_num = BOARD_ETH_MDIO;
|
||||||
|
emac_cfg.interface = EMAC_DATA_INTERFACE_RMII;
|
||||||
|
emac_cfg.clock_config.rmii.clock_mode = EMAC_CLK_EXT_IN;
|
||||||
|
emac_cfg.clock_config.rmii.clock_gpio = (emac_rmii_clock_gpio_t)BOARD_ETH_REF_CLK;
|
||||||
|
emac_cfg.emac_dataif_gpio.rmii.tx_en_num = BOARD_ETH_TX_EN;
|
||||||
|
emac_cfg.emac_dataif_gpio.rmii.txd0_num = BOARD_ETH_TXD0;
|
||||||
|
emac_cfg.emac_dataif_gpio.rmii.txd1_num = BOARD_ETH_TXD1;
|
||||||
|
emac_cfg.emac_dataif_gpio.rmii.crs_dv_num = BOARD_ETH_CRS_DV;
|
||||||
|
emac_cfg.emac_dataif_gpio.rmii.rxd0_num = BOARD_ETH_RXD0;
|
||||||
|
emac_cfg.emac_dataif_gpio.rmii.rxd1_num = BOARD_ETH_RXD1;
|
||||||
|
|
||||||
|
eth_phy_config_t phy_cfg = ETH_PHY_DEFAULT_CONFIG();
|
||||||
|
phy_cfg.phy_addr = BOARD_ETH_PHY_ADDR;
|
||||||
|
phy_cfg.reset_gpio_num = BOARD_ETH_PHY_RST;
|
||||||
|
|
||||||
|
esp_eth_mac_t *mac = esp_eth_mac_new_esp32(&emac_cfg, &mac_cfg);
|
||||||
|
esp_eth_phy_t *phy = esp_eth_phy_new_ip101(&phy_cfg);
|
||||||
|
if (!mac || !phy) {
|
||||||
|
ESP_LOGE(TAG, "EMAC/PHY create failed");
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_eth_config_t eth_cfg = ETH_DEFAULT_CONFIG(mac, phy);
|
||||||
|
esp_err_t err = esp_eth_driver_install(ð_cfg, out_eth);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "driver install failed: %s", esp_err_to_name(err));
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "%s: IP101 at PHY addr %d, RMII", BOARD_NAME, BOARD_ETH_PHY_ADDR);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// aes67_board: Board pin profiles (waveshare_esp32p4_eth.h, ...) and Ethernet init.
|
||||||
|
// Core component: must not depend on main/ (project code).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include "esp_eth_driver.h"
|
||||||
|
|
||||||
|
// Create and install the Ethernet driver (EMAC + PHY) for this board. Does not start it.
|
||||||
|
esp_err_t aes67_board_eth_init(esp_eth_handle_t *out_eth);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Waveshare ESP32-P4-ETH (PoE): pin profile.
|
||||||
|
// IP101GRI PHY over RMII, 50 MHz ref clock from the PHY into the EMAC.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#define BOARD_NAME "Waveshare ESP32-P4-ETH"
|
||||||
|
|
||||||
|
#define BOARD_ETH_PHY_ADDR 1
|
||||||
|
#define BOARD_ETH_PHY_RST 51 // PHY reset/power
|
||||||
|
#define BOARD_ETH_MDC 31
|
||||||
|
#define BOARD_ETH_MDIO 52
|
||||||
|
#define BOARD_ETH_REF_CLK 50 // EMAC_CLK_EXT_IN
|
||||||
|
#define BOARD_ETH_TX_EN 49
|
||||||
|
#define BOARD_ETH_TXD0 34
|
||||||
|
#define BOARD_ETH_TXD1 35
|
||||||
|
#define BOARD_ETH_CRS_DV 28
|
||||||
|
#define BOARD_ETH_RXD0 29
|
||||||
|
#define BOARD_ETH_RXD1 30
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
idf_component_register(SRCS "aes67_health.c"
|
||||||
|
INCLUDE_DIRS "include"
|
||||||
|
PRIV_REQUIRES aes67_web esp_app_format esp_timer heap)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#include "aes67_health.h"
|
||||||
|
|
||||||
|
#include "aes67_web.h"
|
||||||
|
#include "esp_app_desc.h"
|
||||||
|
#include "esp_heap_caps.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
|
||||||
|
static void health_status(cJSON *st)
|
||||||
|
{
|
||||||
|
cJSON_AddStringToObject(st, "fw", esp_app_get_description()->version);
|
||||||
|
cJSON_AddNumberToObject(st, "uptime_s", (double)(esp_timer_get_time() / 1000000));
|
||||||
|
cJSON_AddNumberToObject(st, "heap_free", heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
|
||||||
|
cJSON_AddNumberToObject(st, "psram_free", heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t aes67_health_init(void)
|
||||||
|
{
|
||||||
|
return status_register(health_status);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// aes67_health: Uptime, heap/PSRAM, temperature sensors (temps[] with min/max/warn).
|
||||||
|
// Core component: must not depend on main/ (project code).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
|
||||||
|
// Registers the health fields (fw, uptime_s, heap_free, psram_free) in /api/status.
|
||||||
|
esp_err_t aes67_health_init(void);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
idf_component_register(SRCS "aes67_net.c" "lldp.c"
|
||||||
|
INCLUDE_DIRS "include"
|
||||||
|
REQUIRES esp_eth esp_netif esp_event
|
||||||
|
PRIV_REQUIRES aes67_web lwip espressif__mdns esp_app_format esp_timer)
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#include "aes67_net.h"
|
||||||
|
|
||||||
|
#include <ctype.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "aes67_cfg.h"
|
||||||
|
#include "aes67_web.h"
|
||||||
|
#include "esp_eth.h"
|
||||||
|
#include "esp_event.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_netif.h"
|
||||||
|
#include "lldp.h"
|
||||||
|
#include "mdns.h"
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
static void eth_event(void *arg, esp_event_base_t base, int32_t id, void *data)
|
||||||
|
{
|
||||||
|
esp_eth_handle_t eth = *(esp_eth_handle_t *)data;
|
||||||
|
|
||||||
|
switch (id) {
|
||||||
|
case ETHERNET_EVENT_CONNECTED: {
|
||||||
|
uint8_t mac[6];
|
||||||
|
eth_speed_t speed;
|
||||||
|
eth_duplex_t duplex;
|
||||||
|
esp_eth_ioctl(eth, ETH_CMD_G_MAC_ADDR, mac);
|
||||||
|
esp_eth_ioctl(eth, ETH_CMD_G_SPEED, &speed);
|
||||||
|
esp_eth_ioctl(eth, ETH_CMD_G_DUPLEX_MODE, &duplex);
|
||||||
|
ESP_LOGI(TAG, "link up: %s Mbps %s duplex, MAC %02x:%02x:%02x:%02x:%02x:%02x",
|
||||||
|
speed == ETH_SPEED_100M ? "100" : "10", duplex == ETH_DUPLEX_FULL ? "full" : "half",
|
||||||
|
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||||||
|
snprintf(s_link, sizeof(s_link), "up, %s Mbps %s", speed == ETH_SPEED_100M ? "100" : "10",
|
||||||
|
duplex == ETH_DUPLEX_FULL ? "full" : "half");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ETHERNET_EVENT_DISCONNECTED:
|
||||||
|
ESP_LOGW(TAG, "link down");
|
||||||
|
snprintf(s_link, sizeof(s_link), "down");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ip_event(void *arg, esp_event_base_t base, int32_t id, void *data)
|
||||||
|
{
|
||||||
|
const ip_event_got_ip_t *ev = data;
|
||||||
|
ESP_LOGI(TAG, "got IP " IPSTR " mask " IPSTR " gw " IPSTR,
|
||||||
|
IP2STR(&ev->ip_info.ip), IP2STR(&ev->ip_info.netmask), IP2STR(&ev->ip_info.gw));
|
||||||
|
lldp_changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mDNS: <hostname>.local plus the web UI as _http._tcp.
|
||||||
|
static void mdns_setup(void)
|
||||||
|
{
|
||||||
|
cJSON *net = cfg_get("net");
|
||||||
|
const char *host = cJSON_GetObjectItemCaseSensitive(net, "hostname")->valuestring;
|
||||||
|
esp_err_t err = mdns_init();
|
||||||
|
if (err == ESP_OK) {
|
||||||
|
mdns_hostname_set(host);
|
||||||
|
mdns_instance_name_set(host);
|
||||||
|
mdns_service_add(NULL, "_http", "_tcp", 80, NULL, 0);
|
||||||
|
ESP_LOGI(TAG, "mDNS: %s.local, _http._tcp port 80", host);
|
||||||
|
} else {
|
||||||
|
ESP_LOGE(TAG, "mDNS init failed: %s", esp_err_to_name(err));
|
||||||
|
}
|
||||||
|
cJSON_Delete(net);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void net_apply(const cJSON *g)
|
||||||
|
{
|
||||||
|
const char *host = cJSON_GetObjectItemCaseSensitive(g, "hostname")->valuestring;
|
||||||
|
// DHCP server sees the new name at the next lease renewal.
|
||||||
|
esp_netif_set_hostname(s_netif, host);
|
||||||
|
if (mdns_hostname_set(host) == ESP_OK && mdns_instance_name_set(host) == ESP_OK) {
|
||||||
|
ESP_LOGI(TAG, "mDNS: now %s.local", host);
|
||||||
|
}
|
||||||
|
lldp_changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void net_status(cJSON *st)
|
||||||
|
{
|
||||||
|
char ip[16] = "", mac_s[18] = "";
|
||||||
|
esp_netif_ip_info_t info;
|
||||||
|
if (esp_netif_get_ip_info(s_netif, &info) == ESP_OK && info.ip.addr) {
|
||||||
|
snprintf(ip, sizeof(ip), IPSTR, IP2STR(&info.ip));
|
||||||
|
}
|
||||||
|
uint8_t mac[6];
|
||||||
|
if (esp_netif_get_mac(s_netif, mac) == ESP_OK) {
|
||||||
|
snprintf(mac_s, sizeof(mac_s), "%02x:%02x:%02x:%02x:%02x:%02x",
|
||||||
|
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||||||
|
}
|
||||||
|
// Single untagged interface until the VLAN split: AES67 and web share it.
|
||||||
|
cJSON_AddStringToObject(st, "ip", ip);
|
||||||
|
cJSON_AddStringToObject(st, "aes67_ip", ip);
|
||||||
|
cJSON_AddStringToObject(st, "mac", mac_s);
|
||||||
|
cJSON_AddStringToObject(st, "link", s_link);
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t aes67_net_init(esp_eth_handle_t eth)
|
||||||
|
{
|
||||||
|
// Applied so far: hostname (DHCP, mDNS, LLDP). Static IP and VLAN come later.
|
||||||
|
ESP_ERROR_CHECK(cfg_register("net", NET_DEFAULTS, net_validate, net_apply));
|
||||||
|
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) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_netif_config_t netif_cfg = ESP_NETIF_DEFAULT_ETH();
|
||||||
|
s_netif = esp_netif_new(&netif_cfg);
|
||||||
|
ESP_ERROR_CHECK(esp_netif_attach(s_netif, esp_eth_new_netif_glue(eth)));
|
||||||
|
|
||||||
|
ESP_ERROR_CHECK(esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, eth_event, NULL));
|
||||||
|
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_GOT_IP, ip_event, NULL));
|
||||||
|
|
||||||
|
// Hostname in DHCP requests (option 12), so the router's lease list shows it.
|
||||||
|
cJSON *net = cfg_get("net");
|
||||||
|
ESP_ERROR_CHECK(esp_netif_set_hostname(s_netif, cJSON_GetObjectItemCaseSensitive(net, "hostname")->valuestring));
|
||||||
|
cJSON_Delete(net);
|
||||||
|
mdns_setup();
|
||||||
|
ESP_ERROR_CHECK(lldp_start(eth, s_netif));
|
||||||
|
status_register(net_status);
|
||||||
|
return esp_eth_start(eth);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dependencies:
|
||||||
|
espressif/mdns: "^1.13.1"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// aes67_net: Netif setup, optional VLAN split, DSCP/PCP helpers.
|
||||||
|
// Core component: must not depend on main/ (project code).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include "esp_eth_driver.h"
|
||||||
|
|
||||||
|
// Attach a netif (DHCP client) to the Ethernet driver and start it. Logs link and IP events.
|
||||||
|
esp_err_t aes67_net_init(esp_eth_handle_t eth);
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// LLDP (IEEE 802.1AB) transmit only: tells the switch our name and management IP.
|
||||||
|
#include "lldp.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "aes67_cfg.h"
|
||||||
|
#include "esp_app_desc.h"
|
||||||
|
#include "esp_eth.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
|
||||||
|
#define LLDP_INTERVAL_S 30
|
||||||
|
#define LLDP_TTL_S (4 * LLDP_INTERVAL_S)
|
||||||
|
#define FRAME_MAX 512
|
||||||
|
#define FRAME_MIN 60 // without FCS
|
||||||
|
|
||||||
|
enum { TLV_END, TLV_CHASSIS_ID, TLV_PORT_ID, TLV_TTL, TLV_PORT_DESC, TLV_SYS_NAME, TLV_SYS_DESC,
|
||||||
|
TLV_SYS_CAP, TLV_MGMT_ADDR };
|
||||||
|
|
||||||
|
static const char *TAG = "lldp";
|
||||||
|
static const uint8_t LLDP_MCAST[6] = { 0x01, 0x80, 0xc2, 0x00, 0x00, 0x0e };
|
||||||
|
|
||||||
|
static esp_eth_handle_t s_eth;
|
||||||
|
static esp_netif_t *s_netif;
|
||||||
|
static esp_timer_handle_t s_timer;
|
||||||
|
static bool s_logged;
|
||||||
|
|
||||||
|
static size_t tlv(uint8_t *p, int type, const void *val, size_t len)
|
||||||
|
{
|
||||||
|
p[0] = (type << 1) | ((len >> 8) & 1);
|
||||||
|
p[1] = len & 0xff;
|
||||||
|
memcpy(p + 2, val, len);
|
||||||
|
return 2 + len;
|
||||||
|
}
|
||||||
|
|
||||||
|
static size_t tlv_str(uint8_t *p, int type, const char *s)
|
||||||
|
{
|
||||||
|
size_t len = strlen(s);
|
||||||
|
return tlv(p, type, s, len > 255 ? 255 : len);
|
||||||
|
}
|
||||||
|
|
||||||
|
void lldp_send(void)
|
||||||
|
{
|
||||||
|
if (!s_eth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uint8_t mac[6];
|
||||||
|
if (esp_eth_ioctl(s_eth, ETH_CMD_G_MAC_ADDR, mac) != ESP_OK) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cJSON *net = cfg_get("net");
|
||||||
|
const char *host = cJSON_GetObjectItemCaseSensitive(net, "hostname")->valuestring;
|
||||||
|
const esp_app_desc_t *app = esp_app_get_description();
|
||||||
|
|
||||||
|
uint8_t f[FRAME_MAX] = { 0 };
|
||||||
|
size_t n = 0;
|
||||||
|
memcpy(f, LLDP_MCAST, 6);
|
||||||
|
memcpy(f + 6, mac, 6);
|
||||||
|
f[12] = 0x88;
|
||||||
|
f[13] = 0xcc;
|
||||||
|
n = 14;
|
||||||
|
|
||||||
|
uint8_t id[7] = { 4 }; // chassis ID subtype 4: MAC address
|
||||||
|
memcpy(id + 1, mac, 6);
|
||||||
|
n += tlv(f + n, TLV_CHASSIS_ID, id, sizeof(id));
|
||||||
|
id[0] = 3; // port ID subtype 3: MAC address
|
||||||
|
n += tlv(f + n, TLV_PORT_ID, id, sizeof(id));
|
||||||
|
uint8_t ttl[2] = { LLDP_TTL_S >> 8, LLDP_TTL_S & 0xff };
|
||||||
|
n += tlv(f + n, TLV_TTL, ttl, sizeof(ttl));
|
||||||
|
n += tlv_str(f + n, TLV_PORT_DESC, "eth0");
|
||||||
|
n += tlv_str(f + n, TLV_SYS_NAME, host);
|
||||||
|
char desc[128];
|
||||||
|
snprintf(desc, sizeof(desc), "AES67 device, %s %s (ESP-IDF %s)", app->project_name, app->version, app->idf_ver);
|
||||||
|
n += tlv_str(f + n, TLV_SYS_DESC, desc);
|
||||||
|
uint8_t cap[4] = { 0x00, 0x80, 0x00, 0x80 }; // capabilities: station only (supported, enabled)
|
||||||
|
n += tlv(f + n, TLV_SYS_CAP, cap, sizeof(cap));
|
||||||
|
|
||||||
|
esp_netif_ip_info_t ip = { 0 };
|
||||||
|
bool have_ip = esp_netif_get_ip_info(s_netif, &ip) == ESP_OK && ip.ip.addr;
|
||||||
|
if (have_ip) {
|
||||||
|
// addr len 5, subtype IPv4, address, interface subtype ifIndex, ifIndex 1, OID len 0
|
||||||
|
uint8_t m[12] = { 5, 1 };
|
||||||
|
memcpy(m + 2, &ip.ip.addr, 4); // already network byte order
|
||||||
|
m[6] = 2;
|
||||||
|
m[10] = 1;
|
||||||
|
n += tlv(f + n, TLV_MGMT_ADDR, m, sizeof(m));
|
||||||
|
}
|
||||||
|
n += tlv(f + n, TLV_END, NULL, 0);
|
||||||
|
if (n < FRAME_MIN) {
|
||||||
|
n = FRAME_MIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t err = esp_eth_transmit(s_eth, f, n);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "transmit failed: %s", esp_err_to_name(err));
|
||||||
|
} else if (!s_logged || !have_ip) {
|
||||||
|
// Log once per name/IP change, not every 30 s.
|
||||||
|
ESP_LOGI(TAG, "sent: name %s, mgmt " IPSTR ", %u bytes", host, IP2STR(&ip.ip), (unsigned)n);
|
||||||
|
s_logged = have_ip;
|
||||||
|
}
|
||||||
|
cJSON_Delete(net);
|
||||||
|
}
|
||||||
|
|
||||||
|
void lldp_changed(void)
|
||||||
|
{
|
||||||
|
s_logged = false;
|
||||||
|
lldp_send();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void timer_cb(void *arg)
|
||||||
|
{
|
||||||
|
lldp_send();
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t lldp_start(esp_eth_handle_t eth, esp_netif_t *netif)
|
||||||
|
{
|
||||||
|
s_eth = eth;
|
||||||
|
s_netif = netif;
|
||||||
|
const esp_timer_create_args_t args = { .callback = timer_cb, .name = "lldp" };
|
||||||
|
esp_err_t err = esp_timer_create(&args, &s_timer);
|
||||||
|
if (err == ESP_OK) {
|
||||||
|
err = esp_timer_start_periodic(s_timer, LLDP_INTERVAL_S * 1000000ULL);
|
||||||
|
}
|
||||||
|
return err;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esp_eth_driver.h"
|
||||||
|
#include "esp_netif.h"
|
||||||
|
|
||||||
|
// Send an LLDPDU every 30 s (TTL 120 s).
|
||||||
|
esp_err_t lldp_start(esp_eth_handle_t eth, esp_netif_t *netif);
|
||||||
|
// Send now (e.g. after an IP or hostname change) and log the next one.
|
||||||
|
void lldp_changed(void);
|
||||||
|
void lldp_send(void);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
idf_component_register(SRCS "aes67_ota.c"
|
||||||
|
INCLUDE_DIRS "include"
|
||||||
|
PRIV_REQUIRES aes67_web app_update bootloader_support esp_app_format
|
||||||
|
esp_http_client esp_netif esp_timer hal)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
menu "AES67 OTA"
|
||||||
|
|
||||||
|
config AES67_OTA_SELFTEST_FORCE_FAIL
|
||||||
|
bool "Force the OTA self-test to fail (rollback test only)"
|
||||||
|
default n
|
||||||
|
help
|
||||||
|
Test builds only: the self-test always fails, so a freshly installed
|
||||||
|
image rolls back. Build with sdkconfig.selftest_fail in a separate
|
||||||
|
build directory; never enable in the normal build.
|
||||||
|
|
||||||
|
endmenu
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
#include "aes67_ota.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "aes67_web.h"
|
||||||
|
#include "esp_app_desc.h"
|
||||||
|
#include "esp_app_format.h"
|
||||||
|
#include "esp_http_client.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_netif.h"
|
||||||
|
#include "esp_ota_ops.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "hal/efuse_hal.h"
|
||||||
|
|
||||||
|
#define CHUNK 4096
|
||||||
|
#define SELFTEST_TIMEOUT_S 60
|
||||||
|
// Image header, first segment header, then the app description.
|
||||||
|
#define DESC_OFFSET (sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t))
|
||||||
|
#define HEAD_LEN (DESC_OFFSET + sizeof(esp_app_desc_t))
|
||||||
|
|
||||||
|
static const char *TAG = "ota";
|
||||||
|
|
||||||
|
static bool is_pending(void)
|
||||||
|
{
|
||||||
|
esp_ota_img_states_t st;
|
||||||
|
return esp_ota_get_state_partition(esp_ota_get_running_partition(), &st) == ESP_OK &&
|
||||||
|
st == ESP_OTA_IMG_PENDING_VERIFY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- GET /api/ota ----- */
|
||||||
|
|
||||||
|
static esp_err_t ota_get(httpd_req_t *req)
|
||||||
|
{
|
||||||
|
const esp_app_desc_t *app = esp_app_get_description();
|
||||||
|
const esp_partition_t *run = esp_ota_get_running_partition();
|
||||||
|
const esp_partition_t *other = esp_ota_get_next_update_partition(NULL);
|
||||||
|
char built[40];
|
||||||
|
snprintf(built, sizeof(built), "%s %s", app->date, app->time);
|
||||||
|
|
||||||
|
cJSON *o = cJSON_CreateObject();
|
||||||
|
cJSON_AddStringToObject(o, "version", app->version);
|
||||||
|
cJSON_AddStringToObject(o, "project", app->project_name);
|
||||||
|
cJSON_AddStringToObject(o, "build_date", built);
|
||||||
|
cJSON_AddStringToObject(o, "idf", app->idf_ver);
|
||||||
|
cJSON_AddStringToObject(o, "running", run->label);
|
||||||
|
// Only a bootable image counts as "previous" (not a half-written upload or a rolled-back
|
||||||
|
// one). With two slots, rollback is possible exactly when the other slot is bootable.
|
||||||
|
bool can_rollback = esp_ota_check_rollback_is_possible();
|
||||||
|
esp_app_desc_t prev;
|
||||||
|
if (can_rollback && other && esp_ota_get_partition_description(other, &prev) == ESP_OK) {
|
||||||
|
cJSON_AddStringToObject(o, "previous", other->label);
|
||||||
|
cJSON_AddStringToObject(o, "previous_version", prev.version);
|
||||||
|
}
|
||||||
|
cJSON_AddBoolToObject(o, "pending_verify", is_pending());
|
||||||
|
cJSON_AddBoolToObject(o, "can_rollback", can_rollback);
|
||||||
|
esp_err_t err = web_send_json(req, o);
|
||||||
|
cJSON_Delete(o);
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- POST /api/ota ----- */
|
||||||
|
|
||||||
|
// Checks on the first bytes, before anything is written to flash.
|
||||||
|
static const char *check_head(const uint8_t *buf)
|
||||||
|
{
|
||||||
|
const esp_image_header_t *h = (const esp_image_header_t *)buf;
|
||||||
|
const esp_app_desc_t *d = (const esp_app_desc_t *)(buf + DESC_OFFSET);
|
||||||
|
static char msg[96];
|
||||||
|
|
||||||
|
if (h->magic != ESP_IMAGE_HEADER_MAGIC || d->magic_word != ESP_APP_DESC_MAGIC_WORD) {
|
||||||
|
return "not an ESP-IDF app image";
|
||||||
|
}
|
||||||
|
if (h->chip_id != CONFIG_IDF_FIRMWARE_CHIP_ID) {
|
||||||
|
return "image is for a different chip";
|
||||||
|
}
|
||||||
|
if (strncmp(d->project_name, esp_app_get_description()->project_name, sizeof(d->project_name)) != 0) {
|
||||||
|
snprintf(msg, sizeof(msg), "wrong project '%.32s'", d->project_name);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
unsigned rev = efuse_hal_chip_revision();
|
||||||
|
if (rev < h->min_chip_rev_full || rev > h->max_chip_rev_full) {
|
||||||
|
snprintf(msg, sizeof(msg), "image supports chip rev v%u.%u-v%u.%u, this chip is v%u.%u",
|
||||||
|
h->min_chip_rev_full / 100, h->min_chip_rev_full % 100,
|
||||||
|
h->max_chip_rev_full / 100, h->max_chip_rev_full % 100, rev / 100, rev % 100);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t reject(httpd_req_t *req, const char *msg)
|
||||||
|
{
|
||||||
|
ESP_LOGW(TAG, "upload rejected: %s", msg);
|
||||||
|
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t ota_post(httpd_req_t *req)
|
||||||
|
{
|
||||||
|
const esp_partition_t *dst = esp_ota_get_next_update_partition(NULL);
|
||||||
|
if (!dst) {
|
||||||
|
return reject(req, "no OTA partition");
|
||||||
|
}
|
||||||
|
if (req->content_len < HEAD_LEN || req->content_len > dst->size) {
|
||||||
|
return reject(req, "image size out of range");
|
||||||
|
}
|
||||||
|
uint8_t *buf = malloc(CHUNK);
|
||||||
|
if (!buf) {
|
||||||
|
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "out of memory");
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "upload %u bytes to %s", (unsigned)req->content_len, dst->label);
|
||||||
|
esp_ota_handle_t ota = 0;
|
||||||
|
size_t total = 0, fill = 0;
|
||||||
|
const char *err_msg = NULL;
|
||||||
|
int64_t t0 = esp_timer_get_time();
|
||||||
|
|
||||||
|
while (total < req->content_len) {
|
||||||
|
int r = httpd_req_recv(req, (char *)buf + fill, CHUNK - fill);
|
||||||
|
if (r == HTTPD_SOCK_ERR_TIMEOUT) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (r <= 0) {
|
||||||
|
err_msg = "connection lost";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
fill += r;
|
||||||
|
total += r;
|
||||||
|
if (!ota) {
|
||||||
|
// Collect the header before deciding anything.
|
||||||
|
if (fill < HEAD_LEN && total < req->content_len) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((err_msg = check_head(buf)) != NULL) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const esp_app_desc_t *d = (const esp_app_desc_t *)(buf + DESC_OFFSET);
|
||||||
|
ESP_LOGI(TAG, "image %.32s %.32s", d->project_name, d->version);
|
||||||
|
if (esp_ota_begin(dst, OTA_WITH_SEQUENTIAL_WRITES, &ota) != ESP_OK) {
|
||||||
|
err_msg = "ota begin failed";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (esp_ota_write(ota, buf, fill) != ESP_OK) {
|
||||||
|
err_msg = "flash write failed";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
fill = 0;
|
||||||
|
}
|
||||||
|
free(buf);
|
||||||
|
|
||||||
|
if (err_msg) {
|
||||||
|
if (ota) {
|
||||||
|
esp_ota_abort(ota);
|
||||||
|
}
|
||||||
|
// Rejected before the whole body was read: close instead of draining it.
|
||||||
|
httpd_resp_set_hdr(req, "Connection", "close");
|
||||||
|
return reject(req, err_msg);
|
||||||
|
}
|
||||||
|
esp_err_t err = esp_ota_end(ota); // verifies the image (checksum/hash, chip)
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
return reject(req, err == ESP_ERR_OTA_VALIDATE_FAILED ? "image verification failed" : "ota end failed");
|
||||||
|
}
|
||||||
|
if (esp_ota_set_boot_partition(dst) != ESP_OK) {
|
||||||
|
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "could not set boot partition");
|
||||||
|
}
|
||||||
|
ESP_LOGW(TAG, "installed to %s in %.1f s, rebooting", dst->label,
|
||||||
|
(esp_timer_get_time() - t0) / 1e6);
|
||||||
|
httpd_resp_sendstr(req, "ok");
|
||||||
|
web_reboot_later(500);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- confirm / rollback ----- */
|
||||||
|
|
||||||
|
static esp_err_t confirm_post(httpd_req_t *req)
|
||||||
|
{
|
||||||
|
if (!is_pending()) {
|
||||||
|
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "running firmware is already confirmed");
|
||||||
|
}
|
||||||
|
esp_ota_mark_app_valid_cancel_rollback();
|
||||||
|
ESP_LOGI(TAG, "firmware confirmed via API");
|
||||||
|
return httpd_resp_sendstr(req, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void rollback_cb(void *arg)
|
||||||
|
{
|
||||||
|
esp_ota_mark_app_invalid_rollback_and_reboot();
|
||||||
|
ESP_LOGE(TAG, "rollback failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t rollback_post(httpd_req_t *req)
|
||||||
|
{
|
||||||
|
if (!esp_ota_check_rollback_is_possible()) {
|
||||||
|
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "no previous firmware to roll back to");
|
||||||
|
}
|
||||||
|
ESP_LOGW(TAG, "rollback requested via API");
|
||||||
|
httpd_resp_sendstr(req, "");
|
||||||
|
const esp_timer_create_args_t args = { .callback = rollback_cb, .name = "rollback" };
|
||||||
|
esp_timer_handle_t t;
|
||||||
|
if (esp_timer_create(&args, &t) == ESP_OK) {
|
||||||
|
esp_timer_start_once(t, 500 * 1000);
|
||||||
|
}
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Self-test after an update ----- */
|
||||||
|
|
||||||
|
static bool web_answers(const esp_netif_ip_info_t *ip)
|
||||||
|
{
|
||||||
|
char url[48];
|
||||||
|
snprintf(url, sizeof(url), "http://" IPSTR "/api/status", IP2STR(&ip->ip));
|
||||||
|
esp_http_client_config_t cfg = { .url = url, .timeout_ms = 3000 };
|
||||||
|
esp_http_client_handle_t c = esp_http_client_init(&cfg);
|
||||||
|
bool ok = c && esp_http_client_perform(c) == ESP_OK && esp_http_client_get_status_code(c) == 200;
|
||||||
|
esp_http_client_cleanup(c);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void selftest_task(void *arg)
|
||||||
|
{
|
||||||
|
const char *fail = NULL;
|
||||||
|
int64_t deadline = esp_timer_get_time() + SELFTEST_TIMEOUT_S * 1000000LL;
|
||||||
|
esp_netif_t *netif = esp_netif_get_handle_from_ifkey("ETH_DEF");
|
||||||
|
bool passed = false;
|
||||||
|
|
||||||
|
while (!passed && esp_timer_get_time() < deadline) {
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||||
|
esp_netif_ip_info_t ip;
|
||||||
|
if (!netif || esp_netif_get_ip_info(netif, &ip) != ESP_OK || !ip.ip.addr) {
|
||||||
|
fail = "no IP address";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!web_answers(&ip)) {
|
||||||
|
fail = "web server not answering";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
passed = true;
|
||||||
|
}
|
||||||
|
#if CONFIG_AES67_OTA_SELFTEST_FORCE_FAIL
|
||||||
|
passed = false;
|
||||||
|
fail = "forced failure (CONFIG_AES67_OTA_SELFTEST_FORCE_FAIL)";
|
||||||
|
#endif
|
||||||
|
if (passed) {
|
||||||
|
esp_ota_mark_app_valid_cancel_rollback();
|
||||||
|
ESP_LOGI(TAG, "self-test passed, firmware confirmed");
|
||||||
|
} else if (is_pending()) { // not confirmed manually in the meantime
|
||||||
|
ESP_LOGE(TAG, "self-test failed (%s), rolling back", fail);
|
||||||
|
esp_ota_mark_app_invalid_rollback_and_reboot();
|
||||||
|
}
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t aes67_ota_init(void)
|
||||||
|
{
|
||||||
|
static const httpd_uri_t uris[] = {
|
||||||
|
{ .uri = "/api/ota", .method = HTTP_GET, .handler = ota_get },
|
||||||
|
{ .uri = "/api/ota", .method = HTTP_POST, .handler = ota_post },
|
||||||
|
{ .uri = "/api/ota/confirm", .method = HTTP_POST, .handler = confirm_post },
|
||||||
|
{ .uri = "/api/ota/rollback", .method = HTTP_POST, .handler = rollback_post },
|
||||||
|
};
|
||||||
|
for (int i = 0; i < sizeof(uris) / sizeof(uris[0]); i++) {
|
||||||
|
esp_err_t err = web_register_uri(&uris[i]);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const esp_app_desc_t *app = esp_app_get_description();
|
||||||
|
ESP_LOGI(TAG, "running %s from %s", app->version, esp_ota_get_running_partition()->label);
|
||||||
|
if (is_pending()) {
|
||||||
|
ESP_LOGW(TAG, "new firmware on trial: self-test (IP + web) within %d s", SELFTEST_TIMEOUT_S);
|
||||||
|
xTaskCreate(selftest_task, "ota_selftest", 4096, NULL, 2, NULL);
|
||||||
|
}
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// aes67_ota: Firmware upload, image checks, rollback self-test.
|
||||||
|
// Core component: must not depend on main/ (project code).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
|
||||||
|
// Registers /api/ota routes. If the running image is pending verification, starts the
|
||||||
|
// self-test: IP address + own web server answering within 60 s, else roll back.
|
||||||
|
esp_err_t aes67_ota_init(void);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
idf_component_register(SRCS "aes67_ptp.c" "ptp_clock.c" "ptp_hw.c"
|
||||||
|
INCLUDE_DIRS "include"
|
||||||
|
REQUIRES esp_eth
|
||||||
|
PRIV_REQUIRES aes67_web esp_netif esp_timer lwip hal soc)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#include "aes67_ptp.h"
|
||||||
|
|
||||||
|
#include "aes67_cfg.h"
|
||||||
|
#include "aes67_web.h"
|
||||||
|
#include "ptp_clock.h"
|
||||||
|
#include "ptp_hw.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 cfg_register("ptp", PTP_DEFAULTS, ptp_validate, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t aes67_ptp_start(esp_eth_handle_t eth)
|
||||||
|
{
|
||||||
|
esp_netif_t *netif = esp_netif_get_handle_from_ifkey("ETH_DEF");
|
||||||
|
esp_err_t err = ptp_hw_init(eth, netif);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
err = ptp_clock_start(netif);
|
||||||
|
if (err == ESP_OK) {
|
||||||
|
err = status_register(ptp_clock_status);
|
||||||
|
}
|
||||||
|
return err;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// aes67_ptp: PTPv2 ordinary clock: BMCA, servo, master, multicast/hybrid, EMAC HW timestamps, stats.
|
||||||
|
// Core component: must not depend on main/ (project code).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include "esp_eth_driver.h"
|
||||||
|
|
||||||
|
// Registers the "ptp" config group. Call early, before the config is used.
|
||||||
|
esp_err_t aes67_ptp_init(void);
|
||||||
|
// Start the EMAC PTP clock and the PTP task on this Ethernet interface.
|
||||||
|
esp_err_t aes67_ptp_start(esp_eth_handle_t eth);
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
#include "ptp_clock.h"
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "aes67_cfg.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_random.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/semphr.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "lwip/sockets.h"
|
||||||
|
#include "ptp_hw.h"
|
||||||
|
|
||||||
|
#define PTP_MCAST "224.0.1.129"
|
||||||
|
#define PTP_EVENT_PORT 319
|
||||||
|
#define PTP_GENERAL_PORT 320
|
||||||
|
#define HDR_LEN 34
|
||||||
|
#define FLAG_TWO_STEP 0x0200
|
||||||
|
#define STEP_NS 1000000 // re-step instead of slewing above 1 ms
|
||||||
|
#define LOCK_NS 1000 // |offset| below this counts as good
|
||||||
|
#define LOCK_GOOD 8 // consecutive good Syncs to lock
|
||||||
|
#define LOCK_BAD 3 // consecutive bad Syncs to unlock
|
||||||
|
#define MAX_DRIFT_PPB 500000.0
|
||||||
|
#define WINDOW 64 // samples for interval/delay statistics
|
||||||
|
#define SUMMARY_US (60 * 1000000LL)
|
||||||
|
|
||||||
|
static const char *TAG = "ptp";
|
||||||
|
static const uint8_t PTP_MCAST_MAC[6] = { 0x01, 0x00, 0x5e, 0x00, 0x01, 0x81 };
|
||||||
|
|
||||||
|
// Rolling window of int64 samples.
|
||||||
|
typedef struct {
|
||||||
|
int64_t v[WINDOW];
|
||||||
|
int n, next;
|
||||||
|
} window_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
bool valid;
|
||||||
|
uint8_t port_id[10]; // sourcePortIdentity of the GM (clockId + port)
|
||||||
|
uint32_t ip; // source address, for hybrid mode later
|
||||||
|
uint8_t p1, cls, acc, p2;
|
||||||
|
uint16_t var;
|
||||||
|
uint8_t gm_id[8];
|
||||||
|
uint16_t steps;
|
||||||
|
uint8_t flags; // flagField octet 1: timeTraceable 0x10, frequencyTraceable 0x20
|
||||||
|
int8_t log_announce;
|
||||||
|
int64_t last_us; // last Announce (esp_timer)
|
||||||
|
} master_t;
|
||||||
|
|
||||||
|
static struct {
|
||||||
|
esp_netif_t *netif;
|
||||||
|
int ev, gen;
|
||||||
|
uint8_t domain, dscp, timeout;
|
||||||
|
uint8_t port_id[10]; // our clockId (EUI-64 from MAC) + port 1
|
||||||
|
master_t gm;
|
||||||
|
// Sync / Follow_Up
|
||||||
|
uint16_t sync_seq;
|
||||||
|
bool sync_pending;
|
||||||
|
int64_t t1, t2, sync_corr;
|
||||||
|
// Delay_Req / Delay_Resp
|
||||||
|
uint16_t dreq_seq;
|
||||||
|
bool dreq_pending;
|
||||||
|
int64_t t3;
|
||||||
|
int8_t log_dreq; // from Delay_Resp logMessageInterval
|
||||||
|
int64_t next_dreq_us;
|
||||||
|
int64_t delay_ns; // mean path delay, 0 = not measured yet
|
||||||
|
// Drift of (t2 - t1) between Syncs: corrects the delay for the time between t2 and t3
|
||||||
|
// while the local clock is not yet syntonised.
|
||||||
|
int64_t raw, prev_raw, prev_t2;
|
||||||
|
double rate;
|
||||||
|
int8_t log_sync; // GM's Sync interval (from Sync logMessageInterval)
|
||||||
|
// Servo (PI, linuxptp-style gains)
|
||||||
|
bool stepped; // clock set to GM time since this GM was selected
|
||||||
|
double drift_ppb; // integral term
|
||||||
|
double freq_ppb; // correction currently applied (positive = faster)
|
||||||
|
int64_t offset_ns;
|
||||||
|
int good, bad;
|
||||||
|
bool locked;
|
||||||
|
// Statistics for status.ptp (guarded by lock)
|
||||||
|
SemaphoreHandle_t lock;
|
||||||
|
window_t sync_iv, announce_iv, delays;
|
||||||
|
int64_t last_announce_us;
|
||||||
|
uint32_t delay_req, delay_resp;
|
||||||
|
uint8_t own_class;
|
||||||
|
int64_t sum_max_ns, sum_start_us;
|
||||||
|
} s;
|
||||||
|
|
||||||
|
static void win_add(window_t *w, int64_t v)
|
||||||
|
{
|
||||||
|
w->v[w->next] = v;
|
||||||
|
w->next = (w->next + 1) % WINDOW;
|
||||||
|
if (w->n < WINDOW) {
|
||||||
|
w->n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void win_clear(window_t *w)
|
||||||
|
{
|
||||||
|
w->n = w->next = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mean, min, max and standard deviation of a window
|
||||||
|
static void win_stats(const window_t *w, double *mean, double *min, double *max, double *sd)
|
||||||
|
{
|
||||||
|
double sum = 0, sq = 0, lo = 0, hi = 0;
|
||||||
|
for (int i = 0; i < w->n; i++) {
|
||||||
|
double x = (double)w->v[i];
|
||||||
|
sum += x;
|
||||||
|
sq += x * x;
|
||||||
|
lo = i ? fmin(lo, x) : x;
|
||||||
|
hi = i ? fmax(hi, x) : x;
|
||||||
|
}
|
||||||
|
double m = w->n ? sum / w->n : 0;
|
||||||
|
*mean = m;
|
||||||
|
if (min) *min = lo;
|
||||||
|
if (max) *max = hi;
|
||||||
|
if (sd) *sd = w->n > 1 ? sqrt(fmax(0, sq / w->n - m * m)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#define LOCKED(stmt) do { xSemaphoreTake(s.lock, portMAX_DELAY); stmt; xSemaphoreGive(s.lock); } while (0)
|
||||||
|
|
||||||
|
static void set_locked(bool locked);
|
||||||
|
|
||||||
|
/* ----- helpers ----- */
|
||||||
|
|
||||||
|
static uint16_t rd16(const uint8_t *p) { return (p[0] << 8) | p[1]; }
|
||||||
|
|
||||||
|
static int64_t rd_ts(const uint8_t *p) // 48-bit seconds + 32-bit ns
|
||||||
|
{
|
||||||
|
uint64_t sec = ((uint64_t)rd16(p) << 32) | ((uint32_t)p[2] << 24) | (p[3] << 16) | (p[4] << 8) | p[5];
|
||||||
|
uint32_t ns = ((uint32_t)p[6] << 24) | (p[7] << 16) | (p[8] << 8) | p[9];
|
||||||
|
return (int64_t)sec * 1000000000LL + ns;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int64_t rd_corr_ns(const uint8_t *p) // correctionField: ns * 2^16
|
||||||
|
{
|
||||||
|
int64_t v = 0;
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
v = (v << 8) | p[i];
|
||||||
|
}
|
||||||
|
return v >> 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int64_t mac_ns(const eth_mac_time_t *t)
|
||||||
|
{
|
||||||
|
return (int64_t)t->seconds * 1000000000LL + t->nanoseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void fmt_id(char *out, const uint8_t *id)
|
||||||
|
{
|
||||||
|
sprintf(out, "%02x-%02x-%02x-%02x-%02x-%02x-%02x-%02x", id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IEEE 1588 dataset comparison (without the topology part): <0 if a is better.
|
||||||
|
static int compare(const master_t *a, const master_t *b)
|
||||||
|
{
|
||||||
|
if (a->p1 != b->p1) return a->p1 - b->p1;
|
||||||
|
if (a->cls != b->cls) return a->cls - b->cls;
|
||||||
|
if (a->acc != b->acc) return a->acc - b->acc;
|
||||||
|
if (a->var != b->var) return a->var - b->var;
|
||||||
|
if (a->p2 != b->p2) return a->p2 - b->p2;
|
||||||
|
int c = memcmp(a->gm_id, b->gm_id, 8);
|
||||||
|
if (c) return c;
|
||||||
|
return a->steps - b->steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int open_socket(uint16_t port, struct in_addr ifaddr)
|
||||||
|
{
|
||||||
|
int fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
|
||||||
|
int one = 1;
|
||||||
|
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
|
||||||
|
struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(port), .sin_addr.s_addr = htonl(INADDR_ANY) };
|
||||||
|
struct ip_mreq m = { .imr_interface = ifaddr };
|
||||||
|
inet_aton(PTP_MCAST, &m.imr_multiaddr);
|
||||||
|
if (bind(fd, (struct sockaddr *)&a, sizeof(a)) < 0 ||
|
||||||
|
setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &m, sizeof(m)) < 0) {
|
||||||
|
ESP_LOGE(TAG, "socket %u: bind/join failed (errno %d)", port, errno);
|
||||||
|
close(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool from_gm(const uint8_t *b)
|
||||||
|
{
|
||||||
|
return s.gm.valid && memcmp(b + 20, s.gm.port_id, 10) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- message handling ----- */
|
||||||
|
|
||||||
|
static void on_announce(const uint8_t *b, int len, uint32_t src_ip)
|
||||||
|
{
|
||||||
|
if (len < 64) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
master_t m = {
|
||||||
|
.valid = true, .ip = src_ip, .p1 = b[47], .cls = b[48], .acc = b[49], .var = rd16(b + 50),
|
||||||
|
.p2 = b[52], .steps = rd16(b + 61), .flags = b[7], .log_announce = (int8_t)b[33],
|
||||||
|
.last_us = esp_timer_get_time(),
|
||||||
|
};
|
||||||
|
memcpy(m.port_id, b + 20, 10);
|
||||||
|
memcpy(m.gm_id, b + 53, 8);
|
||||||
|
if (memcmp(m.port_id, s.port_id, 10) == 0) {
|
||||||
|
return; // our own
|
||||||
|
}
|
||||||
|
if (from_gm(b)) {
|
||||||
|
LOCKED({
|
||||||
|
if (s.last_announce_us) {
|
||||||
|
win_add(&s.announce_iv, m.last_us - s.last_announce_us);
|
||||||
|
}
|
||||||
|
s.last_announce_us = m.last_us;
|
||||||
|
s.gm = m; // refresh dataset and timeout
|
||||||
|
});
|
||||||
|
} else if (!s.gm.valid || compare(&m, &s.gm) < 0) {
|
||||||
|
char id[24];
|
||||||
|
fmt_id(id, m.gm_id);
|
||||||
|
ESP_LOGI(TAG, "TimeTransmitter %s (p1 %u class %u p2 %u, %u hops) from " IPSTR,
|
||||||
|
id, m.p1, m.cls, m.p2, m.steps, IP2STR((esp_ip4_addr_t *)&src_ip));
|
||||||
|
LOCKED({
|
||||||
|
s.gm = m;
|
||||||
|
s.last_announce_us = m.last_us;
|
||||||
|
win_clear(&s.announce_iv);
|
||||||
|
win_clear(&s.sync_iv);
|
||||||
|
win_clear(&s.delays);
|
||||||
|
});
|
||||||
|
s.sync_pending = s.dreq_pending = false;
|
||||||
|
LOCKED({
|
||||||
|
s.delay_ns = s.prev_t2 = 0;
|
||||||
|
s.stepped = false;
|
||||||
|
set_locked(false);
|
||||||
|
});
|
||||||
|
s.log_dreq = 0;
|
||||||
|
s.next_dreq_us = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void set_locked(bool locked)
|
||||||
|
{
|
||||||
|
if (locked != s.locked) {
|
||||||
|
s.locked = locked;
|
||||||
|
if (locked) {
|
||||||
|
win_clear(&s.delays); // delay statistics cover the locked period, not the pull-in
|
||||||
|
ESP_LOGI(TAG, "locked: offset %+lld ns, frequency %+.3f ppm, path delay %lld ns",
|
||||||
|
s.offset_ns, s.freq_ppb / 1000, s.delay_ns);
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "unlocked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!locked) {
|
||||||
|
s.good = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// offset = local - GM (ns)
|
||||||
|
static void servo(int64_t offset)
|
||||||
|
{
|
||||||
|
s.offset_ns = offset;
|
||||||
|
if (!s.stepped || llabs(offset) > STEP_NS) {
|
||||||
|
if (!s.stepped) {
|
||||||
|
// Seed the integral with the measured rate error (measured with the current correction applied).
|
||||||
|
s.drift_ppb += s.rate * 1e9;
|
||||||
|
}
|
||||||
|
s.freq_ppb = -s.drift_ppb;
|
||||||
|
ptp_hw_adj_freq(s.freq_ppb);
|
||||||
|
esp_err_t err = ptp_hw_step(offset);
|
||||||
|
ESP_LOGI(TAG, "clock stepped by %+lld ns (%s), frequency %+.3f ppm", -offset, esp_err_to_name(err),
|
||||||
|
s.freq_ppb / 1000);
|
||||||
|
s.stepped = true;
|
||||||
|
s.prev_t2 = 0; // rate across the step is meaningless
|
||||||
|
s.bad = 0;
|
||||||
|
set_locked(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// linuxptp PI gains for hardware timestamps, scaled by the Sync interval
|
||||||
|
double iv = ldexp(1.0, s.log_sync);
|
||||||
|
double kp = fmin(0.7 * pow(iv, -0.3), 0.7 / iv);
|
||||||
|
double ki = fmin(0.3 * pow(iv, 0.4), 0.3 / iv);
|
||||||
|
double ppb = kp * offset + s.drift_ppb;
|
||||||
|
s.drift_ppb = fmax(-MAX_DRIFT_PPB, fmin(MAX_DRIFT_PPB, s.drift_ppb + ki * offset));
|
||||||
|
s.freq_ppb = -ppb;
|
||||||
|
ptp_hw_adj_freq(s.freq_ppb);
|
||||||
|
|
||||||
|
if (llabs(offset) < LOCK_NS) {
|
||||||
|
s.bad = 0;
|
||||||
|
if (++s.good >= LOCK_GOOD) {
|
||||||
|
set_locked(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.good = 0;
|
||||||
|
if (s.locked && ++s.bad >= LOCK_BAD) {
|
||||||
|
set_locked(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sync_complete(void)
|
||||||
|
{
|
||||||
|
s.sync_pending = false;
|
||||||
|
s.raw = s.t2 - s.t1 - s.sync_corr;
|
||||||
|
if (s.prev_t2 && s.t2 > s.prev_t2) {
|
||||||
|
s.rate = (double)(s.raw - s.prev_raw) / (double)(s.t2 - s.prev_t2);
|
||||||
|
LOCKED(win_add(&s.sync_iv, s.t2 - s.prev_t2));
|
||||||
|
}
|
||||||
|
s.prev_raw = s.raw;
|
||||||
|
s.prev_t2 = s.t2;
|
||||||
|
if (!s.delay_ns) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// offset = t2 - t1 - corrections - mean path delay
|
||||||
|
LOCKED(servo(s.raw - s.delay_ns));
|
||||||
|
ESP_LOGD(TAG, "seq %u: offset %+lld ns, freq %+.3f ppm, path delay %lld ns%s", s.sync_seq, s.offset_ns,
|
||||||
|
s.freq_ppb / 1000, s.delay_ns, s.locked ? ", locked" : "");
|
||||||
|
// Info-level summary once a minute: worst offset in the period.
|
||||||
|
int64_t now = esp_timer_get_time();
|
||||||
|
s.sum_max_ns = llabs(s.offset_ns) > s.sum_max_ns ? llabs(s.offset_ns) : s.sum_max_ns;
|
||||||
|
if (!s.sum_start_us) {
|
||||||
|
s.sum_start_us = now;
|
||||||
|
} else if (now - s.sum_start_us >= SUMMARY_US) {
|
||||||
|
ESP_LOGI(TAG, "%s: max |offset| %lld ns, freq %+.3f ppm, path delay %lld ns (last 60 s)",
|
||||||
|
s.locked ? "locked" : "unlocked", s.sum_max_ns, s.freq_ppb / 1000, s.delay_ns);
|
||||||
|
s.sum_max_ns = 0;
|
||||||
|
s.sum_start_us = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_sync(const uint8_t *b, int len)
|
||||||
|
{
|
||||||
|
if (len < 44 || !from_gm(b)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
eth_mac_time_t t2;
|
||||||
|
uint16_t seq = rd16(b + 30);
|
||||||
|
if (!ptp_hw_rx_ts(PTP_MSG_SYNC, seq, b + 20, &t2)) {
|
||||||
|
ESP_LOGW(TAG, "Sync %u: no HW RX timestamp", seq);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.sync_seq = seq;
|
||||||
|
s.log_sync = (int8_t)b[33];
|
||||||
|
s.t2 = mac_ns(&t2);
|
||||||
|
s.sync_corr = rd_corr_ns(b + 8);
|
||||||
|
if (rd16(b + 6) & FLAG_TWO_STEP) {
|
||||||
|
s.sync_pending = true; // wait for Follow_Up
|
||||||
|
} else {
|
||||||
|
s.t1 = rd_ts(b + 34);
|
||||||
|
sync_complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_follow_up(const uint8_t *b, int len)
|
||||||
|
{
|
||||||
|
if (len < 44 || !from_gm(b) || !s.sync_pending || rd16(b + 30) != s.sync_seq) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.t1 = rd_ts(b + 34);
|
||||||
|
s.sync_corr += rd_corr_ns(b + 8);
|
||||||
|
sync_complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void send_delay_req(void)
|
||||||
|
{
|
||||||
|
uint8_t m[44] = { 0 };
|
||||||
|
m[0] = PTP_MSG_DELAY_REQ;
|
||||||
|
m[1] = 2;
|
||||||
|
m[3] = sizeof(m);
|
||||||
|
m[4] = s.domain;
|
||||||
|
memcpy(m + 20, s.port_id, 10);
|
||||||
|
s.dreq_seq++;
|
||||||
|
m[30] = s.dreq_seq >> 8;
|
||||||
|
m[31] = s.dreq_seq & 0xff;
|
||||||
|
m[32] = 1; // controlField: Delay_Req
|
||||||
|
m[33] = 0x7f;
|
||||||
|
uint32_t dst;
|
||||||
|
inet_aton(PTP_MCAST, (struct in_addr *)&dst);
|
||||||
|
eth_mac_time_t t3;
|
||||||
|
esp_err_t err = ptp_hw_send_event(PTP_MCAST_MAC, dst, s.dscp, m, sizeof(m), &t3);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "Delay_Req %u: %s", s.dreq_seq, esp_err_to_name(err));
|
||||||
|
s.dreq_pending = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.t3 = mac_ns(&t3);
|
||||||
|
s.dreq_pending = true;
|
||||||
|
LOCKED(s.delay_req++);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_delay_resp(const uint8_t *b, int len)
|
||||||
|
{
|
||||||
|
if (len < 54 || !from_gm(b) || !s.dreq_pending || rd16(b + 30) != s.dreq_seq ||
|
||||||
|
memcmp(b + 44, s.port_id, 10) != 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.dreq_pending = false;
|
||||||
|
LOCKED(s.delay_resp++);
|
||||||
|
int64_t t4 = rd_ts(b + 34) - rd_corr_ns(b + 8);
|
||||||
|
s.log_dreq = (int8_t)b[33];
|
||||||
|
if (!s.prev_t2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// mean path delay = ((t2 - t1 - corr) + (t4 - t3)) / 2, plus the offset drift between t2 and t3
|
||||||
|
int64_t d = (s.raw + (t4 - s.t3) + (int64_t)(s.rate * (double)(s.t3 - s.t2))) / 2;
|
||||||
|
LOCKED({
|
||||||
|
s.delay_ns = s.delay_ns ? (s.delay_ns * 7 + d) / 8 : d; // light smoothing for the servo
|
||||||
|
win_add(&s.delays, d); // raw samples for status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- task ----- */
|
||||||
|
|
||||||
|
static void load_config(void)
|
||||||
|
{
|
||||||
|
cJSON *c = cfg_get("ptp");
|
||||||
|
s.domain = cJSON_GetObjectItem(c, "domain")->valueint;
|
||||||
|
s.dscp = cJSON_GetObjectItem(c, "dscp")->valueint;
|
||||||
|
s.timeout = cJSON_GetObjectItem(c, "announce_timeout")->valueint;
|
||||||
|
// clockClass per role: slave-only 255, auto/master 248 (TimeTransmitter itself: step 6)
|
||||||
|
s.own_class = strcmp(cJSON_GetObjectItem(c, "role")->valuestring, "slave") == 0 ? 255 : 248;
|
||||||
|
cJSON_Delete(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ptp_task(void *arg)
|
||||||
|
{
|
||||||
|
esp_netif_ip_info_t ip = { 0 };
|
||||||
|
while (esp_netif_get_ip_info(s.netif, &ip) != ESP_OK || !ip.ip.addr) {
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(500));
|
||||||
|
}
|
||||||
|
struct in_addr ifaddr = { .s_addr = ip.ip.addr };
|
||||||
|
s.ev = open_socket(PTP_EVENT_PORT, ifaddr);
|
||||||
|
s.gen = open_socket(PTP_GENERAL_PORT, ifaddr);
|
||||||
|
if (s.ev < 0 || s.gen < 0) {
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
char id[24];
|
||||||
|
fmt_id(id, s.port_id);
|
||||||
|
ESP_LOGI(TAG, "TimeReceiver on " IPSTR ", domain %u, clock %s, listening", IP2STR(&ip.ip), s.domain, id);
|
||||||
|
|
||||||
|
uint8_t b[128];
|
||||||
|
while (1) {
|
||||||
|
fd_set fds;
|
||||||
|
FD_ZERO(&fds);
|
||||||
|
FD_SET(s.ev, &fds);
|
||||||
|
FD_SET(s.gen, &fds);
|
||||||
|
struct timeval tv = { .tv_sec = 0, .tv_usec = 100000 };
|
||||||
|
if (select((s.ev > s.gen ? s.ev : s.gen) + 1, &fds, NULL, NULL, &tv) > 0) {
|
||||||
|
for (int k = 0; k < 2; k++) {
|
||||||
|
int fd = k ? s.gen : s.ev;
|
||||||
|
if (!FD_ISSET(fd, &fds)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
struct sockaddr_in src;
|
||||||
|
socklen_t sl = sizeof(src);
|
||||||
|
int len = recvfrom(fd, b, sizeof(b), 0, (struct sockaddr *)&src, &sl);
|
||||||
|
if (len < HDR_LEN || (b[1] & 0x0f) != 2 || b[4] != s.domain) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
switch (b[0] & 0x0f) {
|
||||||
|
case PTP_MSG_ANNOUNCE: on_announce(b, len, src.sin_addr.s_addr); break;
|
||||||
|
case PTP_MSG_SYNC: on_sync(b, len); break;
|
||||||
|
case PTP_MSG_FOLLOW_UP: on_follow_up(b, len); break;
|
||||||
|
case PTP_MSG_DELAY_RESP: on_delay_resp(b, len); break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t now = esp_timer_get_time();
|
||||||
|
if (s.gm.valid) {
|
||||||
|
// announceReceiptTimeout x the GM's announce interval
|
||||||
|
int64_t window = (int64_t)s.timeout * (s.gm.log_announce >= 0 ? 1000000LL << s.gm.log_announce
|
||||||
|
: 1000000LL >> -s.gm.log_announce);
|
||||||
|
if (now - s.gm.last_us > window) {
|
||||||
|
ESP_LOGW(TAG, "TimeTransmitter lost (no Announce for %lld ms), listening", window / 1000);
|
||||||
|
LOCKED({
|
||||||
|
memset(&s.gm, 0, sizeof(s.gm));
|
||||||
|
s.delay_ns = s.prev_t2 = 0;
|
||||||
|
s.stepped = false;
|
||||||
|
set_locked(false); // frequency correction stays (holdover)
|
||||||
|
});
|
||||||
|
} else if (now >= s.next_dreq_us && s.prev_t2) {
|
||||||
|
send_delay_req();
|
||||||
|
// Delay_Req interval from the GM's Delay_Resp; randomised 0.5..1.5x
|
||||||
|
int64_t iv = s.log_dreq >= 0 ? 1000000LL << s.log_dreq : 1000000LL >> -s.log_dreq;
|
||||||
|
s.next_dreq_us = now + iv / 2 + (esp_random() % (uint32_t)iv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ptp_clock_status(cJSON *st)
|
||||||
|
{
|
||||||
|
cJSON *p = cJSON_AddObjectToObject(st, "ptp");
|
||||||
|
char id[24];
|
||||||
|
double mean, min, max, sd;
|
||||||
|
|
||||||
|
xSemaphoreTake(s.lock, portMAX_DELAY);
|
||||||
|
const char *state = !s.gm.valid ? "LISTENING" : s.locked ? "SLAVE" : "UNCALIBRATED";
|
||||||
|
cJSON_AddStringToObject(p, "state", state);
|
||||||
|
cJSON_AddBoolToObject(p, "locked", s.locked);
|
||||||
|
cJSON_AddNumberToObject(p, "version", 2);
|
||||||
|
cJSON_AddNumberToObject(p, "own_class", s.own_class);
|
||||||
|
fmt_id(id, s.port_id);
|
||||||
|
cJSON_AddStringToObject(p, "clock_id", id);
|
||||||
|
cJSON_AddBoolToObject(p, "hw_ts", true);
|
||||||
|
cJSON_AddNumberToObject(p, "window", WINDOW);
|
||||||
|
cJSON_AddNumberToObject(p, "delay_req", s.delay_req);
|
||||||
|
cJSON_AddNumberToObject(p, "delay_resp", s.delay_resp);
|
||||||
|
if (s.gm.valid) {
|
||||||
|
fmt_id(id, s.gm.gm_id);
|
||||||
|
cJSON_AddStringToObject(p, "gm_id", id);
|
||||||
|
cJSON_AddNumberToObject(p, "gm_class", s.gm.cls);
|
||||||
|
cJSON_AddNumberToObject(p, "gm_accuracy", s.gm.acc);
|
||||||
|
cJSON_AddNumberToObject(p, "gm_p1", s.gm.p1);
|
||||||
|
cJSON_AddNumberToObject(p, "gm_p2", s.gm.p2);
|
||||||
|
cJSON_AddNumberToObject(p, "steps_removed", s.gm.steps);
|
||||||
|
cJSON_AddBoolToObject(p, "gm_time_traceable", s.gm.flags & 0x10);
|
||||||
|
cJSON_AddBoolToObject(p, "gm_freq_traceable", s.gm.flags & 0x20);
|
||||||
|
if (s.stepped) {
|
||||||
|
cJSON_AddNumberToObject(p, "offset_ns", s.offset_ns);
|
||||||
|
cJSON_AddNumberToObject(p, "freq_ppb", round(s.freq_ppb));
|
||||||
|
}
|
||||||
|
if (s.delays.n) {
|
||||||
|
win_stats(&s.delays, &mean, NULL, NULL, &sd);
|
||||||
|
cJSON_AddNumberToObject(p, "path_delay_ns", round(mean));
|
||||||
|
cJSON_AddNumberToObject(p, "path_delay_sd_ns", round(sd));
|
||||||
|
}
|
||||||
|
if (s.sync_iv.n) {
|
||||||
|
win_stats(&s.sync_iv, &mean, &min, &max, &sd);
|
||||||
|
cJSON_AddNumberToObject(p, "sync_avg_ms", mean / 1e6);
|
||||||
|
cJSON_AddNumberToObject(p, "sync_min_ms", min / 1e6);
|
||||||
|
cJSON_AddNumberToObject(p, "sync_max_ms", max / 1e6);
|
||||||
|
cJSON_AddNumberToObject(p, "sync_jitter_us", sd / 1e3);
|
||||||
|
}
|
||||||
|
if (s.announce_iv.n) {
|
||||||
|
win_stats(&s.announce_iv, &mean, NULL, NULL, NULL);
|
||||||
|
cJSON_AddNumberToObject(p, "announce_avg_ms", mean / 1e3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xSemaphoreGive(s.lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ptp_clock_start(esp_netif_t *netif)
|
||||||
|
{
|
||||||
|
s.netif = netif;
|
||||||
|
s.lock = xSemaphoreCreateMutex();
|
||||||
|
load_config();
|
||||||
|
uint8_t mac[6];
|
||||||
|
esp_netif_get_mac(netif, mac);
|
||||||
|
const uint8_t pid[10] = { mac[0], mac[1], mac[2], 0xff, 0xfe, mac[3], mac[4], mac[5], 0, 1 };
|
||||||
|
memcpy(s.port_id, pid, sizeof(pid));
|
||||||
|
return xTaskCreate(ptp_task, "ptp", 4096, NULL, 10, NULL) == pdPASS ? ESP_OK : ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// PTPv2 ordinary clock over UDP/IPv4 (E2E). TimeReceiver (TimeTransmitter: step 6).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "cJSON.h"
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include "esp_netif.h"
|
||||||
|
|
||||||
|
esp_err_t ptp_clock_start(esp_netif_t *netif);
|
||||||
|
// Adds the "ptp" object to /api/status.
|
||||||
|
void ptp_clock_status(cJSON *st);
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#include "ptp_hw.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "esp_eth_mac_esp.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "hal/emac_ll.h"
|
||||||
|
#include "soc/emac_ptp_struct.h"
|
||||||
|
|
||||||
|
#define RX_RING 16
|
||||||
|
#define PTP_EVENT_PORT 319
|
||||||
|
|
||||||
|
static const char *TAG = "ptp_hw";
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
bool used;
|
||||||
|
uint8_t type;
|
||||||
|
uint16_t seq;
|
||||||
|
uint8_t port_id[10];
|
||||||
|
eth_mac_time_t ts;
|
||||||
|
} rx_rec_t;
|
||||||
|
|
||||||
|
static esp_eth_handle_t s_eth;
|
||||||
|
static esp_netif_t *s_netif;
|
||||||
|
static uint16_t s_ip_id;
|
||||||
|
static rx_rec_t s_rx[RX_RING];
|
||||||
|
static int s_rx_next;
|
||||||
|
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
|
|
||||||
|
// Runs in the EMAC RX task for every frame: note PTP event timestamps, then pass to lwIP.
|
||||||
|
static esp_err_t rx_hook(esp_eth_handle_t eth, uint8_t *buf, uint32_t len, void *priv, void *info)
|
||||||
|
{
|
||||||
|
const eth_mac_time_t *ts = info;
|
||||||
|
// Ethernet (untagged) + IPv4 + UDP to port 319 + PTP header (34 bytes)
|
||||||
|
if (ts && (ts->seconds | ts->nanoseconds) && len >= 14 + 20 + 8 + 34 &&
|
||||||
|
buf[12] == 0x08 && buf[13] == 0x00 && buf[23] == 17) {
|
||||||
|
const uint8_t *ip = buf + 14;
|
||||||
|
size_t ihl = (ip[0] & 0x0f) * 4;
|
||||||
|
const uint8_t *udp = ip + ihl;
|
||||||
|
const uint8_t *ptp = udp + 8;
|
||||||
|
if (ihl >= 20 && ptp + 34 <= buf + len && ((udp[2] << 8) | udp[3]) == PTP_EVENT_PORT &&
|
||||||
|
(ptp[1] & 0x0f) == 2) {
|
||||||
|
portENTER_CRITICAL(&s_lock);
|
||||||
|
rx_rec_t *r = &s_rx[s_rx_next];
|
||||||
|
s_rx_next = (s_rx_next + 1) % RX_RING;
|
||||||
|
r->used = true;
|
||||||
|
r->type = ptp[0] & 0x0f;
|
||||||
|
r->seq = (ptp[30] << 8) | ptp[31];
|
||||||
|
memcpy(r->port_id, ptp + 20, 10);
|
||||||
|
r->ts = *ts;
|
||||||
|
portEXIT_CRITICAL(&s_lock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return esp_netif_receive((esp_netif_t *)priv, buf, len, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ptp_hw_rx_ts(uint8_t msg_type, uint16_t seq, const uint8_t *src_port_id, eth_mac_time_t *ts)
|
||||||
|
{
|
||||||
|
bool found = false;
|
||||||
|
portENTER_CRITICAL(&s_lock);
|
||||||
|
for (int i = 0; i < RX_RING; i++) {
|
||||||
|
rx_rec_t *r = &s_rx[i];
|
||||||
|
if (r->used && r->type == msg_type && r->seq == seq && memcmp(r->port_id, src_port_id, 10) == 0) {
|
||||||
|
*ts = r->ts;
|
||||||
|
r->used = false;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
portEXIT_CRITICAL(&s_lock);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ptp_hw_get_time(eth_mac_time_t *t)
|
||||||
|
{
|
||||||
|
return esp_eth_ioctl(s_eth, ETH_MAC_ESP_CMD_G_PTP_TIME, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ptp_hw_adj_freq(double ppb)
|
||||||
|
{
|
||||||
|
if (ppb > 500000) {
|
||||||
|
ppb = 500000; // +-500 ppm is far outside any sane crystal
|
||||||
|
} else if (ppb < -500000) {
|
||||||
|
ppb = -500000;
|
||||||
|
}
|
||||||
|
int32_t v = (int32_t)(ppb >= 0 ? ppb + 0.5 : ppb - 0.5);
|
||||||
|
esp_err_t err = ESP_ERR_INVALID_STATE;
|
||||||
|
// The previous addend update may still be in progress: retry briefly.
|
||||||
|
for (int i = 0; i < 10 && err == ESP_ERR_INVALID_STATE; i++) {
|
||||||
|
err = esp_eth_ioctl(s_eth, ETH_MAC_ESP_CMD_ADJ_PTP_TIME, &v);
|
||||||
|
}
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ptp_hw_step(int64_t offset_ns)
|
||||||
|
{
|
||||||
|
eth_mac_time_t t;
|
||||||
|
esp_err_t err = ptp_hw_get_time(&t);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
int64_t ns = (int64_t)t.seconds * 1000000000LL + t.nanoseconds - offset_ns;
|
||||||
|
if (ns < 0) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
t.seconds = ns / 1000000000LL;
|
||||||
|
t.nanoseconds = ns % 1000000000LL;
|
||||||
|
return esp_eth_ioctl(s_eth, ETH_MAC_ESP_CMD_S_PTP_TIME, &t);
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint16_t ip_checksum(const uint8_t *h, size_t len)
|
||||||
|
{
|
||||||
|
uint32_t sum = 0;
|
||||||
|
for (size_t i = 0; i < len; i += 2) {
|
||||||
|
sum += (h[i] << 8) | h[i + 1];
|
||||||
|
}
|
||||||
|
while (sum >> 16) {
|
||||||
|
sum = (sum & 0xffff) + (sum >> 16);
|
||||||
|
}
|
||||||
|
return ~sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ptp_hw_send_event(const uint8_t dst_mac[6], uint32_t dst_ip, uint8_t dscp,
|
||||||
|
const uint8_t *msg, size_t len, eth_mac_time_t *tx_ts)
|
||||||
|
{
|
||||||
|
uint8_t f[14 + 20 + 8 + 64];
|
||||||
|
esp_netif_ip_info_t ip;
|
||||||
|
if (len > 64 || esp_netif_get_ip_info(s_netif, &ip) != ESP_OK || !ip.ip.addr) {
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
// Ethernet
|
||||||
|
memcpy(f, dst_mac, 6);
|
||||||
|
esp_eth_ioctl(s_eth, ETH_CMD_G_MAC_ADDR, f + 6);
|
||||||
|
f[12] = 0x08;
|
||||||
|
f[13] = 0x00;
|
||||||
|
// IPv4: no options, DF, TTL 1 (PTP stays in the subnet), UDP
|
||||||
|
uint8_t *h = f + 14;
|
||||||
|
size_t ip_len = 20 + 8 + len;
|
||||||
|
uint16_t id = s_ip_id++;
|
||||||
|
const uint8_t hdr[20] = { 0x45, (uint8_t)(dscp << 2), ip_len >> 8, ip_len & 0xff, id >> 8, id & 0xff,
|
||||||
|
0x40, 0x00, 1, 17, 0, 0 };
|
||||||
|
memcpy(h, hdr, 12);
|
||||||
|
memcpy(h + 12, &ip.ip.addr, 4); // network byte order already
|
||||||
|
memcpy(h + 16, &dst_ip, 4);
|
||||||
|
uint16_t cs = ip_checksum(h, 20);
|
||||||
|
h[10] = cs >> 8;
|
||||||
|
h[11] = cs & 0xff;
|
||||||
|
// UDP 319 -> 319, checksum 0 (allowed for IPv4)
|
||||||
|
uint8_t *u = h + 20;
|
||||||
|
u[0] = PTP_EVENT_PORT >> 8;
|
||||||
|
u[1] = PTP_EVENT_PORT & 0xff;
|
||||||
|
u[2] = PTP_EVENT_PORT >> 8;
|
||||||
|
u[3] = PTP_EVENT_PORT & 0xff;
|
||||||
|
u[4] = (8 + len) >> 8;
|
||||||
|
u[5] = (8 + len) & 0xff;
|
||||||
|
u[6] = u[7] = 0;
|
||||||
|
memcpy(u + 8, msg, len);
|
||||||
|
|
||||||
|
size_t total = 14 + ip_len;
|
||||||
|
if (total < 60) {
|
||||||
|
memset(f + total, 0, 60 - total);
|
||||||
|
total = 60;
|
||||||
|
}
|
||||||
|
esp_err_t err = esp_eth_transmit_ctrl_vargs(s_eth, tx_ts, 2, f, (uint32_t)total);
|
||||||
|
if (err == ESP_OK && !(tx_ts->seconds | tx_ts->nanoseconds)) {
|
||||||
|
err = ESP_ERR_TIMEOUT; // sent, but no TX timestamp
|
||||||
|
}
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ptp_hw_init(esp_eth_handle_t eth, esp_netif_t *netif)
|
||||||
|
{
|
||||||
|
s_eth = eth;
|
||||||
|
s_netif = netif;
|
||||||
|
bool on = true;
|
||||||
|
esp_err_t err = esp_eth_ioctl(eth, ETH_MAC_ESP_CMD_PTP_ENABLE, &on);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "EMAC PTP enable failed: %s", esp_err_to_name(err));
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
// IDF enables timestamping for PTP over Ethernet (L2) only; AES67 uses UDP/IPv4.
|
||||||
|
emac_ll_ts_ptp_ip4_enable(&EMAC_PTP, true);
|
||||||
|
|
||||||
|
// The netif glue registered its own input path; take it over and forward to the netif.
|
||||||
|
err = esp_eth_update_input_path_info(eth, rx_hook, netif);
|
||||||
|
if (err == ESP_OK) {
|
||||||
|
ESP_LOGI(TAG, "EMAC IEEE 1588 clock running, HW timestamps for PTP over UDP/IPv4");
|
||||||
|
}
|
||||||
|
return err;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// EMAC IEEE 1588 clock and hardware timestamps for PTP over UDP/IPv4.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "esp_eth_driver.h"
|
||||||
|
#include "esp_eth_mac.h"
|
||||||
|
#include "esp_netif.h"
|
||||||
|
|
||||||
|
#define PTP_MSG_SYNC 0x0
|
||||||
|
#define PTP_MSG_DELAY_REQ 0x1
|
||||||
|
#define PTP_MSG_FOLLOW_UP 0x8
|
||||||
|
#define PTP_MSG_DELAY_RESP 0x9
|
||||||
|
#define PTP_MSG_ANNOUNCE 0xB
|
||||||
|
|
||||||
|
// Start the EMAC PTP clock, enable IPv4/UDP timestamping and hook the RX path.
|
||||||
|
esp_err_t ptp_hw_init(esp_eth_handle_t eth, esp_netif_t *netif);
|
||||||
|
// Hardware RX timestamp of an event message (UDP port 319), looked up by type,
|
||||||
|
// sequence ID and sourcePortIdentity (10 bytes). Each record is returned once.
|
||||||
|
bool ptp_hw_rx_ts(uint8_t msg_type, uint16_t seq, const uint8_t *src_port_id, eth_mac_time_t *ts);
|
||||||
|
esp_err_t ptp_hw_get_time(eth_mac_time_t *t);
|
||||||
|
// Frequency correction relative to the nominal rate, in ppb (positive = faster).
|
||||||
|
esp_err_t ptp_hw_adj_freq(double ppb);
|
||||||
|
// Step the clock back by offset_ns (offset = local - GM), via read + write.
|
||||||
|
esp_err_t ptp_hw_step(int64_t offset_ns);
|
||||||
|
// Send a PTP event message as a raw Ethernet/IPv4/UDP frame (port 319 -> 319) and return its
|
||||||
|
// hardware TX timestamp. dst_ip/dst_mac: 224.0.1.129 / 01:00:5e:00:01:81 or the GM unicast.
|
||||||
|
esp_err_t ptp_hw_send_event(const uint8_t dst_mac[6], uint32_t dst_ip, uint8_t dscp,
|
||||||
|
const uint8_t *msg, size_t len, eth_mac_time_t *tx_ts);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
idf_component_register(SRCS "aes67_sdp_sap.c"
|
||||||
|
INCLUDE_DIRS "include")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#include "aes67_sdp_sap.h"
|
||||||
|
|
||||||
|
// Stub (build step 0).
|
||||||
|
esp_err_t aes67_sdp_sap_init(void)
|
||||||
|
{
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// aes67_sdp_sap: SDP builder/parser and SAP announcer.
|
||||||
|
// Core component: must not depend on main/ (project code).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
|
||||||
|
esp_err_t aes67_sdp_sap_init(void);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
idf_component_register(SRCS "aes67_syslog.c"
|
||||||
|
INCLUDE_DIRS "include"
|
||||||
|
PRIV_REQUIRES aes67_web)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#include "aes67_syslog.h"
|
||||||
|
|
||||||
|
#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)
|
||||||
|
{
|
||||||
|
return cfg_register("log", LOG_DEFAULTS, log_validate, NULL);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// aes67_syslog: Remote syslog (esp_log hook -> queue -> UDP).
|
||||||
|
// Core component: must not depend on main/ (project code).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
|
||||||
|
// Registers the "log" config group. (Sender itself: step 5.)
|
||||||
|
esp_err_t aes67_syslog_init(void);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
idf_component_register(SRCS "aes67_tx.c"
|
||||||
|
INCLUDE_DIRS "include"
|
||||||
|
PRIV_REQUIRES aes67_web lwip)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#include "aes67_tx.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
#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)
|
||||||
|
{
|
||||||
|
cJSON_AddNumberToObject(st, "tx_packets", 0);
|
||||||
|
cJSON_AddNumberToObject(st, "underruns", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// aes67_tx: RTP sender task, PTP-paced, pull callback; optional mono sum.
|
||||||
|
// Core component: must not depend on main/ (project code).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#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);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
idf_component_register(SRCS "aes67_web.c" "cfg.c"
|
||||||
|
INCLUDE_DIRS "include"
|
||||||
|
REQUIRES esp_http_server json
|
||||||
|
PRIV_REQUIRES esp_timer nvs_flash lwip
|
||||||
|
EMBED_TXTFILES "${PROJECT_DIR}/web/index.html")
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#include "aes67_web.h"
|
||||||
|
|
||||||
|
#include "aes67_cfg.h"
|
||||||
|
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_system.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
|
||||||
|
#define MAX_STATUS_CBS 16
|
||||||
|
#define MAX_URIS 32
|
||||||
|
#define MAX_EARLY_URIS 16
|
||||||
|
|
||||||
|
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;
|
||||||
|
static httpd_uri_t s_early_uris[MAX_EARLY_URIS];
|
||||||
|
static int s_early_n;
|
||||||
|
|
||||||
|
esp_err_t status_register(status_cb_t cb)
|
||||||
|
{
|
||||||
|
if (s_status_n >= MAX_STATUS_CBS) {
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
s_status_cbs[s_status_n++] = cb;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t web_register_uri(const httpd_uri_t *uri)
|
||||||
|
{
|
||||||
|
if (s_httpd) {
|
||||||
|
return httpd_register_uri_handler(s_httpd, uri);
|
||||||
|
}
|
||||||
|
if (s_early_n >= MAX_EARLY_URIS) {
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
s_early_uris[s_early_n++] = *uri;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t web_send_json(httpd_req_t *req, const cJSON *json)
|
||||||
|
{
|
||||||
|
char *body = cJSON_PrintUnformatted(json);
|
||||||
|
if (!body) {
|
||||||
|
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "out of memory");
|
||||||
|
}
|
||||||
|
httpd_resp_set_type(req, "application/json");
|
||||||
|
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
|
||||||
|
esp_err_t err = httpd_resp_sendstr(req, body);
|
||||||
|
cJSON_free(body);
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t index_get(httpd_req_t *req)
|
||||||
|
{
|
||||||
|
httpd_resp_set_type(req, "text/html; charset=utf-8");
|
||||||
|
// EMBED_TXTFILES appends a NUL; don't send it.
|
||||||
|
return httpd_resp_send(req, index_html_start, index_html_end - index_html_start - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t status_get(httpd_req_t *req)
|
||||||
|
{
|
||||||
|
cJSON *st = cJSON_CreateObject();
|
||||||
|
for (int i = 0; i < s_status_n; i++) {
|
||||||
|
s_status_cbs[i](st);
|
||||||
|
}
|
||||||
|
esp_err_t err = web_send_json(req, st);
|
||||||
|
cJSON_Delete(st);
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void reboot_cb(void *arg)
|
||||||
|
{
|
||||||
|
esp_restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
void web_reboot_later(uint32_t ms)
|
||||||
|
{
|
||||||
|
const esp_timer_create_args_t args = { .callback = reboot_cb, .name = "reboot" };
|
||||||
|
esp_timer_handle_t t;
|
||||||
|
if (esp_timer_create(&args, &t) == ESP_OK) {
|
||||||
|
esp_timer_start_once(t, ms * 1000ULL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t reboot_post(httpd_req_t *req)
|
||||||
|
{
|
||||||
|
ESP_LOGW(TAG, "reboot requested via API");
|
||||||
|
httpd_resp_sendstr(req, "");
|
||||||
|
web_reboot_later(500); // let the response go out first
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
err = httpd_start(&s_httpd, &cfg);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "httpd start failed: %s", esp_err_to_name(err));
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const httpd_uri_t core[] = {
|
||||||
|
{ .uri = "/", .method = HTTP_GET, .handler = index_get },
|
||||||
|
{ .uri = "/index.html", .method = HTTP_GET, .handler = index_get },
|
||||||
|
{ .uri = "/api/status", .method = HTTP_GET, .handler = status_get },
|
||||||
|
{ .uri = "/api/reboot", .method = HTTP_POST, .handler = reboot_post },
|
||||||
|
};
|
||||||
|
for (int i = 0; i < sizeof(core) / sizeof(core[0]); i++) {
|
||||||
|
httpd_register_uri_handler(s_httpd, &core[i]);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < s_early_n; i++) {
|
||||||
|
httpd_register_uri_handler(s_httpd, &s_early_uris[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// aes67_web: httpd, embedded index.html, config store (cJSON in NVS), core routes.
|
||||||
|
// Core component: must not depend on main/ (project code).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "cJSON.h"
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include "esp_http_server.h"
|
||||||
|
|
||||||
|
// Adds fields to the /api/status object. Called on every GET /api/status.
|
||||||
|
typedef void (*status_cb_t)(cJSON *status);
|
||||||
|
|
||||||
|
// Registries: call before or after aes67_web_start(); routes registered early are added on start.
|
||||||
|
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/config, /api/reboot).
|
||||||
|
esp_err_t aes67_web_start(void);
|
||||||
|
|
||||||
|
// Restart after ms (lets an HTTP response go out first).
|
||||||
|
void web_reboot_later(uint32_t ms);
|
||||||
|
|
||||||
|
// Send a cJSON object as the response body (application/json).
|
||||||
|
esp_err_t web_send_json(httpd_req_t *req, const cJSON *json);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
dependencies:
|
||||||
|
espressif/mdns:
|
||||||
|
component_hash: b679eafd0acae2066e2645bd91d073e33ca5be515e2545cd3c4e55a3e3bce3cb
|
||||||
|
dependencies:
|
||||||
|
- name: idf
|
||||||
|
require: private
|
||||||
|
version: '>=5.0'
|
||||||
|
source:
|
||||||
|
registry_url: https://components.espressif.com/
|
||||||
|
type: service
|
||||||
|
version: 1.13.1
|
||||||
|
idf:
|
||||||
|
source:
|
||||||
|
type: idf
|
||||||
|
version: 5.5.5
|
||||||
|
direct_dependencies:
|
||||||
|
- espressif/mdns
|
||||||
|
manifest_hash: e5eba11c864076c14a58153c7f761bec2b064244e66ca3f76c0dc170db907329
|
||||||
|
target: esp32p4
|
||||||
|
version: 2.0.0
|
||||||
+12
-2
@@ -54,6 +54,12 @@ Reference devices for UI and defaults: Riedel Bolero (PTP status), Riedel Direct
|
|||||||
- Note: Riedel Bolero reports its own clock class as 228.
|
- Note: Riedel Bolero reports its own clock class as 228.
|
||||||
- When the GM changes (including this device becoming GM): update SDP ts-refclk, bump session_ver, send SAP immediately.
|
- When the GM changes (including this device becoming GM): update SDP ts-refclk, bump session_ver, send SAP immediately.
|
||||||
- Hardware timestamping preferred (ESP32-P4 EMAC has IEEE 1588 support); software fallback flagged in status.
|
- Hardware timestamping preferred (ESP32-P4 EMAC has IEEE 1588 support); software fallback flagged in status.
|
||||||
|
- ESP32-P4 implementation (aes67_ptp):
|
||||||
|
- IDF's `examples/ethernet/ptp` (ptpd) is Layer 2 only, so it is not used. IDF's EMAC clock is used: `ETH_MAC_ESP_CMD_PTP_ENABLE`, then the IPv4/UDP snapshot bit (`emac_ll_ts_ptp_ip4_enable`), which IDF leaves off.
|
||||||
|
- RX timestamps: a hook on the driver's info input path (`esp_eth_update_input_path_info`) records port-319 event timestamps and forwards every frame to lwIP. TX: Delay_Req is a raw Eth/IPv4/UDP frame sent with `esp_eth_transmit_ctrl_vargs` for its HW timestamp. Needs `CONFIG_ETH_TRANSMIT_MUTEX`.
|
||||||
|
- Servo: step on the first Sync from a new GM (frequency seeded from the measured rate), then linuxptp-style PI (kp 0.7 / ki 0.3 at 1 Sync/s, scaled by the interval); re-step above 1 ms. Locked: 8 Syncs below 1 µs; unlocked after 3 above. GM loss keeps the frequency (holdover).
|
||||||
|
- Path delay is corrected for offset drift between t2 and t3 until the clock is syntonised; delay statistics start at lock.
|
||||||
|
- Measured vs ptp4l (Intel i210 GM, non-PTP switch): lock in ~35 s cold, ~22 s after GM loss; offset within a few hundred ns; board crystal -39.8 ppm. Link asymmetry (1G GM / 100M board through a store-and-forward switch) adds a constant offset error of a few µs that no receiver can see.
|
||||||
|
|
||||||
### PTP status (`status.ptp`) — main panel modelled on Riedel Bolero "PTP Status"
|
### PTP status (`status.ptp`) — main panel modelled on Riedel Bolero "PTP Status"
|
||||||
| UI row | field(s) | notes |
|
| UI row | field(s) | notes |
|
||||||
@@ -91,6 +97,9 @@ When this device is the GM, offset/frequency/delay show "–".
|
|||||||
## Network
|
## Network
|
||||||
- Default: one untagged interface. VLAN split option: AES67 untagged + internet tagged (inet.vlan_id/pcp) using the ESP-IDF vlan_support approach (examples/network/vlan_support, ESP32-P4 supported). AES67 side has no gateway; default route and DNS are on inet.
|
- Default: one untagged interface. VLAN split option: AES67 untagged + internet tagged (inet.vlan_id/pcp) using the ESP-IDF vlan_support approach (examples/network/vlan_support, ESP32-P4 supported). AES67 side has no gateway; default route and DNS are on inet.
|
||||||
- PTP, RTP, SAP and IGMP bind to the AES67 netif. HTTP server filters on `web_on`.
|
- PTP, RTP, SAP and IGMP bind to the AES67 netif. HTTP server filters on `web_on`.
|
||||||
|
- DHCP sends net.hostname (option 12), so the router's lease list / local DNS shows it; after a rename it applies at the next lease renewal.
|
||||||
|
- mDNS (espressif/mdns): `<net.hostname>.local` plus the web UI as `_http._tcp` port 80. A hostname change applies live (the new name answers after ~1 s of RFC 6762 probing).
|
||||||
|
- LLDP (IEEE 802.1AB), transmit only, own code (not in IDF/lwIP): every 30 s, TTL 120 s, plus immediately on a new IP or hostname. TLVs: chassis ID and port ID = MAC, port description "eth0", system name = net.hostname, system description = project + firmware version, capabilities station-only, management address = IPv4. With the VLAN split, send it on the AES67 (untagged) side; the 802.1 port VLAN ID TLV can be added then.
|
||||||
- Switch port: AES67 as native/untagged VLAN, internet tagged, PoE on. IGMP snooping + querier on the AES67 VLAN.
|
- Switch port: AES67 as native/untagged VLAN, internet tagged, PoE on. IGMP snooping + querier on the AES67 VLAN.
|
||||||
|
|
||||||
## Syslog
|
## Syslog
|
||||||
@@ -105,8 +114,9 @@ When this device is the GM, offset/frequency/delay show "–".
|
|||||||
- POST /api/ota: raw .bin as `application/octet-stream`, streamed with esp_ota_begin/write/end into the inactive slot (never buffered whole in RAM). 200 then reboot; 4xx with a message if rejected.
|
- POST /api/ota: raw .bin as `application/octet-stream`, streamed with esp_ota_begin/write/end into the inactive slot (never buffered whole in RAM). 200 then reboot; 4xx with a message if rejected.
|
||||||
- Upload only, by design: the device never pulls firmware from a URL or git server (no esp_https_ota, no update checks).
|
- Upload only, by design: the device never pulls firmware from a URL or git server (no esp_https_ota, no update checks).
|
||||||
- POST /api/ota/confirm: mark the running app valid. POST /api/ota/rollback: boot the previous slot.
|
- POST /api/ota/confirm: mark the running app valid. POST /api/ota/rollback: boot the previous slot.
|
||||||
- Reject before writing: wrong `project_name` in the image's app description (stops flashing another project's .bin), and an image whose chip revision range doesn't match this chip (matters on the rev < 3 boards; esp_ota_end / image verify checks the header). Reject downgrades only if a flag is set (off by default).
|
- Reject before writing (checked on the first chunk): image magic, chip ID, wrong `project_name` in the image's app description (stops flashing another project's .bin), and a chip revision outside the image's min/max range (matters on the rev < 3 boards). esp_ota_end then verifies the whole image. Reject downgrades only if a flag is set (off by default; the flag doesn't exist yet).
|
||||||
- Rollback: enable `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE`. New firmware boots in "pending verify". Self-test after boot: Ethernet has an IP and the web server answers (optionally PTP locked within N seconds). Pass: `esp_ota_mark_app_valid_cancel_rollback()` automatically. Fail or crash before that: reboot, and the bootloader rolls back to the previous slot. The UI also offers manual confirm/rollback while pending.
|
- `previous`/`previous_version` are only reported when that slot is bootable (`esp_ota_check_rollback_is_possible()`), so a half-written upload or a rolled-back image is never offered.
|
||||||
|
- Rollback: enable `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE`. New firmware boots in "pending verify". Self-test after boot (60 s limit): Ethernet has an IP and the web server answers (an HTTP GET of its own /api/status returns 200; optionally PTP locked within N seconds later). Test-only `CONFIG_AES67_OTA_SELFTEST_FORCE_FAIL` (sdkconfig.selftest_fail) forces a failure to test rollback. Pass: `esp_ota_mark_app_valid_cancel_rollback()` automatically. Fail or crash before that: reboot, and the bootloader rolls back to the previous slot. The UI also offers manual confirm/rollback while pending.
|
||||||
- Keep AES67 TX running during the upload if possible (OTA writes are slow flash erases; run them at lower priority than PTP/TX). Expect short audio glitches; the reboot drops the stream for a few seconds.
|
- Keep AES67 TX running during the upload if possible (OTA writes are slow flash erases; run them at lower priority than PTP/TX). Expect short audio glitches; the reboot drops the stream for a few seconds.
|
||||||
- Security: no auth yet (LAN only), like the rest of the API. Add a bearer token and optionally signed images (secure boot v2 / signed OTA) before exposing the device on an untrusted network.
|
- Security: no auth yet (LAN only), like the rest of the API. Add a bearer token and optionally signed images (secure boot v2 / signed OTA) before exposing the device on an untrusted network.
|
||||||
- UI: Firmware section: upload with progress bar, waits for the reboot and reports the new version (or that it rolled back), confirm/rollback buttons while pending.
|
- UI: Firmware section: upload with progress bar, waits for the reboot and reports the new version (or that it rolled back), confirm/rollback buttons while pending.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Core AES67 parts (PTP, TX, SDP/SAP, network/VLAN, syslog, web UI base, config schema, component layout) are in **aes67-core-base.md**. This file covers the board and the project-specific parts.
|
Core AES67 parts (PTP, TX, SDP/SAP, network/VLAN, syslog, web UI base, config schema, component layout) are in **aes67-core-base.md**. This file covers the board and the project-specific parts.
|
||||||
|
|
||||||
## Board: Waveshare ESP32-P4-ETH (bought as ESP32-P4-POE-ETH, i.e. with PoE add-on module)
|
## Board: Waveshare ESP32-P4-ETH (bought as ESP32-P4-POE-ETH, i.e. with PoE add-on module)
|
||||||
- ESP32-P4, dual-core RISC-V HP @ 360 MHz + LP core, 32 MB in-package PSRAM, NOR flash (Waveshare wiki says 32 MB, ESPHome says 16 MB — check with `esptool.py flash_id`)
|
- ESP32-P4, dual-core RISC-V HP @ 360 MHz + LP core, 32 MB in-package PSRAM, 32 MB NOR flash (confirmed with `esptool.py flash_id`)
|
||||||
- **No Wi-Fi/BT** on this variant (the -WIFI6- variant adds an ESP32-C6). Ethernet only.
|
- **No Wi-Fi/BT** on this variant (the -WIFI6- variant adds an ESP32-C6). Ethernet only.
|
||||||
- Ethernet: IP101GRI PHY, RMII, 100 Mb, 50 MHz ref clock from PHY (25 MHz xtal), PHY addr 1
|
- Ethernet: IP101GRI PHY, RMII, 100 Mb, 50 MHz ref clock from PHY (25 MHz xtal), PHY addr 1
|
||||||
- REF_CLK 50 (EMAC_CLK_EXT_IN), TX_EN 49, TXD0 34, TXD1 35, CRS_DV 28, RXD0 29, RXD1 30, MDC 31, MDIO 52, PHY_RST/power 51
|
- REF_CLK 50 (EMAC_CLK_EXT_IN), TX_EN 49, TXD0 34, TXD1 35, CRS_DV 28, RXD0 29, RXD1 30, MDC 31, MDIO 52, PHY_RST/power 51
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
idf_component_register(SRCS "main.c" "project_cfg.c"
|
||||||
|
INCLUDE_DIRS "."
|
||||||
|
REQUIRES esp_app_format esp_hw_support
|
||||||
|
aes67_board aes67_health aes67_net aes67_ota aes67_ptp
|
||||||
|
aes67_syslog aes67_tx aes67_web)
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#include "aes67_board.h"
|
||||||
|
#include "aes67_health.h"
|
||||||
|
#include "aes67_net.h"
|
||||||
|
#include "aes67_ota.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";
|
||||||
|
|
||||||
|
void app_main(void)
|
||||||
|
{
|
||||||
|
const esp_app_desc_t *app = esp_app_get_description();
|
||||||
|
esp_chip_info_t chip;
|
||||||
|
esp_chip_info(&chip);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "%s %s (IDF %s, built %s %s)",
|
||||||
|
app->project_name, app->version, app->idf_ver, app->date, app->time);
|
||||||
|
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_ptp_start(eth));
|
||||||
|
ESP_ERROR_CHECK(aes67_tx_init());
|
||||||
|
ESP_ERROR_CHECK(aes67_syslog_init());
|
||||||
|
project_cfg_register();
|
||||||
|
ESP_ERROR_CHECK(aes67_ota_init());
|
||||||
|
ESP_ERROR_CHECK(aes67_web_start());
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# OTA layout: two app slots, no factory app. Fixed from step 0 on; do not change later.
|
||||||
|
# 32 MB flash, but app slots must stay below 16 MB (no cache mapping above it). 16-32 MB is free for data.
|
||||||
|
# Partition table at 0x10000 (not 0x8000) leaves the bootloader room to grow (0x2000-0x10000).
|
||||||
|
# NVS sits outside the app slots so config survives updates.
|
||||||
|
# Name, Type, SubType, Offset, Size
|
||||||
|
nvs, data, nvs, 0x11000, 0x6000
|
||||||
|
otadata, data, ota, 0x17000, 0x2000
|
||||||
|
phy_init, data, phy, 0x19000, 0x1000
|
||||||
|
ota_0, app, ota_0, 0x20000, 0x600000
|
||||||
|
ota_1, app, ota_1, 0x620000, 0x600000
|
||||||
|
coredump, data, coredump, 0xc20000, 0x10000
|
||||||
|
@@ -0,0 +1,21 @@
|
|||||||
|
CONFIG_IDF_TARGET="esp32p4"
|
||||||
|
|
||||||
|
# OTA layout (partitions.csv): two app slots, no factory
|
||||||
|
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||||
|
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
|
||||||
|
# Bootloader had only 6% free below 0x8000; moved so it can grow (e.g. secure boot) without a relayout
|
||||||
|
CONFIG_PARTITION_TABLE_OFFSET=0x10000
|
||||||
|
|
||||||
|
# Rollback lives in the bootloader, which is only ever flashed over USB: enable it from the start
|
||||||
|
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
|
||||||
|
|
||||||
|
# Board has engineering silicon v1.3 (esptool chip_id). Rev <3 binaries don't run on rev 3 and vice versa.
|
||||||
|
CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y
|
||||||
|
CONFIG_ESP32P4_REV_MIN_100=y
|
||||||
|
|
||||||
|
# 32 MB flash (esptool flash_id: GigaDevice c8/4019). App slots stay below 16 MB;
|
||||||
|
# cache mapping above 16 MB is experimental in IDF.
|
||||||
|
CONFIG_ESPTOOLPY_FLASHSIZE_32MB=y
|
||||||
|
|
||||||
|
# LLDP and PTP send raw frames from their own tasks next to lwIP: serialise EMAC transmits
|
||||||
|
CONFIG_ETH_TRANSMIT_MUTEX=y
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Rollback test build only (step 2b). Build in its own directory, e.g.:
|
||||||
|
# idf.py -B build-selftest-fail -DSDKCONFIG=build-selftest-fail/sdkconfig \
|
||||||
|
# -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.selftest_fail" build
|
||||||
|
CONFIG_AES67_OTA_SELFTEST_FORCE_FAIL=y
|
||||||
Reference in New Issue
Block a user