Files
bsncubed dcea39b216 Step 6.2: role hand-over, session_ver bump on GM change
- aes67_cfg: cfg_set_number(group, key, value, apply) for firmware-side
  changes, stored in NVS like a POST; apply can be skipped.
- aes67_sdp_sap: once a second, a change of the PTP GM (ts-refclk) bumps
  aes67.session_ver without re-applying the aes67 group (no stream
  restart); SAP then re-announces. Runs whether or not SAP is on.
- Verified with ptp4l as a normal clock (p1 128): board auto (p1 250)
  steps down to SLAVE (session_ver 1->2); role master p1 100 via API ->
  board MASTER at once (->3); back to auto p1 250 -> ptp4l takes over
  within ~1 s, board SLAVE again (->4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:08:00 +10:00

255 lines
8.4 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;
}
// When the PTP GM changes, the SDP's ts-refclk changes: bump the session version (RFC 4566 o=)
// so receivers see a new description. Runs whether or not SAP is on (manual SDP users too).
static void check_gm_change(void)
{
static char last[24];
char gm[24];
if (!aes67_ptp_gm_id(gm) || strcmp(gm, last) == 0) {
return;
}
if (last[0]) {
cJSON *a = cfg_get("aes67");
double ver = cJSON_GetObjectItemCaseSensitive(a, "session_ver")->valuedouble + 1;
cJSON_Delete(a);
// Not applied: re-applying the aes67 group would restart the stream.
esp_err_t err = cfg_set_number("aes67", "session_ver", ver, false);
ESP_LOGI(TAG, "PTP TimeTransmitter %s -> %s: session_ver %.0f (%s)", last, gm, ver, esp_err_to_name(err));
}
strcpy(last, gm);
}
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));
check_gm_change();
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;
}