Compare commits

..

6 Commits

Author SHA1 Message Date
bsncubed e2dc7552c2 Build order: step 5 done without VLAN; VLAN split parked for phase 2
SAP, syslog and health/temperatures are done and verified. The VLAN
split needs a tagged VLAN with DHCP on the switch and is only needed
for the internet sources, so it moves to a phase 2 section. Step 4
notes what is still open (Riedel import, Wireshark).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:26:03 +10:00
bsncubed f6dfa80b19 Step 5 (health): temperatures in status.temps
- aes67_health: status.temps = [{name, c, min_c, max_c, warn_c}]
  (0.1 °C), sampled every 2 s by a health task. On-die "SoC" sensor via
  driver/temperature_sensor.h, range 20..100 °C, warn at 85 °C.
  health_temp_register(name, read_cb, warn_c) for board sensors.
  Warning logged when crossing warn_c, cleared below warn_c - 5 °C.
- Verified: SoC 26-28 °C at idle with min/max since boot; with a
  temporary 27 °C threshold the warning was logged and arrived via
  syslog as <132>, status carried warn_c 27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:20:02 +10:00
bsncubed 2dd8f60cd6 Web: keep httpd warnings out of the log
httpd_txrx warned on every client reset (errno 104) and httpd_uri on
every 404 (the UI polls /api/player every 2 s until step 7), flooding
syslog. Both set to error level; our handlers log the 4xx that matter.
Remaining known line: 'httpd_resp_send_err: error calling setsockopt :
22' (error level) when a client closes right after a 4xx; a harmless
race in IDF's CONFIG_HTTPD_ERR_RESP_NO_DELAY handling, the response is
already delivered.
Verified via syslog: 10 status + 10 /api/player requests, no httpd lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:10:24 +10:00
bsncubed 2522901918 PTP: don't report the pre-step offset
After a clock step, status.ptp showed the whole step (~1.79e18 ns) as
offset_ns until the next Sync, and the 60 s summary reported it as
max |offset|. Offset is reset to 0 and the summary restarts on a step.
Verified: status goes 0 -> pull-in values; first summary max 24960 ns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:08:42 +10:00
bsncubed 8b3bd39857 Step 5 (syslog): esp_log to remote syslog
- aes67_syslog: esp_log_set_vprintf hook chained to the UART; formats
  into a static buffer under a zero-timeout mutex (drop and count when
  busy), strips ANSI codes, parses level/tag, filters by log.level and
  queues (48 messages, non-blocking). Low-priority sender task: RFC 5424
  (<PRI>1 - HOST APP - - - MSG, no timestamp yet) or RFC 3164
  (<PRI>HOST TAG: MSG), PRI = facility*8 + severity; log.host as IP or
  resolved name; config applies live. Messages wait in the queue until
  the interface has an IP. POST /api/log/test sends an info message
  regardless of the level filter.
- Initialised first in app_main so boot messages are captured; the UDP
  socket is created only once the interface is up (creating it before
  esp_netif_init crashed at boot and the bootloader rolled back).
- Verified with a UDP listener: 5424 and 3164 formats, PRI 134/132,
  boot messages delivered after the IP came up, shutdown messages sent
  before reboot, level filter, test message, host by DNS name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:05:34 +10:00
bsncubed f3779696d5 Step 5 (SAP): announce the stream via SAP
- aes67_sdp_sap: SAP (RFC 2974) announcer task. SAPv1 to
  239.255.255.255:9875 every 30 s and within 1 s of any SDP change
  (config save, GM change), with the old hash deleted first. Active while
  aes67.discovery = sap, the stream is enabled, the AES67 interface has
  an IP and a PTP GM is known. Deletion when that stops and at shutdown
  (reboot/OTA) via a shutdown handler. TTL = aes67.ttl.
- Docs: SAP implementation notes; session_ver bump on GM change still open.
- Verified with a SAP listener: 30 s repeats with a stable hash; rename
  -> delete old + announce new (o= version follows); manual -> delete;
  sap -> announce; reboot and OTA -> delete at shutdown, first announce
  after boot only once the GM is known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:57:47 +10:00
