Compare commits

...

20 Commits

Author SHA1 Message Date
bsncubed 004cc6e212 Step 3 verified: PTP TimeReceiver locks, status in the UI, GM loss handled
- Web UI PTP panel checked in the browser.
- GM loss test (ptp4l stopped): SLAVE -> LISTENING after the announce
  timeout, frequency held; ptp4l restarted: UNCALIBRATED -> SLAVE in 22 s.
- Docs: P4 PTP implementation notes (UDP timestamp bit, RX hook, raw
  Delay_Req, servo, measured results, link asymmetry).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:26:44 +10:00
bsncubed 9af6c53294 Step 3.3b: status.ptp for the web UI
- ptp_clock_status() adds status.ptp: state (LISTENING / UNCALIBRATED /
  SLAVE), locked, gm_id, offset_ns, freq_ppb, path_delay_ns +
  path_delay_sd_ns (window of 64, from lock on), steps_removed,
  gm_time/freq_traceable, version, own_class (255 slave / 248 auto,
  master), clock_id, gm_class/accuracy/p1/p2, sync_avg/min/max_ms and
  sync_jitter_us (HW Sync intervals), announce_avg_ms, delay_req/resp
  counters, hw_ts, window. Snapshot under a mutex shared with the PTP task.
- Per-Sync log moved to debug; info level logs state changes plus a
  60 s summary (max |offset|, freq, delay).
- Verified vs ptp4l: LISTENING -> UNCALIBRATED -> SLAVE in ~35 s;
  locked offset within a few hundred ns, delay 10.27 us +-0.20 us,
  Delay_Req/Resp 69/69, Sync avg 1000.097 ms, Announce avg 2000 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:22:41 +10:00
bsncubed 685060e12c Step 3.3a: PTP servo, clock locks to the GM
- ptp_hw: ptp_hw_adj_freq() (ETH_MAC_ESP_CMD_ADJ_PTP_TIME, absolute ppb
  vs nominal, clamped +-500 ppm, retried while the addend update is
  busy) and ptp_hw_step() (read + write time).
- ptp_clock: first Sync after a GM is selected seeds the integral with
  the measured rate and steps the clock; then linuxptp-style PI (kp/ki
  from the Sync interval: 0.7/0.3 at 1 s). Re-step above 1 ms. Locked
  after 8 Syncs with |offset| < 1 us, unlocked after 3 above; GM change
  or loss unlocks and keeps the frequency (holdover).
- Verified vs ptp4l: stepped to GM time, locked after ~19 s; locked
  offset within +-510 ns (mostly < 300 ns), correction +39.9 ppm
  (crystal -39.8 ppm), path delay ~10.2 us.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:15:26 +10:00
bsncubed e2f4d81326 Step 3.2: TimeReceiver measurement (GM selection, Delay_Req/Resp, path delay)
- ptp_clock.c replaces the 3.1 logger: UDP/IPv4 multicast, E2E.
  Announce: IEEE 1588 dataset comparison picks the best GM, dropped after
  announceReceiptTimeout x its announce interval. Sync/Follow_Up (two-step
  and one-step) with HW t2 and correctionField. Delay_Req sent as a raw
  frame (DSCP ptp.dscp, TTL 1, clockIdentity = EUI-64 from MAC) with HW
  TX timestamp t3, randomised at the GM's Delay_Resp interval; Delay_Resp
  matched on requestingPortIdentity + seq.
- Path delay corrected for offset drift between t2 and t3 (rate from
  consecutive Syncs) so it is right before the clock is syntonised.
- ptp_hw: ptp_hw_send_event() builds Eth/IPv4/UDP 319 and returns the HW
  TX timestamp.
- Verified vs ptp4l (i210 GM, HP 2530 non-PTP switch, PC 1G / board 100M):
  GM selected, path delay settles at ~10.3 us and stays flat, rate
  -39.8 +-0.4 ppm, offset drifts at -40 us/s (no servo yet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:50:46 +10:00
bsncubed 880437948d Enable ETH transmit mutex (LLDP/PTP raw TX next to lwIP)
CONFIG_ETH_TRANSMIT_MUTEX was off, so LLDP (esp_timer task) could hit the
EMAC TX descriptors at the same time as lwIP. PTP adds a third sender.
Verified: OTA-installed build passes its self-test, web/status fine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:47:31 +10:00
bsncubed 6090ceb239 Step 3.1: EMAC hardware timestamps for PTP over UDP/IPv4
- aes67_ptp/ptp_hw.c: start the EMAC IEEE 1588 clock (IDF
  ETH_MAC_ESP_CMD_PTP_ENABLE) and additionally enable snapshots for
  PTP over UDP/IPv4 (IDF only enables PTP over Ethernet/L2). An RX hook
  on the driver's info input path records {type, seq, sourcePortId, HW
  timestamp} of port-319 PTPv2 event messages, then hands every frame to
  lwIP unchanged.
