f3779696d5
- 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>
234 lines
7.6 KiB
C
234 lines
7.6 KiB
C
#include "aes67_sdp_sap.h"
|
|
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
|
|
#include "aes67_cfg.h"
|
|
#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 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)
|
|
{
|
|
cJSON *a = cfg_get("aes67");
|
|
cJSON *p = cfg_get("ptp");
|
|
if (!a || !p) {
|
|
cJSON_Delete(a);
|
|
cJSON_Delete(p);
|
|
return 0;
|
|
}
|
|
#define NUM(o, k) cJSON_GetObjectItemCaseSensitive(o, k)->valuedouble
|
|
#define STR(o, k) cJSON_GetObjectItemCaseSensitive(o, k)->valuestring
|
|
char ip[16] = "0.0.0.0";
|
|
esp_netif_ip_info_t info;
|
|
esp_netif_t *netif = aes67_net_netif();
|
|
if (netif && esp_netif_get_ip_info(netif, &info) == ESP_OK && info.ip.addr) {
|
|
snprintf(ip, sizeof(ip), IPSTR, IP2STR(&info.ip));
|
|
}
|
|
char gm[24];
|
|
if (!aes67_ptp_gm_id(gm)) {
|
|
strcpy(gm, "00-00-00-00-00-00-00-00");
|
|
}
|
|
int ch = (int)NUM(a, "channels");
|
|
char info_ch[16];
|
|
snprintf(info_ch, sizeof(info_ch), ch == 1 ? "mono" : ch == 2 ? "stereo" : "%d ch", ch);
|
|
int dom = (int)NUM(p, "domain");
|
|
int pt = (int)NUM(a, "pt");
|
|
|
|
int n = snprintf(buf, size,
|
|
"v=0\r\n"
|
|
"o=- %.0f %.0f IN IP4 %s\r\n"
|
|
"s=%s\r\n"
|
|
"c=IN IP4 %s/%d\r\n"
|
|
"t=0 0\r\n"
|
|
"a=clock-domain:PTPv2 %d\r\n"
|
|
"m=audio %d RTP/AVP %d\r\n"
|
|
"i=%s\r\n"
|
|
"a=rtpmap:%d %s/%d/%d\r\n"
|
|
"a=recvonly\r\n"
|
|
"a=ptime:%g\r\n"
|
|
"a=ts-refclk:ptp=IEEE1588-2008:%s:%d\r\n"
|
|
"a=mediaclk:direct=%.0f\r\n",
|
|
NUM(a, "session_id"), NUM(a, "session_ver"), ip,
|
|
STR(a, "name"),
|
|
STR(a, "mcast"), (int)NUM(a, "ttl"),
|
|
dom,
|
|
(int)NUM(a, "port"), pt,
|
|
info_ch,
|
|
pt, STR(a, "encoding"), (int)NUM(a, "rate"), ch,
|
|
NUM(a, "ptime"),
|
|
gm, dom,
|
|
NUM(a, "clk_offset"));
|
|
#undef NUM
|
|
#undef STR
|
|
cJSON_Delete(a);
|
|
cJSON_Delete(p);
|
|
return n > 0 && (size_t)n < size ? (size_t)n : 0;
|
|
}
|
|
|
|
static esp_err_t sdp_get(httpd_req_t *req)
|
|
{
|
|
char buf[SDP_MAX];
|
|
size_t len = aes67_sdp_build(buf, sizeof(buf));
|
|
if (!len) {
|
|
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "SDP build failed");
|
|
}
|
|
httpd_resp_set_type(req, "application/sdp");
|
|
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
|
|
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 };
|
|
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;
|
|
}
|