Эх сурвалжийг харах

feat(dante): clean-room Dante mDNS discovery

Add components/dante, a new Apache-2.0 clean-room component implemented
from docs/dante-protocol-notes.md (not from reference/inferno).

First capability is read-only discovery: browse mDNS for the four Dante
DNS-SD service types (_netaudio-arc/-cmc/-chan/-bund) and report each
instance (host, IP, port, TXT) via callback or log.

- dante_discovery_scan(): one-shot synchronous sweep.
- dante_discovery_start()/stop(): background scanner on an interval.
- Shares the process-wide ESP-IDF mDNS instance with the AES67 stack
  (query-only; never frees mDNS).
- Kconfig: DANTE_DISCOVERY_ON_BOOT (default y), DANTE_DISCOVERY_INTERVAL_MS.
- main wires it up behind the Kconfig flag after the node is ready.

Read-only and audio-free, so safe on a live Dante network; primarily a
bring-up aid. Advertisement and the ARC/CMC/flow servers are intentionally
left for later (an advertised device that can't answer queries is worse
than an invisible one).

Not build-verified (no ESP-IDF toolchain in this environment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Benjamin Harris 3 сар өмнө
parent
commit
421d6712e9

+ 4 - 2
README.md

@@ -86,6 +86,7 @@ Ethernet PHY: IP101 GRI via RMII. Onboard codec: ES8311 + NS4150B amp.
 
 ```
 components/aes67/   Forked from aes67-esp32p4 — AES67 engine (PTP, RTP, I2S, SAP/SDP, mDNS)
+components/dante/    Clean-room Dante support (discovery now; ARC/CMC/flows/PTPv1 later)
 main/                Application entry point, board config
 docs/                Design notes, incl. clean-room Dante protocol notes
 reference/           Local clones of both source repos, for reading only (gitignored, not part of this repo)
@@ -113,8 +114,9 @@ pinout confirmation noted above.
       are inconsistent and `board_config.h` is a placeholder)
 - [ ] Build-verify with ESP-IDF v5.5+ (`idf.py set-target esp32p4 build`)
 - [ ] Get AES67 send/receive verified against real Dante gear in AES67 mode
-- [ ] Write clean-room Dante protocol notes (`docs/dante-protocol-notes.md`)
-- [ ] Prototype Dante mDNS advertisement/discovery
+- [x] Write clean-room Dante protocol notes (`docs/dante-protocol-notes.md`)
+- [x] Prototype Dante mDNS **discovery** (`components/dante`, read-only browse)
+- [ ] Prototype Dante mDNS **advertisement** (needs ARC/CMC servers behind it)
 - [ ] Prototype Dante control protocol (routing/subscriptions)
 - [ ] Investigate PTPv1 support alongside the existing PTPv2 stack
 

+ 6 - 0
components/dante/CMakeLists.txt

@@ -0,0 +1,6 @@
+idf_component_register(
+    SRCS "src/dante_discovery.c"
+    INCLUDE_DIRS "include"
+    REQUIRES esp_netif
+    PRIV_REQUIRES mdns lwip
+)

+ 21 - 0
components/dante/Kconfig

@@ -0,0 +1,21 @@
+menu "Dante"
+
+config DANTE_DISCOVERY_ON_BOOT
+    bool "Browse for Dante devices on boot"
+    default y
+    help
+        Start a background mDNS scanner once the network is up that logs
+        discovered Dante devices, their transmit channels, and multicast
+        bundles. This is read-only (it does not advertise this device) and
+        is useful for AES67 bring-up: it shows what Dante gear is on the
+        wire and how its channels are formatted.
+
+config DANTE_DISCOVERY_INTERVAL_MS
+    int "Discovery scan interval (ms)"
+    depends on DANTE_DISCOVERY_ON_BOOT
+    default 15000
+    range 2000 600000
+    help
+        How often the background scanner repeats the mDNS sweep.
+
+endmenu

+ 60 - 0
components/dante/README.md

@@ -0,0 +1,60 @@
+# components/dante
+
+Clean-room Dante protocol support for the ESP32-P4, written independently
+from the behavioural notes in [`docs/dante-protocol-notes.md`](../../docs/dante-protocol-notes.md).
+
+> **Licensing:** this component is Apache-2.0 and must stay clean-room. It is
+> implemented from *understanding* of Dante's on-the-wire behaviour, never by
+> copying, translating, or closely paraphrasing `reference/inferno` (GPL/
+> AGPL). See the hard rule in [`CLAUDE.md`](../../CLAUDE.md).
+
+## What's here now
+
+### Discovery (`dante_discovery.h`)
+
+Read-only mDNS/DNS-SD browsing for Dante devices. It queries the four Dante
+service types (`_netaudio-arc`, `_netaudio-cmc`, `_netaudio-chan`,
+`_netaudio-bund`) and reports each instance found — hostname, IP, port, and
+TXT records — via a callback, or logs them if no callback is given.
+
+- `dante_discovery_scan()` — one-shot synchronous sweep.
+- `dante_discovery_start()` / `dante_discovery_stop()` — background scanner
+  that repeats on an interval.
+
+This does **not** advertise this device and does **not** touch audio, so it is
+safe to run on a live Dante network. It's primarily a bring-up aid: it shows
+what Dante gear is present and how its channels are formatted (sample rate,
+bit depth, latency, fpp), which is exactly what you need when getting AES67
+interop working.
+
+It shares the process-wide ESP-IDF mDNS instance with the AES67 stack (it only
+ever queries; it never frees mDNS).
+
+#### Enabling
+
+Controlled by Kconfig (`idf.py menuconfig` → *Dante*):
+
+- `DANTE_DISCOVERY_ON_BOOT` (default y) — `main` starts the background scanner
+  once the network is up.
+- `DANTE_DISCOVERY_INTERVAL_MS` (default 15000) — sweep interval.
+
+## Not yet implemented
+
+Everything past discovery, in rough dependency order (see the protocol notes):
+
+- **Advertisement** — publishing `_netaudio-arc`/`-cmc`/`-chan`/`-bund` so
+  Dante Controller sees this device. Deliberately not done yet: an advertised
+  device that can't answer ARC/CMC queries is worse than an invisible one.
+- **CMC** server — device-announce request/response (port 8800).
+- **ARC** server — capability + channel enumeration and the name-based
+  subscription (routing) model (port 4440).
+- **Flow control (dbcp)** — open/update/stop audio flows with keepalives
+  (port 4455).
+- **Media framing** — the 9-byte-header Dante media packets ↔ our I2S engine.
+- **PTPv1** — the legacy Dante clock (see `CLAUDE.md` "Known hard problems").
+
+## Status
+
+Compiles as an ESP-IDF component but is **not yet build-verified** (no
+ESP-IDF toolchain in the dev environment where it was written) and untested
+against real Dante hardware.

+ 7 - 0
components/dante/idf_component.yml

@@ -0,0 +1,7 @@
+version: "0.1.0"
+description: "Clean-room Dante protocol support for ESP32-P4 (discovery first)."
+dependencies:
+  idf:
+    version: ">=5.5.0"
+  espressif/mdns:
+    version: ">=1.0.0"

+ 88 - 0
components/dante/include/dante_discovery.h

@@ -0,0 +1,88 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Benjamin Harris
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Dante device discovery over mDNS / DNS-SD.
+ *
+ * CLEAN-ROOM: implemented from the behavioural notes in
+ * docs/dante-protocol-notes.md (service types, ports, TXT keys), NOT from
+ * any GPL/AGPL reference source. See CLAUDE.md.
+ *
+ * This is the read-only half of Dante discovery: it browses the network for
+ * Dante devices and their transmit channels / multicast bundles and reports
+ * what it finds. It does not advertise this device (that needs the ARC/CMC
+ * servers behind it) and does not touch audio.
+ *
+ * Dante publishes these DNS-SD service types, all under _udp.local:
+ *   _netaudio-arc   device control endpoint          (ARC port, e.g. 4440)
+ *   _netaudio-cmc   device announce endpoint         (CMC port, e.g. 8800)
+ *   _netaudio-chan  one instance per TX channel      (flow-control port)
+ *   _netaudio-bund  a multicast bundle other devices can join
+ */
+#pragma once
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "esp_err.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* Which Dante DNS-SD service a result came from. */
+typedef enum {
+    DANTE_SVC_ARC = 0,  /* _netaudio-arc  — device control endpoint */
+    DANTE_SVC_CMC,      /* _netaudio-cmc  — device announce endpoint */
+    DANTE_SVC_CHAN,     /* _netaudio-chan — a transmit channel */
+    DANTE_SVC_BUND,     /* _netaudio-bund — a multicast bundle */
+    DANTE_SVC_COUNT
+} dante_service_type_t;
+
+/* One discovered service instance, flattened from an mDNS PTR+SRV+TXT+A set.
+ * String fields are only valid for the duration of the callback. */
+typedef struct {
+    dante_service_type_t service;   /* which _netaudio-* type */
+    const char          *instance;  /* DNS-SD instance name, e.g. "chanName@host" */
+    const char          *hostname;  /* SRV target host */
+    uint32_t             ipv4;       /* resolved IPv4 (host byte order), 0 if none */
+    uint16_t             port;       /* SRV port */
+    const char *const   *txt_keys;   /* TXT record keys   (txt_count entries) */
+    const char *const   *txt_vals;   /* TXT record values (txt_count entries) */
+    size_t               txt_count;
+} dante_discovery_result_t;
+
+/* Callback invoked once per discovered instance. `user` is passed through
+ * from the scan/start call. Must not block for long. */
+typedef void (*dante_discovery_cb_t)(const dante_discovery_result_t *result,
+                                     void *user);
+
+/*
+ * One-shot synchronous scan of all Dante service types.
+ *
+ * Issues an mDNS PTR query per service type, waits up to `timeout_ms` for
+ * each, and invokes `cb` for every instance found. If `cb` is NULL, results
+ * are logged at INFO level instead. Safe to call whether or not the AES67
+ * stack has already initialised mDNS (init is shared and idempotent).
+ *
+ * Returns ESP_OK if the scan ran (even if nothing was found).
+ */
+esp_err_t dante_discovery_scan(uint32_t timeout_ms,
+                               dante_discovery_cb_t cb, void *user);
+
+/*
+ * Start a background task that repeats dante_discovery_scan() every
+ * `interval_ms`. `cb`/`user` are as above. Only one background scanner may
+ * run at a time. Pin/priority follow project convention (core 0, low prio).
+ */
+esp_err_t dante_discovery_start(uint32_t interval_ms,
+                                dante_discovery_cb_t cb, void *user);
+
+/* Stop the background scanner started by dante_discovery_start(). */
+void dante_discovery_stop(void);
+
+/* Human-readable name for a service type, e.g. "_netaudio-arc". */
+const char *dante_service_type_str(dante_service_type_t svc);
+
+#ifdef __cplusplus
+}
+#endif

+ 204 - 0
components/dante/src/dante_discovery.c

@@ -0,0 +1,204 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Benjamin Harris
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Dante mDNS discovery — clean-room, from docs/dante-protocol-notes.md.
+ */
+
+#include "dante_discovery.h"
+
+#include <stdio.h>
+#include <string.h>
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "esp_log.h"
+#include "mdns.h"
+#include "lwip/def.h"
+
+static const char *TAG = "dante_disc";
+
+/* DNS-SD service names for each Dante service type. All use "_udp".
+ * Order MUST match dante_service_type_t. */
+static const char *const s_service_names[DANTE_SVC_COUNT] = {
+    [DANTE_SVC_ARC]  = "_netaudio-arc",
+    [DANTE_SVC_CMC]  = "_netaudio-cmc",
+    [DANTE_SVC_CHAN] = "_netaudio-chan",
+    [DANTE_SVC_BUND] = "_netaudio-bund",
+};
+
+/* Cap on TXT records copied per instance (Dante channel records are well
+ * under this; the extra are ignored rather than risking a large stack copy). */
+#define DANTE_MAX_TXT 24
+
+/* Per-query result cap; a large Dante network can have many channels, but for
+ * discovery logging a bounded slice per scan is plenty. */
+#define DANTE_MAX_RESULTS 32
+
+/* Background scanner state. */
+static TaskHandle_t          s_task;
+static volatile bool         s_running;
+static uint32_t              s_interval_ms;
+static uint32_t              s_scan_timeout_ms;
+static dante_discovery_cb_t  s_cb;
+static void                 *s_cb_user;
+
+const char *dante_service_type_str(dante_service_type_t svc)
+{
+    if (svc < 0 || svc >= DANTE_SVC_COUNT) {
+        return "?";
+    }
+    return s_service_names[svc];
+}
+
+/* Pull the first IPv4 address (host byte order) from an mDNS addr list. */
+static uint32_t first_ipv4(const mdns_ip_addr_t *addr)
+{
+    for (const mdns_ip_addr_t *a = addr; a != NULL; a = a->next) {
+        if (a->addr.type == ESP_IPADDR_TYPE_V4) {
+            return lwip_ntohl(a->addr.u_addr.ip4.addr);
+        }
+    }
+    return 0;
+}
+
+/* Default reporter used when the caller passes cb == NULL: log the instance. */
+static void log_result(const dante_discovery_result_t *r, void *user)
+{
+    (void)user;
+    char ip[16] = "-";
+    if (r->ipv4) {
+        snprintf(ip, sizeof(ip), "%u.%u.%u.%u",
+                 (unsigned)((r->ipv4 >> 24) & 0xFF),
+                 (unsigned)((r->ipv4 >> 16) & 0xFF),
+                 (unsigned)((r->ipv4 >> 8) & 0xFF),
+                 (unsigned)(r->ipv4 & 0xFF));
+    }
+    ESP_LOGI(TAG, "%s  %s  (%s:%u)",
+             dante_service_type_str(r->service),
+             r->instance ? r->instance : "?",
+             ip, r->port);
+    for (size_t i = 0; i < r->txt_count; i++) {
+        ESP_LOGI(TAG, "      %s=%s",
+                 r->txt_keys[i] ? r->txt_keys[i] : "",
+                 r->txt_vals[i] ? r->txt_vals[i] : "");
+    }
+}
+
+/* Walk one mDNS PTR result list, invoking cb for each instance. */
+static void report_results(dante_service_type_t svc,
+                           mdns_result_t *results,
+                           dante_discovery_cb_t cb, void *user)
+{
+    for (mdns_result_t *m = results; m != NULL; m = m->next) {
+        const char *txt_keys[DANTE_MAX_TXT];
+        const char *txt_vals[DANTE_MAX_TXT];
+        size_t n = m->txt_count < DANTE_MAX_TXT ? m->txt_count : DANTE_MAX_TXT;
+        for (size_t i = 0; i < n; i++) {
+            txt_keys[i] = m->txt[i].key;
+            txt_vals[i] = m->txt[i].value;
+        }
+
+        dante_discovery_result_t r = {
+            .service   = svc,
+            .instance  = m->instance_name,
+            .hostname  = m->hostname,
+            .ipv4      = first_ipv4(m->addr),
+            .port      = m->port,
+            .txt_keys  = txt_keys,
+            .txt_vals  = txt_vals,
+            .txt_count = n,
+        };
+        cb(&r, user);
+    }
+}
+
+esp_err_t dante_discovery_scan(uint32_t timeout_ms,
+                               dante_discovery_cb_t cb, void *user)
+{
+    /* mDNS is a shared, process-wide service. The AES67 stack initialises it
+     * during node start; if it hasn't yet, do it ourselves. Either way we
+     * never free it — we're only a querier. */
+    esp_err_t err = mdns_init();
+    if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
+        ESP_LOGE(TAG, "mdns_init failed: %s", esp_err_to_name(err));
+        return err;
+    }
+
+    if (cb == NULL) {
+        cb = log_result;
+    }
+
+    int total = 0;
+    for (int svc = 0; svc < DANTE_SVC_COUNT; svc++) {
+        mdns_result_t *results = NULL;
+        err = mdns_query_ptr(s_service_names[svc], "_udp",
+                             timeout_ms, DANTE_MAX_RESULTS, &results);
+        if (err != ESP_OK) {
+            ESP_LOGW(TAG, "query %s failed: %s",
+                     s_service_names[svc], esp_err_to_name(err));
+            continue;
+        }
+        for (mdns_result_t *m = results; m != NULL; m = m->next) {
+            total++;
+        }
+        report_results((dante_service_type_t)svc, results, cb, user);
+        mdns_query_results_free(results);
+    }
+
+    ESP_LOGI(TAG, "scan complete: %d Dante service instance(s)", total);
+    return ESP_OK;
+}
+
+static void discovery_task(void *arg)
+{
+    (void)arg;
+    ESP_LOGI(TAG, "discovery task started (interval=%lums)",
+             (unsigned long)s_interval_ms);
+    while (s_running) {
+        dante_discovery_scan(s_scan_timeout_ms, s_cb, s_cb_user);
+        /* Sleep the interval in small slices so stop() is responsive. */
+        for (uint32_t waited = 0; s_running && waited < s_interval_ms;
+             waited += 200) {
+            vTaskDelay(pdMS_TO_TICKS(200));
+        }
+    }
+    ESP_LOGI(TAG, "discovery task stopped");
+    s_task = NULL;
+    vTaskDelete(NULL);
+}
+
+esp_err_t dante_discovery_start(uint32_t interval_ms,
+                                dante_discovery_cb_t cb, void *user)
+{
+    if (s_task != NULL) {
+        ESP_LOGW(TAG, "discovery already running");
+        return ESP_ERR_INVALID_STATE;
+    }
+    /* Per-query wait: at most the interval, capped so a full sweep of all
+     * service types stays well under one interval. */
+    s_scan_timeout_ms = interval_ms / (2 * DANTE_SVC_COUNT);
+    if (s_scan_timeout_ms < 250) {
+        s_scan_timeout_ms = 250;
+    }
+    s_interval_ms = interval_ms;
+    s_cb = cb;
+    s_cb_user = user;
+    s_running = true;
+
+    /* Core 0 for cache coherency with the audio/net tasks; low priority —
+     * discovery is best-effort and must never contend with the audio path. */
+    BaseType_t ok = xTaskCreatePinnedToCore(discovery_task, "dante_disc",
+                                            4096, NULL, 3, &s_task, 0);
+    if (ok != pdPASS) {
+        s_running = false;
+        s_task = NULL;
+        ESP_LOGE(TAG, "failed to create discovery task");
+        return ESP_ERR_NO_MEM;
+    }
+    return ESP_OK;
+}
+
+void dante_discovery_stop(void)
+{
+    s_running = false;  /* task observes this and self-deletes */
+}

+ 1 - 1
main/CMakeLists.txt

@@ -1,6 +1,6 @@
 idf_component_register(
     SRCS "main.c"
     INCLUDE_DIRS "."
-    PRIV_REQUIRES aes67 esp_eth esp_netif esp_event nvs_flash
+    PRIV_REQUIRES aes67 dante esp_eth esp_netif esp_event nvs_flash
                   esp_driver_gpio esp_driver_i2c esp-dsp esp_mm
 )

+ 8 - 0
main/main.c

@@ -39,6 +39,7 @@
 #include "aes67_sap.h"
 #include "aes67_audio.h"
 #include "aes67_webui.h"
+#include "dante_discovery.h"
 #include "dsps_tone_gen.h"
 #include "driver/i2c.h"
 #include "freertos/stream_buffer.h"
@@ -651,6 +652,13 @@ void app_main(void)
 
     ESP_LOGI(TAG, "node ready");
 
+#if CONFIG_DANTE_DISCOVERY_ON_BOOT
+    /* Read-only background browse for Dante devices on the network. Logs what
+     * it finds (devices, TX channels, multicast bundles); does not advertise
+     * this node. Useful for AES67 bring-up — see components/dante. */
+    dante_discovery_start(CONFIG_DANTE_DISCOVERY_INTERVAL_MS, NULL, NULL);
+#endif
+
     /* Monitor loop: ISR health only */
     extern void aes67_audio_log_isr_stats(void);
     while (1) {