14 changed files with 560 additions and 12 deletions
+5 -1
View File
@@ -47,7 +47,11 @@ Repo: https://gitea.apointless.space/bsncubed/aes67-ESP32-P4
- [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).
Done and checked with a receiver script (all ptimes, L16/L24, tone phase-locked to PTP); still open: the Riedel import and a Wireshark capture.
- [x] 5. SAP discovery, then syslog, then health/temperatures (one at a time). VLAN split moved to phase 2.
- [ ] 6. PTP TimeTransmitter: BMCA roles (auto/master), hybrid mode.
- [ ] 7. Sources: HLS player, then cspot (Spotify Connect), then failover + /api/player.
- [ ] 8. Mono sum, gain, polish.
## Phase 2 (parked)
- [ ] VLAN split: AES67 untagged + internet tagged (inet.vlan_id/pcp), per aes67-core-base.md "Network". Needs a tagged VLAN with DHCP on the switch port. Config group `inet` and the UI fields exist already; nothing is applied yet.
+1 -1
View File
@@ -1,3 +1,3 @@
idf_component_register(SRCS "aes67_health.c"
INCLUDE_DIRS "include"
PRIV_REQUIRES aes67_web esp_app_format esp_timer heap)
PRIV_REQUIRES aes67_web esp_app_format esp_driver_tsens esp_timer heap)
+126
View File
@@ -1,9 +1,98 @@
#include "aes67_health.h"
#include <string.h>
#include "aes67_web.h"
#include "driver/temperature_sensor.h"
#include "esp_app_desc.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#define MAX_SENSORS 8
#define SAMPLE_MS 2000
#define HYSTERESIS_C 5.0f
#define SOC_WARN_C 85.0f
static const char *TAG = "health";
typedef struct {
char name[16];
health_temp_read_cb_t read;
float warn_c;
bool valid, hot;
float c, min_c, max_c;
} sensor_t;
static sensor_t s_sensors[MAX_SENSORS];
static int s_n;
static SemaphoreHandle_t s_lock;
static temperature_sensor_handle_t s_tsens;
esp_err_t health_temp_register(const char *name, health_temp_read_cb_t read, float warn_c)
{
if (!s_lock || s_n >= MAX_SENSORS) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
sensor_t *t = &s_sensors[s_n++];
strlcpy(t->name, name, sizeof(t->name));
t->read = read;
t->warn_c = warn_c;
xSemaphoreGive(s_lock);
return ESP_OK;
}
// On-die sensor: measures the die, not ambient; expect well above room temperature.
static esp_err_t soc_read(float *c)
{
return temperature_sensor_get_celsius(s_tsens, c);
}
static void health_task(void *arg)
{
while (1) {
for (int i = 0; i < s_n; i++) {
float c;
if (s_sensors[i].read(&c) != ESP_OK) {
continue;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
sensor_t *t = &s_sensors[i];
t->c = c;
t->min_c = t->valid && t->min_c < c ? t->min_c : c;
t->max_c = t->valid && t->max_c > c ? t->max_c : c;
t->valid = true;
bool was_hot = t->hot;
if (t->warn_c > 0) {
if (!t->hot && c >= t->warn_c) {
t->hot = true;
} else if (t->hot && c < t->warn_c - HYSTERESIS_C) {
t->hot = false;
}
}
xSemaphoreGive(s_lock);
// Logged outside the lock; goes to syslog like any warning.
if (t->hot != was_hot) {
if (t->hot) {
ESP_LOGW(TAG, "temperature %s %.1f °C: at or above %.0f °C", t->name, c, t->warn_c);
} else {
ESP_LOGI(TAG, "temperature %s %.1f °C: back below %.0f °C", t->name, c,
t->warn_c - HYSTERESIS_C);
}
}
}
vTaskDelay(pdMS_TO_TICKS(SAMPLE_MS));
}
}
static double round1(float v)
{
return (double)(int)(v * 10.0f + (v < 0 ? -0.5f : 0.5f)) / 10.0;
}
static void health_status(cJSON *st)
{
@@ -11,9 +100,46 @@ static void health_status(cJSON *st)
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));
cJSON *temps = cJSON_AddArrayToObject(st, "temps");
xSemaphoreTake(s_lock, portMAX_DELAY);
for (int i = 0; i < s_n; i++) {
const sensor_t *t = &s_sensors[i];
if (!t->valid) {
continue;
}
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "name", t->name);
cJSON_AddNumberToObject(o, "c", round1(t->c));
cJSON_AddNumberToObject(o, "min_c", round1(t->min_c));
cJSON_AddNumberToObject(o, "max_c", round1(t->max_c));
if (t->warn_c > 0) {
cJSON_AddNumberToObject(o, "warn_c", t->warn_c);
}
cJSON_AddItemToArray(temps, o);
}
xSemaphoreGive(s_lock);
}
esp_err_t aes67_health_init(void)
{
s_lock = xSemaphoreCreateMutex();
if (!s_lock) {
return ESP_ERR_NO_MEM;
}
// 20..100 °C (+-2 °C): the die runs well above ambient and the warning level must be in range.
temperature_sensor_config_t cfg = TEMPERATURE_SENSOR_CONFIG_DEFAULT(20, 100);
esp_err_t err = temperature_sensor_install(&cfg, &s_tsens);
if (err == ESP_OK) {
err = temperature_sensor_enable(s_tsens);
}
if (err == ESP_OK) {
health_temp_register("SoC", soc_read, SOC_WARN_C);
} else {
ESP_LOGE(TAG, "on-die temperature sensor: %s", esp_err_to_name(err));
}
if (xTaskCreate(health_task, "health", 3072, NULL, 2, NULL) != pdPASS) {
return ESP_ERR_NO_MEM;
}
return status_register(health_status);
}
@@ -4,5 +4,11 @@
#include "esp_err.h"
// Registers the health fields (fw, uptime_s, heap_free, psram_free) in /api/status.
// Read one temperature in °C. Return ESP_OK on success.
typedef esp_err_t (*health_temp_read_cb_t)(float *celsius);
// Registers the health fields (fw, uptime_s, heap_free, psram_free, temps) in /api/status,
// adds the on-die "SoC" sensor and starts the 2 s sampling task.
esp_err_t aes67_health_init(void);
// Add a temperature sensor (e.g. an external I2C one from the board). warn_c <= 0: no warning.
esp_err_t health_temp_register(const char *name, health_temp_read_cb_t read, float warn_c);
+3
View File
@@ -268,6 +268,9 @@ static void servo(int64_t 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.offset_ns = 0; // the pre-step offset is history: not for status or the summary
s.sum_max_ns = 0;
s.sum_start_us = 0;
s.prev_t2 = 0; // rate across the step is meaningless
s.bad = 0;
set_locked(false);
+1 -1
View File
@@ -1,3 +1,3 @@
idf_component_register(SRCS "aes67_sdp_sap.c"
INCLUDE_DIRS "include"
PRIV_REQUIRES aes67_net aes67_ptp aes67_web esp_netif)
PRIV_REQUIRES aes67_net aes67_ptp aes67_web esp_netif esp_timer lwip)
+146 -2
View File
@@ -7,9 +7,21 @@
#include "aes67_net.h"
#include "aes67_ptp.h"
#include "aes67_web.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "lwip/sockets.h"
#define SDP_MAX 1024
#define SDP_MAX 1024
#define SAP_ADDR "239.255.255.255"
#define SAP_PORT 9875
#define SAP_INTERVAL_US (30 * 1000000LL)
#define SAP_MIME "application/sdp"
static const char *TAG = "sap";
// Same lines, in the same order, as the SDP preview in web/index.html.
size_t aes67_sdp_build(char *buf, size_t size)
@@ -82,8 +94,140 @@ static esp_err_t sdp_get(httpd_req_t *req)
return httpd_resp_send(req, buf, len);
}
/* ----- SAP (RFC 2974) ----- */
static struct {
int fd;
uint8_t ttl;
uint32_t origin; // IPv4 of the announcement (network order)
char sdp[SDP_MAX]; // what is currently announced ("" = nothing)
size_t len;
uint16_t hash;
} s_sap = { .fd = -1 };
static uint16_t sdp_hash(const char *sdp, size_t len)
{
uint32_t h = 2166136261u; // FNV-1a folded to 16 bits; 0 is not allowed
for (size_t i = 0; i < len; i++) {
h = (h ^ (uint8_t)sdp[i]) * 16777619u;
}
uint16_t v = (uint16_t)(h ^ (h >> 16));
return v ? v : 1;
}
static void sap_send(bool deletion)
{
static uint8_t pkt[8 + sizeof(SAP_MIME) + SDP_MAX];
pkt[0] = deletion ? 0x24 : 0x20; // V=1, IPv4, announce / delete (T bit)
pkt[1] = 0; // no authentication
pkt[2] = s_sap.hash >> 8;
pkt[3] = s_sap.hash & 0xff;
memcpy(pkt + 4, &s_sap.origin, 4);
memcpy(pkt + 8, SAP_MIME, sizeof(SAP_MIME)); // includes the terminating NUL
size_t n = 8 + sizeof(SAP_MIME);
memcpy(pkt + n, s_sap.sdp, s_sap.len);
n += s_sap.len;
struct sockaddr_in dst = { .sin_family = AF_INET, .sin_port = htons(SAP_PORT) };
inet_aton(SAP_ADDR, &dst.sin_addr);
if (sendto(s_sap.fd, pkt, n, 0, (struct sockaddr *)&dst, sizeof(dst)) < 0) {
ESP_LOGW(TAG, "send failed (errno %d)", errno);
}
}
static void sap_delete(const char *why)
{
if (s_sap.len && s_sap.fd >= 0) {
sap_send(true);
ESP_LOGI(TAG, "deleted announcement %04x (%s)", s_sap.hash, why);
}
s_sap.len = 0;
s_sap.sdp[0] = 0;
}
// Runs from esp_restart() (reboot, OTA): withdraw the session so receivers drop it now.
static void sap_shutdown(void)
{
if (s_sap.len) {
sap_delete("shutdown");
vTaskDelay(pdMS_TO_TICKS(20)); // let lwIP put it on the wire
}
}
static bool sap_wanted(uint8_t *ttl)
{
cJSON *a = cfg_get("aes67");
bool on = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(a, "enabled")) &&
strcmp(cJSON_GetObjectItemCaseSensitive(a, "discovery")->valuestring, "sap") == 0;
*ttl = (uint8_t)cJSON_GetObjectItemCaseSensitive(a, "ttl")->valuedouble;
cJSON_Delete(a);
return on;
}
static void sap_task(void *arg)
{
static char sdp[SDP_MAX];
int64_t last_us = 0;
uint8_t ttl_set = 0;
while (1) {
vTaskDelay(pdMS_TO_TICKS(1000));
esp_netif_ip_info_t ip;
esp_netif_t *netif = aes67_net_netif();
bool have_ip = netif && esp_netif_get_ip_info(netif, &ip) == ESP_OK && ip.ip.addr;
uint8_t ttl;
char gm[24];
if (!sap_wanted(&ttl) || !have_ip) {
sap_delete(have_ip ? "discovery off or stream disabled" : "no IP");
continue;
}
if (!aes67_ptp_gm_id(gm)) {
// Without a GM the SDP's ts-refclk would be all zeros: don't advertise that.
sap_delete("no PTP TimeTransmitter");
continue;
}
if (s_sap.fd < 0) {
s_sap.fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
uint8_t loop = 0;
setsockopt(s_sap.fd, IPPROTO_IP, IP_MULTICAST_LOOP, &loop, sizeof(loop));
}
if (ttl != ttl_set) { // same scope as the session
setsockopt(s_sap.fd, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl));
ttl_set = ttl;
}
struct in_addr ifa = { .s_addr = ip.ip.addr };
setsockopt(s_sap.fd, IPPROTO_IP, IP_MULTICAST_IF, &ifa, sizeof(ifa));
size_t len = aes67_sdp_build(sdp, sizeof(sdp));
if (!len) {
continue;
}
int64_t now = esp_timer_get_time();
if (len != s_sap.len || memcmp(sdp, s_sap.sdp, len) != 0 || ip.ip.addr != s_sap.origin) {
sap_delete("SDP changed");
memcpy(s_sap.sdp, sdp, len);
s_sap.len = len;
s_sap.hash = sdp_hash(sdp, len);
s_sap.origin = ip.ip.addr;
sap_send(false);
last_us = now;
ESP_LOGI(TAG, "announcing %04x to " SAP_ADDR ":%d every 30 s", s_sap.hash, SAP_PORT);
} else if (now - last_us >= SAP_INTERVAL_US) {
sap_send(false);
last_us = now;
}
}
}
esp_err_t aes67_sdp_sap_init(void)
{
static const httpd_uri_t uri = { .uri = "/stream.sdp", .method = HTTP_GET, .handler = sdp_get };
return web_register_uri(&uri);
esp_err_t err = web_register_uri(&uri);
if (err == ESP_OK) {
err = esp_register_shutdown_handler(sap_shutdown);
}
if (err == ESP_OK && xTaskCreate(sap_task, "sap", 4096, NULL, 3, NULL) != pdPASS) {
err = ESP_ERR_NO_MEM;
}
return err;
}
@@ -6,7 +6,10 @@
#include "esp_err.h"
// Registers GET /stream.sdp. (SAP: step 5.)
// Registers GET /stream.sdp and starts the SAP announcer (RFC 2974): 239.255.255.255:9875 every
// 30 s and on SDP change while aes67.discovery is "sap", the stream is enabled and a PTP GM is
// known (else ts-refclk would be all zeros); deletion
// when that stops and at shutdown.
esp_err_t aes67_sdp_sap_init(void);
// Build the current SDP (RFC 4566 + RFC 7273, AES67). Returns the length, or 0 if it didn't fit.
size_t aes67_sdp_build(char *buf, size_t size);
+1 -1
View File
@@ -1,3 +1,3 @@
idf_component_register(SRCS "aes67_syslog.c"
INCLUDE_DIRS "include"
PRIV_REQUIRES aes67_web)
PRIV_REQUIRES aes67_net aes67_web esp_netif log lwip)
+253 -1
View File
@@ -1,13 +1,41 @@
#include "aes67_syslog.h"
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include "aes67_cfg.h"
#include "aes67_net.h"
#include "aes67_web.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "lwip/netdb.h"
#include "lwip/sockets.h"
#define QUEUE_LEN 48
#define MSG_MAX 240
// facility: 16 = local0 (PRI = facility * 8 + severity).
static const char LOG_DEFAULTS[] =
"{\"syslog\":false,\"host\":\"\",\"port\":514,\"level\":\"info\",\"facility\":16,\"format\":\"rfc5424\"}";
typedef struct {
uint8_t severity; // 3 error, 4 warning, 6 info, 7 debug
char text[MSG_MAX]; // "tag: message" without the level/timestamp prefix
} msg_t;
static QueueHandle_t s_queue;
static SemaphoreHandle_t s_fmt_lock;
static TaskHandle_t s_task;
static vprintf_like_t s_uart;
static volatile bool s_enabled;
static volatile uint8_t s_min_severity = 6;
static volatile bool s_reconfig = true;
static volatile uint32_t s_dropped;
static bool log_validate(const cJSON *g, char *err, size_t n)
{
static const char *const levels[] = { "error", "warn", "info", "debug", NULL };
@@ -22,7 +50,231 @@ static bool log_validate(const cJSON *g, char *err, size_t n)
cfg_check_enum(g, "format", formats, err, n);
}
static uint8_t severity_of(const char *level)
{
return level[0] == 'e' ? 3 : level[0] == 'w' ? 4 : level[0] == 'i' ? 6 : 7;
}
static void read_filter(void)
{
cJSON *l = cfg_get("log");
s_enabled = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(l, "syslog"));
s_min_severity = severity_of(cJSON_GetObjectItemCaseSensitive(l, "level")->valuestring);
cJSON_Delete(l);
}
static void log_apply(const cJSON *g)
{
read_filter();
s_reconfig = true;
if (s_task) {
xTaskNotifyGive(s_task);
}
}
/* ----- esp_log hook ----- */
// "\033[0;32mI (1234) tag: message\033[0m\n" -> severity + "tag: message"
static bool parse_line(const char *in, msg_t *m)
{
char *o = m->text, *end = m->text + MSG_MAX - 1;
const char *p = in;
char level = 0;
bool prefix_done = false;
while (*p && o < end) {
if (*p == '\033') { // skip ANSI colour codes
while (*p && *p != 'm') {
p++;
}
if (*p) {
p++;
}
continue;
}
if (!level) {
level = *p++;
continue;
}
if (!prefix_done) { // skip " (1234) "
if (*p == ')') {
prefix_done = true;
p++;
if (*p == ' ') {
p++;
}
} else {
p++;
}
continue;
}
if (*p == '\n' || *p == '\r') {
p++;
continue;
}
*o++ = *p++;
}
*o = 0;
m->severity = level == 'E' ? 3 : level == 'W' ? 4 : level == 'I' ? 6 : 7;
return prefix_done && m->text[0];
}
static int log_hook(const char *fmt, va_list ap)
{
va_list copy;
va_copy(copy, ap);
int ret = s_uart(fmt, ap); // UART output unchanged
if (s_enabled && xTaskGetCurrentTaskHandle() != s_task &&
xSemaphoreTake(s_fmt_lock, 0) == pdTRUE) {
static char line[MSG_MAX + 32];
static msg_t m;
vsnprintf(line, sizeof(line), fmt, copy);
if (parse_line(line, &m) && m.severity <= s_min_severity &&
xQueueSend(s_queue, &m, 0) != pdTRUE) {
s_dropped++;
}
xSemaphoreGive(s_fmt_lock);
} else if (s_enabled && xTaskGetCurrentTaskHandle() != s_task) {
s_dropped++; // formatter busy: drop rather than block
}
va_end(copy);
return ret;
}
/* ----- sender ----- */
typedef struct {
struct sockaddr_in dst;
bool have_dst;
uint8_t facility;
bool rfc5424;
char host[64];
char hostname[64];
} sender_cfg_t;
static void load_sender(sender_cfg_t *c)
{
cJSON *l = cfg_get("log"), *n = cfg_get("net");
strlcpy(c->host, cJSON_GetObjectItemCaseSensitive(l, "host")->valuestring, sizeof(c->host));
c->facility = (uint8_t)cJSON_GetObjectItemCaseSensitive(l, "facility")->valuedouble;
c->rfc5424 = strcmp(cJSON_GetObjectItemCaseSensitive(l, "format")->valuestring, "rfc5424") == 0;
uint16_t port = (uint16_t)cJSON_GetObjectItemCaseSensitive(l, "port")->valuedouble;
strlcpy(c->hostname, cJSON_GetObjectItemCaseSensitive(n, "hostname")->valuestring, sizeof(c->hostname));
cJSON_Delete(l);
cJSON_Delete(n);
c->have_dst = false;
memset(&c->dst, 0, sizeof(c->dst));
c->dst.sin_family = AF_INET;
c->dst.sin_port = htons(port);
if (inet_aton(c->host, &c->dst.sin_addr)) {
c->have_dst = true;
return;
}
struct addrinfo hints = { .ai_family = AF_INET, .ai_socktype = SOCK_DGRAM }, *res = NULL;
if (c->host[0] && getaddrinfo(c->host, NULL, &hints, &res) == 0 && res) {
c->dst.sin_addr = ((struct sockaddr_in *)res->ai_addr)->sin_addr;
c->have_dst = true;
}
if (res) {
freeaddrinfo(res);
}
}
// RFC 5424: <PRI>1 - HOSTNAME APP-NAME - - - MSG (no wall-clock time yet: NILVALUE)
// RFC 3164: <PRI>HOSTNAME TAG: MSG (TIMESTAMP omitted; the server adds it)
static int format_msg(char *out, size_t size, const sender_cfg_t *c, const msg_t *m)
{
const char *colon = strstr(m->text, ": ");
char tag[33] = "-";
const char *msg = m->text;
if (colon && colon - m->text < (int)sizeof(tag)) {
memcpy(tag, m->text, colon - m->text);
tag[colon - m->text] = 0;
msg = colon + 2;
}
int pri = c->facility * 8 + m->severity;
if (c->rfc5424) {
return snprintf(out, size, "<%d>1 - %s %s - - - %s", pri, c->hostname, tag, msg);
}
return snprintf(out, size, "<%d>%s %s: %s", pri, c->hostname, tag, msg);
}
static void syslog_task(void *arg)
{
sender_cfg_t c = { 0 };
int fd = -1; // created once the interface is up: lwIP may not be running yet at init
msg_t m;
char out[MSG_MAX + 128];
while (1) {
// Hold messages until the interface has an IP (early boot logs stay queued).
esp_netif_ip_info_t ip;
esp_netif_t *netif = aes67_net_netif();
if (!netif || esp_netif_get_ip_info(netif, &ip) != ESP_OK || !ip.ip.addr) {
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(500));
continue;
}
if (fd < 0 && (fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)) < 0) {
vTaskDelay(pdMS_TO_TICKS(1000));
continue;
}
if (s_reconfig) {
s_reconfig = false;
load_sender(&c);
if (s_enabled && !c.have_dst) {
ESP_LOGW("syslog", "cannot resolve '%s'", c.host); // not sent: logged from this task
}
}
if (xQueueReceive(s_queue, &m, pdMS_TO_TICKS(1000)) != pdTRUE) {
continue;
}
if (s_enabled && c.have_dst) {
int n = format_msg(out, sizeof(out), &c, &m);
sendto(fd, out, n < (int)sizeof(out) ? n : (int)sizeof(out) - 1, 0,
(struct sockaddr *)&c.dst, sizeof(c.dst));
}
static uint32_t reported;
if (s_dropped != reported) {
reported = s_dropped;
msg_t d = { .severity = 4 };
snprintf(d.text, sizeof(d.text), "syslog: %lu messages dropped so far (queue full)",
(unsigned long)reported);
xQueueSend(s_queue, &d, 0);
}
}
}
static esp_err_t test_post(httpd_req_t *req)
{
if (!s_enabled) {
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "syslog is disabled");
}
msg_t m = { .severity = 6 }; // info, regardless of the level filter
snprintf(m.text, sizeof(m.text), "syslog: test message from the web UI");
if (xQueueSend(s_queue, &m, 0) != pdTRUE) {
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "syslog queue full");
}
return httpd_resp_sendstr(req, "");
}
esp_err_t aes67_syslog_init(void)
{
return cfg_register("log", LOG_DEFAULTS, log_validate, NULL);
esp_err_t err = cfg_register("log", LOG_DEFAULTS, log_validate, log_apply);
if (err != ESP_OK) {
return err;
}
s_queue = xQueueCreate(QUEUE_LEN, sizeof(msg_t));
s_fmt_lock = xSemaphoreCreateMutex();
if (!s_queue || !s_fmt_lock) {
return ESP_ERR_NO_MEM;
}
read_filter();
if (xTaskCreate(syslog_task, "syslog", 4096, NULL, 2, &s_task) != pdPASS) {
return ESP_ERR_NO_MEM;
}
static const httpd_uri_t uri = { .uri = "/api/log/test", .method = HTTP_POST, .handler = test_post };
err = web_register_uri(&uri);
s_uart = esp_log_set_vprintf(log_hook);
return err;
}
@@ -4,5 +4,6 @@
#include "esp_err.h"
// Registers the "log" config group. (Sender itself: step 5.)
// Registers the "log" config group, hooks esp_log (still printed on the UART) and starts the
// sender task. Messages logged before the interface has an IP are queued (up to the queue size).
esp_err_t aes67_syslog_init(void);
+5
View File
@@ -104,6 +104,11 @@ esp_err_t aes67_web_start(void)
return err;
}
// httpd warns on every client reset (errno 104) and every 404 (e.g. UI polling a route the
// firmware doesn't have yet): noise in syslog. Our handlers log the 4xx cases that matter.
esp_log_level_set("httpd_txrx", ESP_LOG_ERROR);
esp_log_level_set("httpd_uri", ESP_LOG_ERROR);
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
cfg.max_uri_handlers = MAX_URIS;
cfg.stack_size = 8192;
+4 -1
View File
@@ -33,6 +33,7 @@ Reference devices for UI and defaults: Riedel Bolero (PTP status), Riedel Direct
## Temperatures
- `status.temps` = array of {name, c, min_c, max_c, warn_c}; min/max since boot, warn_c optional. Any number of sensors; the UI renders one row per sensor and marks HIGH (red) at or above warn_c.
- ESP32-P4: on-die sensor via the `temperature_sensor` driver (driver/temperature_sensor.h). Pick the measurement range to suit (e.g. -10..80 °C), sample every ~2 s in the health task, and report it as "SoC". It measures the die, not ambient: expect it to read well above room temperature.
- Implemented with range 20..100 °C (±2 °C; the warning level must lie inside the range), warn_c 85 °C. On the test board the die reads ~27 °C at idle in a cool room.
- Boards may add external sensors (e.g. I2C) by appending to the same array; the project or board component registers them.
- Log a warning (goes to syslog) when crossing warn_c, with hysteresis (e.g. clear at warn_c - 5).
@@ -87,6 +88,8 @@ When this device is the GM, offset/frequency/delay show "–".
- Discovery: manual (SDP export only) or SAP. NMOS IS-04/05 is a possible future option (Riedel SIC cards have an NMOS tab).
- SDP: v, o (aes67 IP, session_id, session_ver), s, c=mcast/ttl, t=0 0, a=clock-domain:PTPv2 <dom>, m=audio port RTP/AVP pt, a=rtpmap, a=recvonly, a=ptime, a=ts-refclk:ptp=IEEE1588-2008:<gm>:<dom>, a=mediaclk:direct.
- SAP to 239.255.255.255:9875 every 30 s, plus immediately on change; deletion packet on disable or shutdown.
- Implemented (aes67_sdp_sap): SAPv1, payload type `application/sdp`, msg id hash = 16-bit hash of the SDP, TTL = aes67.ttl. The SDP is checked once a second; on a change the old hash is deleted before the new one is announced. Nothing is announced until a PTP GM is known (else ts-refclk would be all zeros). Shutdown deletion via `esp_register_shutdown_handler` (covers reboot and OTA).
- Not done yet: bumping session_ver on a GM change (needs a firmware-side config write; relevant once the device can become GM, step 6). A GM change already re-announces immediately with the new ts-refclk.
- Media 2 (ST 2022-7 redundancy): not possible on single-port boards like the ESP32-P4-ETH. Keep the schema open for a `mcast2`/`port2` pair on dual-NIC hardware (Riedel applies one PTP config to both Media 1 and Media 2).
## AES67 RX (future, for receiver projects) — Riedel Director 4-wire input defaults
@@ -95,7 +98,7 @@ When this device is the GM, offset/frequency/delay show "–".
- Channel selection: which channel of the stream feeds a mono output.
## Network
- Default: one untagged interface. VLAN split option: AES67 untagged + internet tagged (inet.vlan_id/pcp) using the ESP-IDF vlan_support approach (examples/network/vlan_support, ESP32-P4 supported). AES67 side has no gateway; default route and DNS are on inet.
- Default: one untagged interface. VLAN split (phase 2, not implemented yet) 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).
+2 -1
View File
@@ -26,6 +26,8 @@ void app_main(void)
chip.revision / 100, chip.revision % 100, chip.cores);
project_cfg_defaults();
// First, so boot messages are queued for syslog until the interface has an IP.
ESP_ERROR_CHECK(aes67_syslog_init());
esp_eth_handle_t eth;
ESP_ERROR_CHECK(aes67_board_eth_init(&eth));
@@ -34,7 +36,6 @@ void app_main(void)
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_sdp_sap_init());