- aes67_ptp.c: temporary 3.1 logger joins 224.0.1.129:319/320 and pairs
  Sync HW RX timestamps with Follow_Up origin timestamps.
- Checked against linuxptp ptp4l (-4 -E -H, Intel igb) as GM: every Sync
  has a HW timestamp; HW Sync intervals track the GM intervals with a
  constant -39.8 us/s (+-0.3 us), i.e. local clock -39.8 ppm vs GM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:44:11 +10:00
bsncubed f259c5d554 Step 2b verified: OTA update, rejections and rollback on board
Test on board (ESP32-P4 rev v1.3):
- Rejected with 400 and nothing booted differently: garbage, wrong
  project, min chip rev v3.0, truncated image.
- A (USB, ota_0) -> B via /api/ota: self-test passed, confirmed.
- Manual rollback B -> A; A -> B again.
- B -> C (forced self-test failure): C boots on trial in ota_0, fails,
  rolls back to B in ota_1.
- D installed over the network; a later truncated upload hides
  'previous' and clears can_rollback.

Docs: network flash command, serial-reset caveat, rollback test build,
OTA details in aes67-core-base.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:28:55 +10:00
bsncubed ad5a8b7545 OTA: report previous slot only when it is bootable
GET /api/ota listed a half-written upload or a rolled-back image as
'previous'. Only report it when esp_ota_check_rollback_is_possible().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:27:49 +10:00
bsncubed bbc5729cc1 OTA rollback test build config (sdkconfig.selftest_fail)
Builds an image whose self-test always fails, in its own build directory
(build-*/ ignored), for the step 2b rollback test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:20:20 +10:00
bsncubed 2403a6b64b Step 2b: firmware upload (/api/ota) with image checks and rollback self-test
- aes67_ota: GET /api/ota (version, project, build_date, idf, running,
  previous, previous_version, pending_verify, can_rollback), POST
  /api/ota streamed into the inactive slot (4 KiB chunks), POST
  /api/ota/confirm and /api/ota/rollback.
- Rejected before any flash write: bad magic, wrong chip, wrong
  project_name, chip revision outside the image's min/max range.
  esp_ota_end verifies the whole image.
- Self-test on a pending image: IP address plus HTTP 200 from our own
  /api/status within 60 s marks it valid; otherwise mark invalid and
  reboot into the previous slot. A crash before that is rolled back by
  the bootloader.
- CONFIG_AES67_OTA_SELFTEST_FORCE_FAIL (test builds only) forces a
  failed self-test.
- aes67_web: web_reboot_later() shared by /api/reboot and OTA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:20:04 +10:00
bsncubed cbfc37cde6 Rebuild picks up the git version after each commit
PROJECT_VER was only read at CMake configure time, so builds kept the
previous commit's version. CMake now re-runs when .git/logs/HEAD or
.git/index change. Needed for OTA tests (step 2b), which rely on the
version changing between builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:17:13 +10:00
bsncubed 07e16e6163 DHCP hostname: send net.hostname in DHCP requests
- aes67_net: esp_netif_set_hostname() before the interface starts, and
  in the net apply callback (takes effect at the next lease renewal).
- Verified: the router's DNS (lan.apointless.space) resolves p4-aes67
  to 192.168.192.244.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:16:39 +10:00
bsncubed 30c32e702b Step 2a (2/2): LLDP transmit, switch shows name and IP
- aes67_net/lldp.c: IEEE 802.1AB LLDPDU sent as a raw frame with
  esp_eth_transmit every 30 s (TTL 120 s) and immediately on a new IP
  or hostname change. TLVs: chassis/port ID (MAC), port description,
  system name (net.hostname), system description (project + version),
  capabilities station-only, IPv4 management address.
- Docs: LLDP in the core Network section; step 2a ticked.
- Verified on an HP 2530-8G-PoE+ (show lldp info remote-device 2): all
  fields shown, management address 192.168.192.244.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:14:09 +10:00
bsncubed 979420a6bb Step 2a (1/2): mDNS hostname.local and _http._tcp
- aes67_net: espressif/mdns 1.13.1 (managed component, pinned in
  dependencies.lock). Advertises <net.hostname>.local and the web UI as
  _http._tcp port 80. net apply callback renames live.
