From d4aa9c7be17e640fe15e9425d1b04d51198f7609 Mon Sep 17 00:00:00 2001 From: Ben Nicholson Date: Sat, 26 Sep 2026 08:32:15 +1000 Subject: [PATCH] PTP status: 2-min average offset, details split; uptime as d/h/m/s - Firmware: status.ptp.offset_avg_ns = mean |offset| over the last 2 min (one bucket per second, cleared on a clock step). - Main panel: Time Offset (2 min avg) shows only the Bolero bracket of the average. Details: the current offset (coloured by the same brackets), hops, time/frequency traceable, version and own clock class. - Uptime shown as "1d 2h 3m 4s". - Preset buttons renamed: "Riedel PTP defaults", "AES67 Media PTP defaults". Co-Authored-By: Claude Opus 5.5 --- components/aes67_ptp/ptp_clock.c | 46 ++++++++++++++++++++++++++++++++ docs/aes67-core-base.md | 10 +++---- docs/user-guide.md | 6 ++--- web/index.html | 28 ++++++++++++------- 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/components/aes67_ptp/ptp_clock.c b/components/aes67_ptp/ptp_clock.c index d82c8d4..0cf2e11 100644 --- a/components/aes67_ptp/ptp_clock.c +++ b/components/aes67_ptp/ptp_clock.c @@ -26,6 +26,7 @@ #define MAX_DRIFT_PPB 500000.0 #define WINDOW 64 // samples for interval/delay statistics #define SUMMARY_US (60 * 1000000LL) +#define AVG_S 120 // status.ptp.offset_avg_ns: mean |offset| over 2 min #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 @@ -102,8 +103,48 @@ static struct { uint32_t delay_req, delay_resp; uint8_t own_class; int64_t sum_max_ns, sum_start_us; + // Mean |offset| over the last AVG_S seconds (status.ptp.offset_avg_ns): one bucket per second. + struct { + int64_t sec, sum; + uint32_t n; + } avg[AVG_S]; } s; +static void avg_add(int64_t offset) +{ + int64_t sec = esp_timer_get_time() / 1000000; + typeof(s.avg[0]) *b = &s.avg[sec % AVG_S]; + if (b->sec != sec) { + b->sec = sec; + b->sum = 0; + b->n = 0; + } + b->sum += llabs(offset); + b->n++; +} + +static void avg_clear(void) +{ + memset(s.avg, 0, sizeof(s.avg)); +} + +// False while no offset was measured in the window. +static bool avg_get(double *mean) +{ + int64_t now = esp_timer_get_time() / 1000000, sum = 0; + uint32_t n = 0; + for (int i = 0; i < AVG_S; i++) { + if (s.avg[i].n && now - s.avg[i].sec < AVG_S) { + sum += s.avg[i].sum; + n += s.avg[i].n; + } + } + if (n) { + *mean = (double)sum / n; + } + return n > 0; +} + static void win_add(window_t *w, int64_t v) { w->v[w->next] = v; @@ -309,6 +350,7 @@ static void servo(int64_t offset) s.stepped = true; s.offset_ns = 0; // the pre-step offset is history: not for status or the summary s.sum_max_ns = 0; + avg_clear(); s.sum_start_us = 0; s.prev_t2 = 0; // rate across the step is meaningless s.bad = 0; @@ -323,6 +365,7 @@ static void servo(int64_t offset) s.drift_ppb = fmax(-MAX_DRIFT_PPB, fmin(MAX_DRIFT_PPB, s.drift_ppb + ki * offset)); s.freq_ppb = -ppb; ptp_hw_adj_freq(s.freq_ppb); + avg_add(offset); if (llabs(offset) < LOCK_NS) { s.bad = 0; @@ -838,6 +881,9 @@ void ptp_clock_status(cJSON *st) cJSON_AddBoolToObject(p, "gm_freq_traceable", s.gm.flags & 0x20); if (s.stepped) { cJSON_AddNumberToObject(p, "offset_ns", s.offset_ns); + if (avg_get(&mean)) { + cJSON_AddNumberToObject(p, "offset_avg_ns", round(mean)); + } cJSON_AddNumberToObject(p, "freq_ppb", round(s.freq_ppb)); } if (s.delays.n) { diff --git a/docs/aes67-core-base.md b/docs/aes67-core-base.md index 83e8fd9..0d2c2da 100644 --- a/docs/aes67-core-base.md +++ b/docs/aes67-core-base.md @@ -15,7 +15,7 @@ Reference devices for UI and defaults: Riedel Bolero (PTP status), Riedel Direct - Save bumps `aes67.session_ver` (SDP o= version). Save is blocked while any field is invalid (e.g. multicast out of range). - UI uses IEEE 1588-2019 terms (TimeTransmitter / TimeReceiver), like Riedel. Config/API values stay slave/auto. - AES67 fields carry Riedel Director-style "Default / Range" hints. -- PTP preset buttons: "Riedel SIC defaults" and "AES67 media profile defaults" fill the form (Save to apply). +- PTP preset buttons: "Riedel PTP defaults" and "AES67 Media PTP defaults" fill the form (Save to apply). ## Config schema (core groups) - ptp: {mode: multicast|hybrid, role: slave|auto, domain, priority1, priority2, log_sync, log_announce, announce_timeout, log_delay_req, dscp} (hardware timestamps always; not configurable) @@ -76,15 +76,11 @@ Reference devices for UI and defaults: Riedel Bolero (PTP status), Riedel Direct | PTP State | state | INITIALIZING/LISTENING/UNCALIBRATED/SLAVE/MASTER/PASSIVE/FAULTY/DISABLED, shown as TimeReceiver/TimeTransmitter/… | | Lock State | locked | servo converged (e.g. \|offset\| < 1 µs for N consecutive syncs) | | TimeTransmitter | gm_id | shown as MAC + "(FFFE)" when EUI-64 from MAC | -| Time Offset | offset_ns | Bolero brackets in ns: <100 (green), 100-500 (green), 500-1000 (amber), 1000-10000 (red), >10000 (red); exact value in brackets | +| Time Offset (2 min avg) | offset_avg_ns | mean \|offset\| over the last 2 min (reset on a clock step); Bolero brackets in ns: <100 (green), 100-500 (green), 500-1000 (amber), 1000-10000 (red), >10000 (red). The current offset_ns is in Details | | Frequency Deviation | freq_ppb | servo frequency correction; buckets <100 ppb, 100-500 ppb, 500 ppb-1 ppm, 1-10 ppm, 10-50 ppm, >50 ppm | | Network Delay | path_delay_ns ± path_delay_sd_ns | mean path delay ± std dev over the rolling window | -| Hops | steps_removed | | -| Time/Frequency Traceable | gm_time_traceable, gm_freq_traceable | Announce flagField bits | -| Version | version | 2 | -| Own Clock Class | own_class | 255 / 248 per role | -Details (collapsed): clock_id, gm_class, gm_accuracy, gm_p1, gm_p2, sync_avg_ms/min/max + sync_jitter_us (measured Sync interval over the last `window` = 64 messages; RX as receiver, TX as transmitter), announce_avg_ms, delay_req/delay_resp counters, hw_ts. +Details (collapsed): clock_id, current offset_ns, gm_class, gm_accuracy, gm_p1, gm_p2, hops (steps_removed), time/frequency traceable (gm_time_traceable, gm_freq_traceable: Announce flagField bits), version (2), own clock class (own_class: 255 / 248 per role), sync_avg_ms/min/max + sync_jitter_us (measured Sync interval over the last `window` = 64 messages; RX as receiver, TX as transmitter), announce_avg_ms, delay_req/delay_resp counters, hw_ts. When this device is the GM, offset/frequency/delay show "–". ## AES67 TX diff --git a/docs/user-guide.md b/docs/user-guide.md index 196ff7b..499cdae 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -29,7 +29,7 @@ The page is split into sections, from top to bottom: | Section | What it's for | |---|---| | **Status** | Firmware version, IP / MAC, link speed, active source, Spotify state, buffer, packets sent, underruns, uptime, memory, chip temperature. | -| **PTP status** | Whether the clock is locked, which grandmaster it follows, offset and path delay. *Details* shows more. | +| **PTP status** | Whether the clock is locked, which grandmaster it follows, the time offset (bracket of the 2-minute average) and path delay. *Details* shows the current offset, hops and more. | | **Player** | What's playing now, transport buttons, progress bar (drag to seek), volume. | | **Source**, **PTP**, **AES67 output**, **Network**, **Logging** | Settings. They take effect only when you click **Save**. | | **SDP** | The stream description, to copy or download for receivers. | @@ -129,8 +129,8 @@ The board locks its media clock to the PTP grandmaster on the network (IEEE 1588 | Intervals, timeout, DSCP (46) | | Presets below set them. | **Presets:** -- **Riedel SIC defaults:** TimeReceiver only, priorities 128/126, Riedel's intervals. Use this when a Riedel system provides the clock. -- **AES67 media profile defaults:** the faster intervals of the AES67 profile. +- **Riedel PTP defaults:** TimeReceiver only, priorities 128/126, Riedel's intervals. Use this when a Riedel system provides the clock. +- **AES67 Media PTP defaults:** the faster intervals of the AES67 profile. A preset only fills in the fields; click **Save** to apply them. diff --git a/web/index.html b/web/index.html index c2d8bc5..20e3764 100644 --- a/web/index.html +++ b/web/index.html @@ -98,8 +98,8 @@ e.g. curl -X POST http://p4-aes67.local/api/player/next
PTP - - + + @@ -458,7 +458,7 @@ function offsetBucket(ns) { // Same brackets and colours as Riedel Bolero. const [t, cls] = a < 100 ? ['< 100 ns','ok'] : a < 500 ? ['100-500 ns','ok'] : a < 1e3 ? ['500-1000 ns','warn'] : a < 1e4 ? ['1000-10000 ns','bad'] : ['> 10000 ns','bad']; - return {cls, text: `${t} (${n(ns)} ns)`}; + return {cls, text: t}; } function freqBucket(ppb) { if (ppb == null) return '–'; @@ -468,6 +468,13 @@ function freqBucket(ppb) { return `${t} (${n(ppb)} ppb)`; } const yn = v => v == null ? '–' : v ? 'Yes' : 'No'; +// 93784 -> "1d 2h 3m 4s"; leading zero units left out. +function uptime(s) { + if (s == null) return '–'; + const parts = [[Math.floor(s / 86400), 'd'], [Math.floor(s / 3600) % 24, 'h'], [Math.floor(s / 60) % 60, 'm'], [s % 60, 's']]; + const i = parts.findIndex(([v]) => v > 0); + return parts.slice(i < 0 ? 3 : i).map(([v, u]) => v + u).join(' '); +} function showPtp(p) { if (!p) { table($('ptpmore'), {}); return table($('ptpstat'), {'PTP': 'no data'}); } @@ -476,18 +483,21 @@ function showPtp(p) { 'PTP State': PTP_STATE[p.state] || p.state, 'Lock State': p.locked ? {cls:'ok', text:'Locked'} : {cls:'bad', text:'Unlocked'}, 'TimeTransmitter': clockId(p.gm_id) + (gmSelf ? ' (this device)' : ''), - 'Time Offset': gmSelf ? '–' : offsetBucket(p.offset_ns), + // Bracket from the mean |offset| over the last 2 min; the current value is in Details. + 'Time Offset (2 min avg)': gmSelf ? '–' : offsetBucket(p.offset_avg_ns), 'Frequency Deviation': gmSelf ? '–' : freqBucket(p.freq_ppb), 'Network Delay': gmSelf ? '–' : `${n(p.path_delay_ns)} ± ${n(p.path_delay_sd_ns)} ns`, - 'Hops': p.steps_removed, - 'Time/Frequency Traceable': `${yn(p.gm_time_traceable)} / ${yn(p.gm_freq_traceable)}`, - 'Version': p.version ?? 2, - 'Own Clock Class': p.own_class }); table($('ptpmore'), { 'This clock': clockId(p.clock_id), + // Same colours as the average's brackets, for the current value. + 'Time offset now': gmSelf || p.offset_ns == null ? '–' : {cls: offsetBucket(p.offset_ns).cls, text: `${n(p.offset_ns)} ns`}, 'TT class / accuracy': `${p.gm_class ?? '–'} / 0x${(p.gm_accuracy ?? 0).toString(16).toUpperCase()}`, 'TT priority 1 / 2': `${p.gm_p1 ?? '–'} / ${p.gm_p2 ?? '–'}`, + 'Hops': p.steps_removed ?? '–', + 'Time/Frequency Traceable': `${yn(p.gm_time_traceable)} / ${yn(p.gm_freq_traceable)}`, + 'Version': p.version ?? 2, + 'Own Clock Class': p.own_class ?? '–', // Measured over the last p.window Sync messages (RX as receiver, TX as transmitter). 'Sync interval avg': `${n(p.sync_avg_ms, 3)} ms (min ${n(p.sync_min_ms, 3)} / max ${n(p.sync_max_ms, 3)}, jitter ${n(p.sync_jitter_us, 1)} µs)`, 'Announce interval avg': n(p.announce_avg_ms, 1) + ' ms', @@ -521,7 +531,7 @@ async function poll() { ...(st.power ? {'Power source': st.power} : {}), ...PROJECT.statusRows?.(st), 'AES67 TX': `${st.tx_packets} pkts, ${st.underruns} underruns`, - 'Uptime': st.uptime_s + ' s', 'Heap / PSRAM free': `${st.heap_free} / ${st.psram_free}`, + 'Uptime': uptime(st.uptime_s), 'Heap / PSRAM free': `${st.heap_free} / ${st.psram_free}`, ...temps(st.temps) }); showPtp(st.ptp);