Step 6.3: PTP hybrid mode (unicast Delay_Req/Resp), step 6 done

- As TimeReceiver in ptp.mode hybrid: Delay_Req unicast to the GM (IP
  from its Announce, MAC recorded by the RX hook from its Sync frames),
  unicastFlag set.
- As TimeTransmitter: a Delay_Req with the unicastFlag gets a unicast
  Delay_Resp; multicast requests get multicast replies.
- EMAC timestamps every received frame (en_ts4all): its PTP filter left
  unicast Delay_Req unstamped ("no HW RX timestamp").
- A unicast Delay_Resp's logMessageInterval 0x7F is ignored (use
  ptp.log_delay_req); log_us() clamps to -7..6 (the shift was undefined).
- Verified vs ptp4l --hybrid_e2e 1: board hybrid TimeReceiver 18
  Delay_Req in 20 s, all answered, locked; board TimeTransmitter
  (p1 100): tcpdump shows Sync/Follow_Up/Announce to 224.0.1.129,
  Delay_Req 192.168.192.233 -> .244 [unicast] and Delay_Resp .244 ->
  .233 [unicast] within 0.4 ms; ptp4l s2.
- Docs: TimeTransmitter/hybrid notes; step 6 ticked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 08:21:09 +10:00
parent dcea39b216
commit 5bd1a20e43
5 changed files with 67 additions and 13 deletions
+1 -1
View File
@@ -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.
+44 -12
View File
@@ -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);
}
}
+14
View File
@@ -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);
+3
View File
@@ -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);
+5
View File
@@ -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"