diff --git a/CLAUDE.md b/CLAUDE.md index 756ef11..44e65f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ Repo: https://gitea.apointless.space/bsncubed/aes67-ESP32-P4 - [ ] 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. 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. +- [x] 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. diff --git a/components/aes67_ptp/ptp_clock.c b/components/aes67_ptp/ptp_clock.c index da18800..708a892 100644 --- a/components/aes67_ptp/ptp_clock.c +++ b/components/aes67_ptp/ptp_clock.c @@ -27,6 +27,7 @@ #define WINDOW 64 // samples for interval/delay statistics #define SUMMARY_US (60 * 1000000LL) #define FLAG_PTP_TIMESCALE 0x0008 // flagField octet 1 bit 3 +#define FLAG_UNICAST 0x0400 // flagField octet 0 bit 2 #define UTC_OFFSET 37 // TAI - UTC (s), announced as information only #define TIME_SOURCE_OSC 0xA0 // internal oscillator @@ -60,6 +61,10 @@ static struct { master_t gm; // foreign TimeTransmitter we follow (valid = TimeReceiver) master_t own; // our own dataset for BMCA bool slave_only; // role "slave": never TimeTransmitter + bool hybrid; // mode "hybrid": Delay_Req/Resp unicast as TimeReceiver + uint8_t gm_mac[6]; // Ethernet source of the GM's Sync (for unicast Delay_Req) + bool have_gm_mac; + uint32_t dreq_logged_ip; // last Delay_Req destination logged bool master; // we are the TimeTransmitter int64_t listen_since_us; // start of LISTENING (for the announce receipt timeout) volatile bool reconfig; @@ -138,6 +143,13 @@ static void become_master(void); /* ----- helpers ----- */ +// 2^log seconds in us; log clamped to the sane PTP range (-7..6). +static int64_t log_us(int8_t log) +{ + log = log < -7 ? -7 : log > 6 ? 6 : log; + return log >= 0 ? 1000000LL << log : 1000000LL >> -log; +} + static uint16_t rd16(const uint8_t *p) { return (p[0] << 8) | p[1]; } static int64_t rd_ts(const uint8_t *p) // 48-bit seconds + 32-bit ns @@ -252,6 +264,7 @@ static void on_announce(const uint8_t *b, int len, uint32_t src_ip) win_clear(&s.delays); }); s.sync_pending = s.dreq_pending = false; + s.have_gm_mac = false; LOCKED({ s.delay_ns = s.prev_t2 = 0; s.stepped = false; @@ -361,10 +374,11 @@ static void on_sync(const uint8_t *b, int len) } eth_mac_time_t t2; uint16_t seq = rd16(b + 30); - if (!ptp_hw_rx_ts(PTP_MSG_SYNC, seq, b + 20, &t2)) { + if (!ptp_hw_rx_ts_mac(PTP_MSG_SYNC, seq, b + 20, &t2, s.gm_mac)) { ESP_LOGW(TAG, "Sync %u: no HW RX timestamp", seq); return; } + s.have_gm_mac = true; s.sync_seq = seq; s.log_sync = (int8_t)b[33]; s.t2 = mac_ns(&t2); @@ -401,9 +415,22 @@ static void send_delay_req(void) m[32] = 1; // controlField: Delay_Req m[33] = 0x7f; uint32_t dst; + const uint8_t *dst_mac = PTP_MCAST_MAC; inet_aton(PTP_MCAST, (struct in_addr *)&dst); + if (s.hybrid && s.gm.ip && s.have_gm_mac) { + // Hybrid: unicast to the GM (address from its Announce, MAC from its Sync frames). + dst = s.gm.ip; + dst_mac = s.gm_mac; + m[6] |= FLAG_UNICAST >> 8; + } + if (dst != s.dreq_logged_ip) { + s.dreq_logged_ip = dst; + ESP_LOGI(TAG, "Delay_Req %s to " IPSTR " (%02x:%02x:%02x:%02x:%02x:%02x)", + dst_mac == PTP_MCAST_MAC ? "multicast" : "unicast", IP2STR((esp_ip4_addr_t *)&dst), + dst_mac[0], dst_mac[1], dst_mac[2], dst_mac[3], dst_mac[4], dst_mac[5]); + } eth_mac_time_t t3; - esp_err_t err = ptp_hw_send_event(PTP_MCAST_MAC, dst, s.dscp, m, sizeof(m), &t3); + esp_err_t err = ptp_hw_send_event(dst_mac, dst, s.dscp, m, sizeof(m), &t3); if (err != ESP_OK) { ESP_LOGW(TAG, "Delay_Req %u: %s", s.dreq_seq, esp_err_to_name(err)); s.dreq_pending = false; @@ -423,7 +450,10 @@ static void on_delay_resp(const uint8_t *b, int len) s.dreq_pending = false; LOCKED(s.delay_resp++); int64_t t4 = rd_ts(b + 34) - rd_corr_ns(b + 8); - s.log_dreq = (int8_t)b[33]; + // Delay_Req interval from the GM; a unicast Delay_Resp carries 0x7F ("not specified"): + // then use our configured interval. + int8_t l = (int8_t)b[33]; + s.log_dreq = l >= -7 && l <= 6 ? l : s.cfg_log_dreq; if (!s.prev_t2) { return; } @@ -437,10 +467,6 @@ static void on_delay_resp(const uint8_t *b, int len) /* ----- TimeTransmitter ----- */ -static int64_t log_us(int8_t log) -{ - return log >= 0 ? 1000000LL << log : 1000000LL >> -log; -} static void wr_ts(uint8_t *p, int64_t ns) { @@ -547,7 +573,12 @@ static void on_delay_req(const uint8_t *b, int len, uint32_t src_ip) memcpy(m + 8, b + 8, 8); // correctionField of the Delay_Req wr_ts(m + 34, mac_ns(&t4)); memcpy(m + 44, b + 20, 10); // requestingPortIdentity - send_general(m, sizeof(m), mcast_ip()); + // Unicast Delay_Req (hybrid TimeReceiver) -> unicast Delay_Resp; multicast -> multicast. + bool unicast = rd16(b + 6) & FLAG_UNICAST; + if (unicast) { + m[6] |= FLAG_UNICAST >> 8; + } + send_general(m, sizeof(m), unicast ? src_ip : mcast_ip()); LOCKED({ s.delay_req++; s.delay_resp++; @@ -594,6 +625,7 @@ static void load_config(void) // Roles (docs): slave = clockClass 255, never transmits; auto/master = 248 with the configured // priorities (auto 250/250 by default, master e.g. p1 100). s.slave_only = strcmp(cJSON_GetObjectItem(c, "role")->valuestring, "slave") == 0; + s.hybrid = strcmp(cJSON_GetObjectItem(c, "mode")->valuestring, "hybrid") == 0; s.own_class = s.slave_only ? 255 : 248; s.own = (master_t){ .valid = true, .p1 = cJSON_GetObjectItem(c, "priority1")->valueint, .cls = s.own_class, @@ -678,7 +710,8 @@ static void ptp_task(void *arg) load_config(); int tos2 = s.dscp << 2; setsockopt(s.gen, IPPROTO_IP, IP_TOS, &tos2, sizeof(tos2)); - ESP_LOGI(TAG, "config applied: role %s, p1 %u p2 %u, domain %u", s.slave_only ? "slave" : "auto/master", + ESP_LOGI(TAG, "config applied: role %s, mode %s, p1 %u p2 %u, domain %u", + s.slave_only ? "slave" : "auto/master", s.hybrid ? "hybrid" : "multicast", s.own.p1, s.own.p2, s.domain); // Re-run the decision: a foreign GM worse than our new dataset is dropped; as // TimeTransmitter with role slave we stop. @@ -716,8 +749,7 @@ static void ptp_task(void *arg) } } else if (s.gm.valid) { // announceReceiptTimeout x the GM's announce interval - int64_t window = (int64_t)s.timeout * (s.gm.log_announce >= 0 ? 1000000LL << s.gm.log_announce - : 1000000LL >> -s.gm.log_announce); + int64_t window = (int64_t)s.timeout * log_us(s.gm.log_announce); if (now - s.gm.last_us > window) { ESP_LOGW(TAG, "TimeTransmitter lost (no Announce for %lld ms), listening", window / 1000); LOCKED({ @@ -730,7 +762,7 @@ static void ptp_task(void *arg) } else if (now >= s.next_dreq_us && s.prev_t2) { send_delay_req(); // Delay_Req interval from the GM's Delay_Resp; randomised 0.5..1.5x - int64_t iv = s.log_dreq >= 0 ? 1000000LL << s.log_dreq : 1000000LL >> -s.log_dreq; + int64_t iv = log_us(s.log_dreq); s.next_dreq_us = now + iv / 2 + (esp_random() % (uint32_t)iv); } } diff --git a/components/aes67_ptp/ptp_hw.c b/components/aes67_ptp/ptp_hw.c index dd730b7..ca991dd 100644 --- a/components/aes67_ptp/ptp_hw.c +++ b/components/aes67_ptp/ptp_hw.c @@ -18,6 +18,7 @@ typedef struct { uint8_t type; uint16_t seq; uint8_t port_id[10]; + uint8_t mac[6]; // Ethernet source eth_mac_time_t ts; } rx_rec_t; @@ -48,6 +49,7 @@ static esp_err_t rx_hook(esp_eth_handle_t eth, uint8_t *buf, uint32_t len, void r->type = ptp[0] & 0x0f; r->seq = (ptp[30] << 8) | ptp[31]; memcpy(r->port_id, ptp + 20, 10); + memcpy(r->mac, buf + 6, 6); r->ts = *ts; portEXIT_CRITICAL(&s_lock); } @@ -56,6 +58,12 @@ static esp_err_t rx_hook(esp_eth_handle_t eth, uint8_t *buf, uint32_t len, void } bool ptp_hw_rx_ts(uint8_t msg_type, uint16_t seq, const uint8_t *src_port_id, eth_mac_time_t *ts) +{ + return ptp_hw_rx_ts_mac(msg_type, seq, src_port_id, ts, NULL); +} + +bool ptp_hw_rx_ts_mac(uint8_t msg_type, uint16_t seq, const uint8_t *src_port_id, eth_mac_time_t *ts, + uint8_t src_mac[6]) { bool found = false; portENTER_CRITICAL(&s_lock); @@ -63,6 +71,9 @@ bool ptp_hw_rx_ts(uint8_t msg_type, uint16_t seq, const uint8_t *src_port_id, et rx_rec_t *r = &s_rx[i]; if (r->used && r->type == msg_type && r->seq == seq && memcmp(r->port_id, src_port_id, 10) == 0) { *ts = r->ts; + if (src_mac) { + memcpy(src_mac, r->mac, 6); + } r->used = false; found = true; break; @@ -181,6 +192,9 @@ esp_err_t ptp_hw_init(esp_eth_handle_t eth, esp_netif_t *netif) } // IDF enables timestamping for PTP over Ethernet (L2) only; AES67 uses UDP/IPv4. emac_ll_ts_ptp_ip4_enable(&EMAC_PTP, true); + // The PTP packet filter only stamps multicast PTP; unicast Delay_Req (hybrid mode) would get + // no timestamp. Stamp every frame instead; rx_hook picks the PTP event messages. + emac_ll_ts_all_enable(&EMAC_PTP, true); // The netif glue registered its own input path; take it over and forward to the netif. err = esp_eth_update_input_path_info(eth, rx_hook, netif); diff --git a/components/aes67_ptp/ptp_hw.h b/components/aes67_ptp/ptp_hw.h index 5a72610..e880466 100644 --- a/components/aes67_ptp/ptp_hw.h +++ b/components/aes67_ptp/ptp_hw.h @@ -19,6 +19,9 @@ esp_err_t ptp_hw_init(esp_eth_handle_t eth, esp_netif_t *netif); // Hardware RX timestamp of an event message (UDP port 319), looked up by type, // sequence ID and sourcePortIdentity (10 bytes). Each record is returned once. bool ptp_hw_rx_ts(uint8_t msg_type, uint16_t seq, const uint8_t *src_port_id, eth_mac_time_t *ts); +// Same, also returning the sender's Ethernet MAC (for unicast replies as raw frames). src_mac may be NULL. +bool ptp_hw_rx_ts_mac(uint8_t msg_type, uint16_t seq, const uint8_t *src_port_id, eth_mac_time_t *ts, + uint8_t src_mac[6]); esp_err_t ptp_hw_get_time(eth_mac_time_t *t); // Frequency correction relative to the nominal rate, in ppb (positive = faster). esp_err_t ptp_hw_adj_freq(double ppb); diff --git a/docs/aes67-core-base.md b/docs/aes67-core-base.md index 1e19629..6357450 100644 --- a/docs/aes67-core-base.md +++ b/docs/aes67-core-base.md @@ -60,6 +60,11 @@ Reference devices for UI and defaults: Riedel Bolero (PTP status), Riedel Direct - RX timestamps: a hook on the driver's info input path (`esp_eth_update_input_path_info`) records port-319 event timestamps and forwards every frame to lwIP. TX: Delay_Req is a raw Eth/IPv4/UDP frame sent with `esp_eth_transmit_ctrl_vargs` for its HW timestamp. Needs `CONFIG_ETH_TRANSMIT_MUTEX`. - Servo: step on the first Sync from a new GM (frequency seeded from the measured rate), then linuxptp-style PI (kp 0.7 / ki 0.3 at 1 Sync/s, scaled by the interval); re-step above 1 ms. Locked: 8 Syncs below 1 µs; unlocked after 3 above. GM loss keeps the frequency (holdover). - Path delay is corrected for offset drift between t2 and t3 until the clock is syntonised; delay statistics start at lock. + - TimeTransmitter/BMCA (step 6): own dataset from the role; a foreign GM is followed only if better; LISTENING -> MASTER after the announce receipt timeout. Two-step Sync (raw frame, HW TX time in Follow_Up), Announce with the PTP timescale flag, Delay_Resp with HW RX time. Hybrid: as TimeReceiver, Delay_Req unicast to the GM (IP from Announce, MAC from its Sync) with the unicastFlag; as TimeTransmitter, a unicast Delay_Req gets a unicast Delay_Resp. + - The EMAC's PTP filter only timestamps multicast PTP; unicast Delay_Req got none. The EMAC now timestamps every received frame (`emac_ll_ts_all_enable`); the RX hook picks the port-319 PTP event messages. + - A unicast Delay_Resp carries logMessageInterval 0x7F; then ptp.log_delay_req is used. + - As GM without an earlier lock the clock starts at 0 (1970): no RTC/NTP. Media timing is unaffected; NTP seeding could follow with the internet interface (phase 2). + - Sync send times jitter by ~10 ms (FreeRTOS 100 Hz tick); accuracy is unaffected (two-step). - Measured vs ptp4l (Intel i210 GM, non-PTP switch): lock in ~35 s cold, ~22 s after GM loss; offset within a few hundred ns; board crystal -39.8 ppm. Link asymmetry (1G GM / 100M board through a store-and-forward switch) adds a constant offset error of a few µs that no receiver can see. ### PTP status (`status.ptp`) — main panel modelled on Riedel Bolero "PTP Status"