diff --git a/components/aes67_sdp_sap/CMakeLists.txt b/components/aes67_sdp_sap/CMakeLists.txt index fbb348e..f642dff 100644 --- a/components/aes67_sdp_sap/CMakeLists.txt +++ b/components/aes67_sdp_sap/CMakeLists.txt @@ -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) diff --git a/components/aes67_sdp_sap/aes67_sdp_sap.c b/components/aes67_sdp_sap/aes67_sdp_sap.c index 553526d..6e34f6b 100644 --- a/components/aes67_sdp_sap/aes67_sdp_sap.c +++ b/components/aes67_sdp_sap/aes67_sdp_sap.c @@ -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; } diff --git a/components/aes67_sdp_sap/include/aes67_sdp_sap.h b/components/aes67_sdp_sap/include/aes67_sdp_sap.h index 110ab96..84fd79f 100644 --- a/components/aes67_sdp_sap/include/aes67_sdp_sap.h +++ b/components/aes67_sdp_sap/include/aes67_sdp_sap.h @@ -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); diff --git a/docs/aes67-core-base.md b/docs/aes67-core-base.md index 155d54e..6110353 100644 --- a/docs/aes67-core-base.md +++ b/docs/aes67-core-base.md @@ -87,6 +87,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 , m=audio port RTP/AVP pt, a=rtpmap, a=recvonly, a=ptime, a=ts-refclk:ptp=IEEE1588-2008::, 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