- First config value actually applied: net.hostname.
- Docs: mDNS in the core Network section; build order gets step 2a
  (mDNS, then LLDP).
- Verified on board: p4-aes67.local resolves via raw mDNS and getent,
  http://p4-aes67.local/ answers, service shows in _http._tcp browse;
  live rename to p4-rename and back works (after ~1 s probing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:51 +10:00
bsncubed 8e52401e9c Step 2 verified: web UI checked in browser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:05:10 +10:00
bsncubed c2621f192a Step 2.2: config registry and NVS store, GET/POST /api/config
- aes67_web/cfg.c: cfg_register(group, defaults, validate, apply),
  cfg_override_defaults() for project defaults, cfg_get(). Stored values
  are loaded on register and merged over defaults (known keys, matching
  types). NVS keeps only keys that differ from defaults, one JSON string
  per group, so new firmware defaults still reach untouched settings.
- POST /api/config: type check against defaults, per-group validation,
  nothing stored unless every group passes; 400 "group.key: reason".
  Unknown keys/groups are ignored. Returns the new config.
- Core groups registered by their components with the UI's ranges:
  ptp (aes67_ptp), aes67 (aes67_tx, incl. multicast range and mono_sum
  => 1 channel), net/inet (aes67_net), log (aes67_syslog).
- Project: PROJECT.def overrides (hostname p4-aes67, name P4 AES67) and
  the "source" group in main/project_cfg.c.
- Config is stored only; nothing is applied live yet.
- Verified on board: defaults, valid save, 8 rejected values (nothing
  stored), persistence across reboot, restore to defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:00:55 +10:00
bsncubed 35275b8ccf Step 2.1: web server, embedded UI, /api/status, /api/reboot
- aes67_web: httpd (wildcard URI matching), serves web/index.html embedded
  via EMBED_TXTFILES, status registry (status_register) and route registry
  (web_register_uri), GET /api/status, POST /api/reboot (restarts 0.5 s
  after the response).
- Status fields come from each core component: aes67_net (ip, aes67_ip,
  mac, link), aes67_health (fw, uptime_s, heap_free, psram_free),
  aes67_tx stub (tx_packets, underruns = 0). No ptp object yet, so the UI
  shows "no data".
- Verified on board: / serves index.html byte-identical (27601 B),
  /api/status returns live values, reboot via API returns in ~12 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:56:06 +10:00
bsncubed 69b4e18118 Step 1: Ethernet up (IP101/RMII), DHCP, link and IP logged
- aes67_board: Waveshare ESP32-P4-ETH pin profile and EMAC + IP101 driver
  install (PHY addr 1, reset GPIO51, ref clock in on GPIO50).
- aes67_net: Ethernet netif with DHCP client; logs link up/down (speed,
  duplex, MAC) and the DHCP lease.
- Verified on board: 100 Mbps full duplex, DHCP lease in ~8 s, ping
  0% loss, ~0.2 ms RTT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:52:08 +10:00
bsncubed b391b962a7 Step 0 verified on board: boots rev v1.3 build, 32 MB, OTA layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:48:46 +10:00
bsncubed bbfd3506bf Step 0: IDF project skeleton for esp32p4 with core component stubs
- Board checked with esptool: ESP32-P4 rev v1.3 (engineering silicon),
  32 MB GigaDevice flash. Rev <3 support and 32 MB set in sdkconfig.defaults.
- OTA partition layout: nvs, otadata, phy_init, ota_0/ota_1 (6 MB each,
  below 16 MB), coredump; no factory app. Partition table at 0x10000 so
  the bootloader can grow.
- App rollback enabled in the bootloader from the start.
- PROJECT_VER from git describe --tags --always --dirty.
- Empty stubs for the nine aes67_* core components; main logs version
  and chip revision.
- Built with ESP-IDF v5.5.5; not yet flashed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:47:21 +10:00
51 changed files with 2582 additions and 9 deletions
+5
View File
@@ -0,0 +1,5 @@
build/
sdkconfig
sdkconfig.old
managed_components/
build-*/
+14 -6
View File
@@ -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`
- 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`.
- 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`.
- 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)
- 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).
## 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.
- [ ] 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.
- [ ] 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.
- [ ] 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] 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] 1. Ethernet: IP101 up, DHCP, IP logged. Ping works.
- [x] 2. Web server + config store (cJSON in NVS) + embedded index.html; /api/config, /api/status (stub values), /api/reboot.
- [x] 2a. Finding the device: mDNS (hostname.local + _http._tcp), then LLDP (switch shows name + IP).
- [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.
- [ ] 5. SAP discovery, then syslog, then health/temperatures, then VLAN split (one at a time).
- [ ] 6. PTP TimeTransmitter: BMCA roles (auto/master), hybrid mode.
+20
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
idf_component_register(SRCS "aes67_board.c"
INCLUDE_DIRS "include"
REQUIRES esp_eth)
+44
View File
@@ -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(&eth_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
+3
View File
@@ -0,0 +1,3 @@
idf_component_register(SRCS "aes67_health.c"
INCLUDE_DIRS "include"
PRIV_REQUIRES aes67_web esp_app_format esp_timer heap)
+19
View File
@@ -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);
+4
View File
@@ -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)
+185
View File
@@ -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);
}
+2
View File
@@ -0,0 +1,2 @@
dependencies:
espressif/mdns: "^1.13.1"
+9
View File
@@ -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);
+125
View File
@@ -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;
}
+10
View File
@@ -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);
+4
View File
@@ -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)
+11
View File
@@ -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
+277
View File
@@ -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;
}
+9
View File
@@ -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);
+4
View File
@@ -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)
+47
View File
@@ -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;
}
+11
View File
@@ -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);
+552
View File
@@ -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;
}
+10
View File
@@ -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);
+191
View File
@@ -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;
}
+30
View File
@@ -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);
+2
View File
@@ -0,0 +1,2 @@
idf_component_register(SRCS "aes67_sdp_sap.c"
INCLUDE_DIRS "include")
+7
View File
@@ -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);
+3
View File
@@ -0,0 +1,3 @@
idf_component_register(SRCS "aes67_syslog.c"
INCLUDE_DIRS "include"
PRIV_REQUIRES aes67_web)
+28
View File
@@ -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);
+3
View File
@@ -0,0 +1,3 @@
idf_component_register(SRCS "aes67_tx.c"
INCLUDE_DIRS "include"
PRIV_REQUIRES aes67_web lwip)
+75
View File
@@ -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);
}
+8
View File
@@ -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);
+5
View File
@@ -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")
+135
View File
@@ -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;
}
+472
View File
@@ -0,0 +1,472 @@
#include "aes67_cfg.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
#include "aes67_web.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "lwip/inet.h"
#include "nvs.h"
#include "nvs_flash.h"
#define MAX_GROUPS 12
#define MAX_BODY 8192
#define NVS_NAMESPACE "cfg"
static const char *TAG = "cfg";
typedef struct {
char name[16]; // also the NVS key (max 15 chars)
cJSON *defaults;
cJSON *values;
cfg_validate_cb_t validate;
cfg_apply_cb_t apply;
} cfg_group_t;
static cfg_group_t s_groups[MAX_GROUPS];
static int s_group_n;
static cJSON *s_overrides; // {group: {...}} from cfg_override_defaults
static SemaphoreHandle_t s_lock;
static bool s_nvs_ok;
static void cfg_init_once(void)
{
if (s_lock) {
return;
}
s_lock = xSemaphoreCreateMutex();
s_overrides = cJSON_CreateObject();
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_LOGW(TAG, "NVS needs erase (%s), config reset to defaults", esp_err_to_name(err));
nvs_flash_erase();
err = nvs_flash_init();
}
s_nvs_ok = err == ESP_OK;
if (!s_nvs_ok) {
ESP_LOGE(TAG, "NVS init failed: %s; config will not persist", esp_err_to_name(err));
}
}
static cfg_group_t *find(const char *name)
{
for (int i = 0; i < s_group_n; i++) {
if (strcmp(s_groups[i].name, name) == 0) {
return &s_groups[i];
}
}
return NULL;
}
// Same JSON kind for our purposes: number, string or bool.
static bool same_type(const cJSON *a, const cJSON *b)
{
if (cJSON_IsBool(a)) {
return cJSON_IsBool(b);
}
return (a->type & 0xFF) == (b->type & 0xFF);
}
static const char *type_name(const cJSON *v)
{
return cJSON_IsBool(v) ? "boolean" : cJSON_IsNumber(v) ? "number" : cJSON_IsString(v) ? "string" : "value";
}
// Copy known keys of src over dst (keys must exist in defs with the same type).
// With err set, a type mismatch is an error; otherwise it is skipped with a warning.
static bool merge_known(cJSON *dst, const cJSON *defs, const cJSON *src, const char *group,
char *err, size_t err_len)
{
const cJSON *v;
cJSON_ArrayForEach(v, src) {
const cJSON *d = cJSON_GetObjectItemCaseSensitive(defs, v->string);
if (!d) {
ESP_LOGW(TAG, "%s.%s: unknown key, ignored", group, v->string);
continue;
}
if (!same_type(d, v)) {
if (err) {
snprintf(err, err_len, "%s.%s: expected %s", group, v->string, type_name(d));
return false;
}
ESP_LOGW(TAG, "%s.%s: stored type differs from default, ignored", group, v->string);
continue;
}
cJSON_ReplaceItemInObjectCaseSensitive(dst, v->string, cJSON_Duplicate(v, true));
}
return true;
}
static void load_stored(cfg_group_t *g)
{
if (!s_nvs_ok) {
return;
}
nvs_handle_t h;
if (nvs_open(NVS_NAMESPACE, NVS_READONLY, &h) != ESP_OK) {
return; // nothing stored yet
}
size_t len = 0;
if (nvs_get_str(h, g->name, NULL, &len) == ESP_OK && len > 0) {
char *buf = malloc(len);
if (buf && nvs_get_str(h, g->name, buf, &len) == ESP_OK) {
cJSON *stored = cJSON_Parse(buf);
if (cJSON_IsObject(stored)) {
merge_known(g->values, g->defaults, stored, g->name, NULL, 0);
ESP_LOGI(TAG, "%s: loaded %d stored value(s)", g->name, cJSON_GetArraySize(stored));
} else {
ESP_LOGW(TAG, "%s: stored config unreadable, using defaults", g->name);
}
cJSON_Delete(stored);
}
free(buf);
}
nvs_close(h);
}
// Store only the keys that differ from the defaults, so new firmware defaults still apply
// to settings the user never changed.
static esp_err_t store(nvs_handle_t h, const cfg_group_t *g)
{
cJSON *diff = cJSON_CreateObject();
const cJSON *v;
cJSON_ArrayForEach(v, g->values) {
const cJSON *d = cJSON_GetObjectItemCaseSensitive(g->defaults, v->string);
if (!d || !cJSON_Compare(d, v, true)) {
cJSON_AddItemToObject(diff, v->string, cJSON_Duplicate(v, true));
}
}
esp_err_t err;
if (cJSON_GetArraySize(diff) == 0) {
err = nvs_erase_key(h, g->name);
if (err == ESP_ERR_NVS_NOT_FOUND) {
err = ESP_OK;
}
} else {
char *s = cJSON_PrintUnformatted(diff);
err = s ? nvs_set_str(h, g->name, s) : ESP_ERR_NO_MEM;
cJSON_free(s);
}
cJSON_Delete(diff);
return err;
}
esp_err_t cfg_override_defaults(const char *group, const char *json)
{
cfg_init_once();
cJSON *o = cJSON_Parse(json);
if (!cJSON_IsObject(o)) {
cJSON_Delete(o);
ESP_LOGE(TAG, "%s: bad override JSON", group);
return ESP_ERR_INVALID_ARG;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
cJSON *prev = cJSON_GetObjectItemCaseSensitive(s_overrides, group);
if (prev) {
const cJSON *v;
cJSON_ArrayForEach(v, o) {
cJSON_DeleteItemFromObjectCaseSensitive(prev, v->string);
cJSON_AddItemToObject(prev, v->string, cJSON_Duplicate(v, true));
}
cJSON_Delete(o);
} else {
cJSON_AddItemToObject(s_overrides, group, o);
}
bool late = find(group) != NULL;
xSemaphoreGive(s_lock);
if (late) {
ESP_LOGE(TAG, "%s: override after register has no effect", group);
return ESP_ERR_INVALID_STATE;
}
return ESP_OK;
}
esp_err_t cfg_register(const char *group, const char *defaults_json,
cfg_validate_cb_t validate, cfg_apply_cb_t apply)
{
cfg_init_once();
if (strlen(group) >= sizeof(s_groups[0].name)) {
return ESP_ERR_INVALID_ARG;
}
cJSON *defs = cJSON_Parse(defaults_json);
if (!cJSON_IsObject(defs)) {
cJSON_Delete(defs);
ESP_LOGE(TAG, "%s: bad defaults JSON", group);
return ESP_ERR_INVALID_ARG;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
if (find(group) || s_group_n >= MAX_GROUPS) {
xSemaphoreGive(s_lock);
cJSON_Delete(defs);
ESP_LOGE(TAG, "%s: already registered or registry full", group);
return ESP_ERR_INVALID_STATE;
}
const cJSON *ov = cJSON_GetObjectItemCaseSensitive(s_overrides, group);
if (ov) {
merge_known(defs, defs, ov, group, NULL, 0);
}
cfg_group_t *g = &s_groups[s_group_n++];
strcpy(g->name, group);
g->defaults = defs;
g->values = cJSON_Duplicate(defs, true);
g->validate = validate;
g->apply = apply;
load_stored(g);
xSemaphoreGive(s_lock);
return ESP_OK;
}
cJSON *cfg_get(const char *group)
{
if (!s_lock) {
return NULL;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
cfg_group_t *g = find(group);
cJSON *copy = g ? cJSON_Duplicate(g->values, true) : NULL;
xSemaphoreGive(s_lock);
return copy;
}
/* ----- HTTP ----- */
static esp_err_t config_get(httpd_req_t *req)
{
cJSON *all = cJSON_CreateObject();
xSemaphoreTake(s_lock, portMAX_DELAY);
for (int i = 0; i < s_group_n; i++) {
cJSON_AddItemToObject(all, s_groups[i].name, cJSON_Duplicate(s_groups[i].values, true));
}
xSemaphoreGive(s_lock);
esp_err_t err = web_send_json(req, all);
cJSON_Delete(all);
return err;
}
static esp_err_t bad_request(httpd_req_t *req, const char *msg)
{
ESP_LOGW(TAG, "POST /api/config rejected: %s", msg);
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, msg);
}
static esp_err_t config_post(httpd_req_t *req)
{
if (req->content_len == 0 || req->content_len > MAX_BODY) {
return bad_request(req, "body missing or too large");
}
char *body = malloc(req->content_len + 1);
if (!body) {
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "out of memory");
}
size_t got = 0;
while (got < req->content_len) {
int r = httpd_req_recv(req, body + got, req->content_len - got);
if (r == HTTPD_SOCK_ERR_TIMEOUT) {
continue;
}
if (r <= 0) {
free(body);
return ESP_FAIL;
}
got += r;
}
body[got] = 0;
cJSON *in = cJSON_Parse(body);
free(body);
if (!cJSON_IsObject(in)) {
cJSON_Delete(in);
return bad_request(req, "body is not a JSON object");
}
char err[128] = "";
cJSON *cand[MAX_GROUPS] = { 0 };
bool ok = true;
xSemaphoreTake(s_lock, portMAX_DELAY);
// Build and validate every candidate group before storing anything.
for (int i = 0; ok && i < s_group_n; i++) {
cfg_group_t *g = &s_groups[i];
const cJSON *src = cJSON_GetObjectItemCaseSensitive(in, g->name);
if (!src) {
continue;
}
if (!cJSON_IsObject(src)) {
snprintf(err, sizeof(err), "%s: expected object", g->name);
ok = false;
break;
}
cand[i] = cJSON_Duplicate(g->values, true);
ok = merge_known(cand[i], g->defaults, src, g->name, err, sizeof(err));
if (ok && g->validate) {
char verr[96] = "";
ok = g->validate(cand[i], verr, sizeof(verr));
if (!ok) {
snprintf(err, sizeof(err), "%s.%s", g->name, verr);
}
}
}
const cJSON *v;
cJSON_ArrayForEach(v, in) {
if (!find(v->string)) {
ESP_LOGW(TAG, "%s: unknown group, ignored", v->string);
}
}
esp_err_t serr = ESP_OK;
int changed = 0;
if (ok && s_nvs_ok) {
nvs_handle_t h;
serr = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h);
for (int i = 0; serr == ESP_OK && i < s_group_n; i++) {
if (cand[i] && !cJSON_Compare(cand[i], s_groups[i].values, true)) {
cJSON *old = s_groups[i].values;
s_groups[i].values = cand[i];
serr = store(h, &s_groups[i]);
if (serr != ESP_OK) {
s_groups[i].values = old;
break;
}
cand[i] = old; // freed below
changed |= 1 << i;
}
}
if (serr == ESP_OK) {
serr = nvs_commit(h);
}
nvs_close(h);
}
for (int i = 0; i < s_group_n; i++) {
cJSON_Delete(cand[i]);
}
// Apply after the lock is released; callbacks may call cfg_get().
cJSON *applied[MAX_GROUPS] = { 0 };
for (int i = 0; i < s_group_n; i++) {
if ((changed & (1 << i)) && s_groups[i].apply) {
applied[i] = cJSON_Duplicate(s_groups[i].values, true);
}
}
int group_n = s_group_n;
xSemaphoreGive(s_lock);
cJSON_Delete(in);
if (!ok) {
return bad_request(req, err);
}
if (!s_nvs_ok || serr != ESP_OK) {
ESP_LOGE(TAG, "store failed: %s", s_nvs_ok ? esp_err_to_name(serr) : "NVS unavailable");
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "could not store config");
}
for (int i = 0; i < group_n; i++) {
if (changed & (1 << i)) {
ESP_LOGI(TAG, "%s: saved", s_groups[i].name);
}
if (applied[i]) {
s_groups[i].apply(applied[i]);
cJSON_Delete(applied[i]);
}
}
return config_get(req);
}
esp_err_t cfg_web_register(void)
{
static const httpd_uri_t uris[] = {
{ .uri = "/api/config", .method = HTTP_GET, .handler = config_get },
{ .uri = "/api/config", .method = HTTP_POST, .handler = config_post },
};
cfg_init_once();
for (int i = 0; i < 2; i++) {
esp_err_t err = web_register_uri(&uris[i]);
if (err != ESP_OK) {
return err;
}
}
return ESP_OK;
}
/* ----- Validation helpers ----- */
static const cJSON *item(const cJSON *g, const char *key)
{
return cJSON_GetObjectItemCaseSensitive(g, key);
}
bool cfg_check_num(const cJSON *g, const char *key, double min, double max, char *err, size_t n)
{
const cJSON *v = item(g, key);
if (!cJSON_IsNumber(v) || v->valuedouble < min || v->valuedouble > max) {
snprintf(err, n, "%s: must be %g..%g", key, min, max);
return false;
}
return true;
}
bool cfg_check_int(const cJSON *g, const char *key, double min, double max, char *err, size_t n)
{
if (!cfg_check_num(g, key, min, max, err, n)) {
return false;
}
double d = item(g, key)->valuedouble;
if (d != floor(d)) {
snprintf(err, n, "%s: must be a whole number", key);
return false;
}
return true;
}
bool cfg_check_num_in(const cJSON *g, const char *key, const double *vals, int count, char *err, size_t n)
{
const cJSON *v = item(g, key);
for (int i = 0; cJSON_IsNumber(v) && i < count; i++) {
if (v->valuedouble == vals[i]) {
return true;
}
}
int off = snprintf(err, n, "%s: must be one of", key);
for (int i = 0; i < count && off > 0 && off < (int)n; i++) {
off += snprintf(err + off, n - off, " %g", vals[i]);
}
return false;
}
bool cfg_check_enum(const cJSON *g, const char *key, const char *const *opts, char *err, size_t n)
{
const cJSON *v = item(g, key);
for (int i = 0; cJSON_IsString(v) && opts[i]; i++) {
if (strcmp(v->valuestring, opts[i]) == 0) {
return true;
}
}
int off = snprintf(err, n, "%s: must be one of", key);
for (int i = 0; opts[i] && off > 0 && off < (int)n; i++) {
off += snprintf(err + off, n - off, " %s", opts[i]);
}
return false;
}
bool cfg_check_str(const cJSON *g, const char *key, size_t min_len, size_t max_len, char *err, size_t n)
{
const cJSON *v = item(g, key);
size_t len = cJSON_IsString(v) ? strlen(v->valuestring) : 0;
if (!cJSON_IsString(v) || len < min_len || len > max_len) {
snprintf(err, n, "%s: length must be %u..%u", key, (unsigned)min_len, (unsigned)max_len);
return false;
}
return true;
}
bool cfg_check_ipv4(const cJSON *g, const char *key, bool allow_empty, char *err, size_t n)
{
const cJSON *v = item(g, key);
struct in_addr a;
if (cJSON_IsString(v) && ((allow_empty && !v->valuestring[0]) || inet_aton(v->valuestring, &a))) {
return true;
}
snprintf(err, n, "%s: not a valid IPv4 address", key);
return false;
}
+30
View File
@@ -0,0 +1,30 @@
// Config registry and store (cJSON in NVS), part of aes67_web.
// Each component registers its group; /api/config is built from the registry.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include "cJSON.h"
#include "esp_err.h"
// Validate a candidate group object. On failure write "key: reason" to err and return false.
typedef bool (*cfg_validate_cb_t)(const cJSON *group, char *err, size_t err_len);
// Called with the new group values after a successful save (apply live where possible).
typedef void (*cfg_apply_cb_t)(const cJSON *group);
// Project defaults merged over a group's core defaults. Call before that group is registered.
esp_err_t cfg_override_defaults(const char *group, const char *json);
// Register a group. Stored values (NVS) are loaded immediately, merged over the defaults.
esp_err_t cfg_register(const char *group, const char *defaults_json,
cfg_validate_cb_t validate, cfg_apply_cb_t apply);
// Copy of a group's current values; caller frees with cJSON_Delete. NULL if not registered.
cJSON *cfg_get(const char *group);
// Validation helpers for validate callbacks. Each returns false and fills err on failure.
bool cfg_check_num(const cJSON *g, const char *key, double min, double max, char *err, size_t n);
bool cfg_check_int(const cJSON *g, const char *key, double min, double max, char *err, size_t n);
bool cfg_check_num_in(const cJSON *g, const char *key, const double *vals, int count, char *err, size_t n);
bool cfg_check_enum(const cJSON *g, const char *key, const char *const *opts, char *err, size_t n);
bool cfg_check_str(const cJSON *g, const char *key, size_t min_len, size_t max_len, char *err, size_t n);
bool cfg_check_ipv4(const cJSON *g, const char *key, bool allow_empty, char *err, size_t n);
+23
View File
@@ -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);
+20
View File
@@ -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
View File
@@ -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.
- 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.
- 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"
| UI row | field(s) | notes |
@@ -91,6 +97,9 @@ When this device is the GM, offset/frequency/delay show "–".
## 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.
- 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.
## 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.
- 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.
- 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).
- 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.
- 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).
- `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.
- 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.
+1 -1
View File
@@ -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.
## 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.
- 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
+5
View File
@@ -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
View File
@@ -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(&eth));
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());
}
+33
View File
@@ -0,0 +1,33 @@
// Project config: defaults over the core groups and the "source" group.
#include "project_cfg.h"
#include "aes67_cfg.h"
#include "esp_err.h"
static const char SOURCE_DEFAULTS[] =
"{\"mode\":\"spotify\",\"spotify_name\":\"P4 AES67\",\"spotify_bitrate\":320,\"hls_url\":\"\","
"\"autoplay\":false,\"gain_db\":0,\"failover_delay_s\":5,\"failover_on_pause\":false}";
static bool source_validate(const cJSON *g, char *err, size_t n)
{
static const char *const modes[] = { "spotify", "hls", "auto", "off", NULL };
static const double bitrates[] = { 96, 160, 320 };
return cfg_check_enum(g, "mode", modes, err, n) &&
cfg_check_str(g, "spotify_name", 1, 63, err, n) &&
cfg_check_num_in(g, "spotify_bitrate", bitrates, 3, err, n) &&
cfg_check_str(g, "hls_url", 0, 255, err, n) &&
cfg_check_num(g, "gain_db", -60, 12, err, n) &&
cfg_check_int(g, "failover_delay_s", 0, 3600, err, n);
}
void project_cfg_defaults(void)
{
// Same as PROJECT.def in web/index.html.
ESP_ERROR_CHECK(cfg_override_defaults("aes67", "{\"name\":\"P4 AES67\"}"));
ESP_ERROR_CHECK(cfg_override_defaults("net", "{\"hostname\":\"p4-aes67\"}"));
}
void project_cfg_register(void)
{
ESP_ERROR_CHECK(cfg_register("source", SOURCE_DEFAULTS, source_validate, NULL));
}
+6
View File
@@ -0,0 +1,6 @@
#pragma once
// Call before the core components register their groups.
void project_cfg_defaults(void);
// Registers the project's own config groups.
void project_cfg_register(void);
+11
View File
@@ -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
1 # OTA layout: two app slots, no factory app. Fixed from step 0 on; do not change later.
2 # 32 MB flash, but app slots must stay below 16 MB (no cache mapping above it). 16-32 MB is free for data.
3 # Partition table at 0x10000 (not 0x8000) leaves the bootloader room to grow (0x2000-0x10000).
4 # NVS sits outside the app slots so config survives updates.
5 # Name, Type, SubType, Offset, Size
6 nvs, data, nvs, 0x11000, 0x6000
7 otadata, data, ota, 0x17000, 0x2000
8 phy_init, data, phy, 0x19000, 0x1000
9 ota_0, app, ota_0, 0x20000, 0x600000
10 ota_1, app, ota_1, 0x620000, 0x600000
11 coredump, data, coredump, 0xc20000, 0x10000
+21
View File
@@ -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
+4
View File
@@ -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