Compare commits

..

8 Commits

Author SHA1 Message Date
bsncubed d4aa9c7be1 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 <noreply@anthropic.com>
2026-09-26 08:32:15 +10:00
bsncubed 5f27262b27 PTP: drop the "Preferred TimeTransmitter" role
It behaved exactly like auto (clock class 248); the only difference was a
priority1 suggestion in the page. Roles are now slave|auto; to prefer this
device, use auto with a low priority1. A stored "master" is converted to
auto at boot (new cfg_set_string, like cfg_set_number).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:58:26 +10:00
bsncubed ee03d9f9f1 PTP: hardware timestamping is not a setting any more
The hw_ts config key was never read (hardware timestamps are always used);
the checkbox suggested it could be turned off. Removed from the config,
the page and the docs; status.ptp.hw_ts still reports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:55:54 +10:00
bsncubed ec65374e8d Web UI: firmware table without build date, project and ESP-IDF
Only version, slots and state are shown; GET /api/ota still reports them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:54:28 +10:00
bsncubed 214d6b2c29 Firmware version: <version.txt>-<git short hash>, starting at 0.0.1
e.g. 0.0.1-1d613b1: the first part is set by hand, the hash says which
commit was built. No dirty flag (the build always patches cspot).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:52:23 +10:00
bsncubed 1d613b16c1 Firmware version from version.txt (0.9.0) instead of git describe
The version is now set by hand in version.txt, so it's readable and only
changes on purpose. git describe gave commit hashes, and always "-dirty"
because the build applies patches to the cspot submodule. Editing the file
is enough; the next build picks it up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:41:53 +10:00
bsncubed 05f4ce45c3 48 kHz only: drop 96 kHz from the AES67 output
All sources (player, test signals) are 48 kHz; at 96 kHz Spotify and HLS
played at the wrong speed. Validation, the page and the docs allow 48000
only, and a 96000 stored before is reset to 48000 at boot (stored values
aren't re-validated, and the SDP would still have said 96 kHz).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:30:39 +10:00
bsncubed 62b32c31ff Docs: end-user guide; README brought up to date
docs/user-guide.md covers setup, the web page, sources and failover, the
Spotify developer app, AES67 output and SDP/SAP, PTP presets, network and
static IP, logging, firmware updates (OTA and USB), the player API,
troubleshooting and known limitations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:26:52 +10:00
13 changed files with 413 additions and 56 deletions
+1
View File
@@ -3,3 +3,4 @@ sdkconfig
sdkconfig.old
managed_components/
build-*/
tools/__pycache__/
+2 -1
View File
@@ -28,6 +28,7 @@ Repo: https://gitea.apointless.space/bsncubed/aes67-ESP32-P4
- Rev < 3 also limits Espressif's prebuilt audio libraries: `esp_audio_codec` must stay < 2.6 and `esp_audio_effects` < 1.4 (newer versions use P4 assembly that needs rev >= 3; the build fails with a message saying so). Check this for any new Espressif binary component.
- Embed `web/index.html` via `EMBED_TXTFILES` in `aes67_web`.
- cspot (Spotify, `external/cspot` git submodule, philippe44 fork, pinned): after cloning run `git submodule update --init external/cspot && git -C external/cspot submodule update --init cspot/bell`. Its nanopb code generator needs, in the IDF Python env: `python -m pip install protobuf grpcio-tools 'setuptools<81'` (after `. export.sh`). Our fixes to cspot/bell live in `components/spotify/patches/{cspot,bell}/*.patch` and are applied automatically at configure time; don't edit the submodule directly, add a patch.
- Firmware version: `<version.txt>-<git short hash>`, e.g. `0.0.1-1d613b1`. Bump `version.txt` for a release; the hash is the commit that was built (commit first, then build, so it matches). No dirty flag.
- Flash over the network (normal way since step 2b; keep USB for recovery):
`curl -f --data-binary @build/aes67_p4.bin -H 'Content-Type: application/octet-stream' http://p4-aes67/api/ota`
The board reboots into the new image on trial; check `GET /api/ota` shows the new version with `pending_verify: false`.
@@ -43,7 +44,7 @@ Repo: https://gitea.apointless.space/bsncubed/aes67-ESP32-P4
- On-die temperature sensor only (driver/temperature_sensor.h).
## Build order / status
- [x] 0. Check chip revision and flash size; create IDF project and empty component stubs (layout in aes67-core-base.md). Set up the OTA partition table (two app slots, no factory) and PROJECT_VER from git now, so the layout never changes later.
- [x] 0. Check chip revision and flash size; create IDF project and empty component stubs (layout in aes67-core-base.md). Set up the OTA partition table (two app slots, no factory) and PROJECT_VER now, so the layout never changes later. (PROJECT_VER = `version.txt` + git short hash.)
- [x] 1. Ethernet: IP101 up, DHCP, IP logged. Ping works.
- [x] 2. Web server + config store (cJSON in NVS) + embedded index.html; /api/config, /api/status (stub values), /api/reboot.
- [x] 2a. Finding the device: mDNS (hostname.local + _http._tcp), then LLDP (switch shows name + IP).
+11 -9
View File
@@ -1,19 +1,21 @@
cmake_minimum_required(VERSION 3.16)
# Firmware version = git describe (tags, dirty flag). Reported by esp_app_get_description()
# and in status.fw / GET /api/ota. CMake re-runs when git's HEAD log or index changes (commit,
# checkout, add), so the version follows. A new tag or unstaged edits need `idf.py reconfigure`.
# Firmware version = "<version.txt>-<git short hash>", e.g. 0.0.1-1d613b1. The first part is set by
# hand in version.txt; the hash says which commit was built (no dirty flag: the build always patches
# the cspot submodule). Reported by esp_app_get_description(), status.fw and GET /api/ota.
# CMake re-runs when version.txt or the checked-out commit changes.
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
${CMAKE_CURRENT_LIST_DIR}/.git/logs/HEAD
${CMAKE_CURRENT_LIST_DIR}/.git/index)
${CMAKE_CURRENT_LIST_DIR}/version.txt
${CMAKE_CURRENT_LIST_DIR}/.git/logs/HEAD)
file(STRINGS ${CMAKE_CURRENT_LIST_DIR}/version.txt PROJECT_VER LIMIT_COUNT 1)
execute_process(
COMMAND git describe --tags --always --dirty
COMMAND git rev-parse --short HEAD
WORKING_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}
OUTPUT_VARIABLE PROJECT_VER
OUTPUT_VARIABLE GIT_HASH
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET)
if(NOT PROJECT_VER)
set(PROJECT_VER "0.0.0-nogit")
if(GIT_HASH)
set(PROJECT_VER "${PROJECT_VER}-${GIT_HASH}")
endif()
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
+20 -6
View File
@@ -1,10 +1,24 @@
# aes67-ESP32-P4
AES67 sender for the Waveshare ESP32-P4-ETH (PoE): Spotify Connect and HLS/m3u8 streams out as an AES67 multicast stream, PTP-locked, with a simple web UI for settings and SDP export.
AES67 sender for the Waveshare ESP32-P4-ETH (PoE): Spotify Connect and HLS/m3u8 internet radio (with failover between them) go out as an AES67 multicast stream, locked to PTP. Everything is set up from a built-in web page, with SAP announcements and SDP export for receivers (tested with Riedel PTP).
- `docs/aes67-core-base.md` — reusable AES67 core design (PTP, RTP, SDP/SAP, VLAN, syslog, web UI contract)
- `docs/hardware-and-design-notes.md` — board details and project-specific design
- `web/index.html` — web UI (served by the device)
- `CLAUDE.md` — working rules and build order
**Using it:** see the [user guide](docs/user-guide.md).
Status: design complete, firmware not started. See the build order in CLAUDE.md.
**Quick start:** power the board over PoE and open http://p4-aes67.local/. Pick a source and save. Then find "P4 AES67" via SAP on the receiver, or import the SDP from the page.
## Building and flashing
- ESP-IDF 5.5 or later, target `esp32p4`: `idf.py set-target esp32p4 && idf.py build`
- cspot submodule: `git submodule update --init external/cspot && git -C external/cspot submodule update --init cspot/bell`
- New board over USB: `tools/flash_board.py`
- Update over the network: web page → Firmware, or `curl -f --data-binary @build/aes67_p4.bin -H 'Content-Type: application/octet-stream' http://p4-aes67.local/api/ota`
Toolchain details and caveats (chip revision, Python packages for cspot) are in `CLAUDE.md`.
## Documentation
- `docs/user-guide.md`: setup, web page, sources, Spotify, AES67, PTP, network, updates, player API, troubleshooting
- `docs/aes67-core-base.md`: reusable AES67 core design (PTP, RTP, SDP/SAP, syslog, web UI and API contract)
- `docs/hardware-and-design-notes.md`: board details and project-specific design
- `web/index.html`: the web page (served by the device)
- `CLAUDE.md`: working rules, build order and status
+19 -3
View File
@@ -1,7 +1,10 @@
#include "aes67_ptp.h"
#include <string.h>
#include "aes67_cfg.h"
#include "aes67_web.h"
#include "esp_log.h"
#include "ptp_clock.h"
#include "ptp_hw.h"
@@ -9,12 +12,12 @@
static const char PTP_DEFAULTS[] =
"{\"mode\":\"multicast\",\"role\":\"auto\",\"domain\":0,\"priority1\":250,\"priority2\":250,"
"\"log_sync\":0,\"log_announce\":1,\"announce_timeout\":3,\"log_delay_req\":0,"
"\"dscp\":46,\"hw_ts\":true}";
"\"dscp\":46}"; // hardware timestamps always (status.ptp.hw_ts)
static bool ptp_validate(const cJSON *g, char *err, size_t n)
{
static const char *const modes[] = { "multicast", "hybrid", NULL };
static const char *const roles[] = { "slave", "auto", "master", NULL };
static const char *const roles[] = { "slave", "auto", NULL };
return cfg_check_enum(g, "mode", modes, err, n) &&
cfg_check_enum(g, "role", roles, err, n) &&
cfg_check_int(g, "domain", 0, 127, err, n) &&
@@ -34,7 +37,20 @@ static void ptp_apply(const cJSON *g)
esp_err_t aes67_ptp_init(void)
{
return cfg_register("ptp", PTP_DEFAULTS, ptp_validate, ptp_apply);
esp_err_t err = cfg_register("ptp", PTP_DEFAULTS, ptp_validate, ptp_apply);
if (err != ESP_OK) {
return err;
}
// Role "master" was dropped (it behaved like auto; a low priority1 does the same). Stored values
// aren't re-validated, so convert it here.
cJSON *c = cfg_get("ptp");
bool master = !strcmp(cJSON_GetObjectItemCaseSensitive(c, "role")->valuestring, "master");
cJSON_Delete(c);
if (master) {
ESP_LOGW("ptp", "stored role \"master\" is no longer offered: now \"auto\" (same behaviour)");
cfg_set_string("ptp", "role", "auto", false);
}
return ESP_OK;
}
esp_err_t aes67_ptp_start(esp_eth_handle_t eth)
+49 -3
View File
@@ -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;
@@ -622,8 +665,8 @@ static void load_config(void)
s.cfg_log_sync = cJSON_GetObjectItem(c, "log_sync")->valueint;
s.cfg_log_announce = cJSON_GetObjectItem(c, "log_announce")->valueint;
s.cfg_log_dreq = cJSON_GetObjectItem(c, "log_delay_req")->valueint;
// Roles (docs): slave = clockClass 255, never transmits; auto/master = 248 with the configured
// priorities (auto 250/250 by default, master e.g. p1 100).
// Roles (docs): slave = clockClass 255, never transmits; auto = 248 with the configured priorities
// (250/250 by default: loses to any real GM; a lower priority1 prefers this device).
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;
@@ -711,7 +754,7 @@ static void ptp_task(void *arg)
int tos2 = s.dscp << 2;
setsockopt(s.gen, IPPROTO_IP, IP_TOS, &tos2, sizeof(tos2));
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.slave_only ? "slave" : "auto", 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.
@@ -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) {
+14 -5
View File
@@ -16,6 +16,7 @@
#include "lwip/inet.h"
#include "lwip/sockets.h"
#define AES67_RATE 48000 // the only sample rate (all sources are 48 kHz)
#define MAX_FRAMES 192 // 4 ms at 48 kHz
#define MAX_CH 2
#define RTP_HDR 12
@@ -56,7 +57,7 @@ static bool aes67_validate(const cJSON *g, char *err, size_t n)
{
static const char *const discovery[] = { "manual", "sap", NULL };
static const char *const encodings[] = { "L16", "L24", NULL };
static const double rates[] = { 48000, 96000 };
static const double rates[] = { AES67_RATE }; // the sources (player, test signals) are 48 kHz
static const double ptimes[] = { 0.125, 0.25, 0.333, 1, 4 };
bool ok = cfg_check_str(g, "name", 1, 63, err, n) &&
@@ -67,7 +68,7 @@ static bool aes67_validate(const cJSON *g, char *err, size_t n)
cfg_check_int(g, "dscp", 0, 63, err, n) &&
cfg_check_int(g, "channels", 1, 2, err, n) &&
cfg_check_enum(g, "encoding", encodings, err, n) &&
cfg_check_num_in(g, "rate", rates, 2, err, n) &&
cfg_check_num_in(g, "rate", rates, 1, err, n) &&
cfg_check_num_in(g, "ptime", ptimes, 5, err, n) &&
cfg_check_int(g, "pt", 96, 127, err, n) &&
cfg_check_int(g, "ssrc", 0, 4294967295.0, err, n) &&
@@ -168,8 +169,6 @@ static int64_t samples_to_ns(int64_t s, int rate)
return (s / rate) * 1000000000LL + ((s % rate) * 1000000000LL + rate - 1) / rate;
}
// 1 kHz tone, phase from the PTP sample index, so every sender's tone lines up.
// One period is rate / 1000 samples (48 at 48 kHz, 96 at 96 kHz): precomputed table.
// Pink noise: xorshift32 white noise through Paul Kellet's refined pink filter (within 0.05 dB
// above 9 Hz). The scale gives -18 dBFS RMS (measured over 10 s; peaks about -6 dBFS).
#define PINK_SCALE 0.0714f
@@ -209,9 +208,11 @@ static void to_mono(int32_t *buf, int frames, bool sum)
}
}
// 1 kHz tone, phase from the PTP sample index, so every sender's tone lines up.
// One period is rate / 1000 samples (48 at 48 kHz): precomputed table.
static void tone(int32_t *buf, int frames, int channels, int rate, int64_t s0)
{
static int32_t table[96000 / TONE_HZ];
static int32_t table[AES67_RATE / TONE_HZ];
static int table_rate;
int period = rate / TONE_HZ;
if (table_rate != rate) {
@@ -361,5 +362,13 @@ esp_err_t aes67_tx_init(void)
if (err != ESP_OK) {
return err;
}
// Stored before 96 kHz was dropped: stored values aren't re-validated, so fix it here (SDP too).
cJSON *a = cfg_get("aes67");
double rate = cJSON_GetObjectItemCaseSensitive(a, "rate")->valuedouble;
cJSON_Delete(a);
if (rate != AES67_RATE) {
ESP_LOGW(TAG, "stored sample rate %.0f not supported: using %d", rate, AES67_RATE);
cfg_set_number("aes67", "rate", AES67_RATE, false);
}
return status_register(tx_status);
}
+31
View File
@@ -295,6 +295,37 @@ esp_err_t cfg_set_number(const char *group, const char *key, double value, bool
return err;
}
esp_err_t cfg_set_string(const char *group, const char *key, const char *value, bool apply)
{
if (!s_lock) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
cfg_group_t *g = find(group);
cJSON *cur = g ? cJSON_GetObjectItemCaseSensitive(g->values, key) : NULL;
esp_err_t err = ESP_OK;
if (!cur || !cJSON_IsString(cur)) {
err = ESP_ERR_NOT_FOUND;
} else {
cJSON_ReplaceItemInObjectCaseSensitive(g->values, key, cJSON_CreateString(value));
nvs_handle_t h;
if (s_nvs_ok && (err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h)) == ESP_OK) {
err = store(h, g);
if (err == ESP_OK) {
err = nvs_commit(h);
}
nvs_close(h);
}
}
cJSON *copy = err == ESP_OK && apply && g->apply ? cJSON_Duplicate(g->values, true) : NULL;
xSemaphoreGive(s_lock);
if (copy) {
g->apply(copy);
cJSON_Delete(copy);
}
return err;
}
/* ----- HTTP ----- */
static esp_err_t config_get(httpd_req_t *req)
+2
View File
@@ -26,6 +26,8 @@ cJSON *cfg_get(const char *group);
// Firmware-side change of one number (e.g. aes67.session_ver): stored like a POST, no validation.
// apply = false skips the group's apply callback (e.g. to avoid restarting the stream).
esp_err_t cfg_set_number(const char *group, const char *key, double value, bool apply);
// Same for a string value.
esp_err_t cfg_set_string(const char *group, const char *key, const char *value, bool apply);
// Validation helpers for validate callbacks. Each returns false and fills err on failure.
bool cfg_check_num(const cJSON *g, const char *key, double min, double max, char *err, size_t n);
+8 -12
View File
@@ -13,13 +13,13 @@ Reference devices for UI and defaults: Riedel Bolero (PTP status), Riedel Direct
- Status table values can be text or {cls: ok|warn|bad, text} for colour.
- Device config is merged over defaults on load, so older firmware missing keys still renders.
- 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 master/slave/auto.
- 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|master, domain, priority1, priority2, log_sync, log_announce, announce_timeout, log_delay_req, dscp, hw_ts}
- aes67: {name, enabled, discovery: manual|sap, mcast, port, ttl, dscp, channels, mono_sum, encoding: L16|L24, rate, ptime, pt, ssrc, clk_offset, session_id, session_ver}
- 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)
- aes67: {name, enabled, discovery: manual|sap, mcast, port, ttl, dscp, channels, mono_sum, encoding: L16|L24, rate: 48000 only (all sources are 48 kHz), ptime, pt, ssrc, clk_offset, session_id, session_ver}
- net: {hostname, vlan, web_on: both|inet|aes67, dhcp, ip, mask, gw, dns} (AES67 / untagged interface)
- inet: {vlan_id, pcp, dhcp, ip, mask, gw, dns} (internet / tagged, only if net.vlan)
- log: {syslog, host, port, level: error|warn|info|debug, facility, format: rfc5424|rfc3164}
@@ -52,7 +52,7 @@ Reference devices for UI and defaults: Riedel Bolero (PTP status), Riedel Direct
- Roles / BMCA dataset:
- slave (TimeReceiver only): clockClass 255, never transmits time.
- auto (default): clockClass 248, priority1 250, priority2 250. Loses to any real GM (Riedel default 128); takes over only if none.
- master (preferred TimeTransmitter): clockClass 248, priority1 100 (UI suggestion, editable).
- To prefer this device as TimeTransmitter, use auto with a lower priority1 (e.g. 100). (A separate "master" role was dropped: it behaved like auto; a stored "master" is converted to auto at boot.)
- clockAccuracy 0xFE (unknown), offsetScaledLogVariance 0xFFFF, timeSource 0xA0 (internal oscillator).
- clockIdentity = EUI-64 from MAC (xx-xx-xx-FF-FE-xx-xx-xx).
- Note: Riedel Bolero reports its own clock class as 228.
@@ -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
@@ -125,7 +121,7 @@ When this device is the GM, offset/frequency/delay show "–".
## Firmware update (OTA)
- Partition table: nvs, otadata, phy_init, ota_0, ota_1 (no factory app). Size the slots from flash_id; e.g. on 16 MB, 2 × 6 MB leaves room for growth (cspot + TLS + codecs). Keep NVS outside the app slots so config survives updates.
- Version: `PROJECT_VER` from `git describe --tags --dirty`, read at runtime from `esp_app_get_description()`; also reported as `status.fw`.
- Version: `PROJECT_VER` = `<version.txt>-<git short hash>` (e.g. `0.0.1-1d613b1`; the first part set by hand), read at runtime from `esp_app_get_description()`; also reported as `status.fw`.
- API:
- GET /api/ota -> {version, project, build_date, idf, running, previous, previous_version, pending_verify, can_rollback}
- POST /api/ota: raw .bin as `application/octet-stream`, streamed with esp_ota_begin/write/end into the inactive slot (never buffered whole in RAM). 200 then reboot; 4xx with a message if rejected.
+230
View File
@@ -0,0 +1,230 @@
# P4 AES67 Streamer: user guide
The P4 AES67 Streamer turns Spotify Connect or an internet radio stream (HLS / m3u8) into an **AES67 multicast stream**, locked to your PTP clock. Any AES67 receiver on the network can play it: a Riedel Artist 4-wire port, a Dante device in AES67 mode, a DAW, and so on.
It runs on a **Waveshare ESP32-P4-ETH** board, powered over PoE, and is set up entirely from a web page.
Contents: [Getting started](#getting-started) · [The web page](#the-web-page) · [Sources](#sources) · [Spotify](#spotify) · [AES67 output](#aes67-output) · [PTP](#ptp) · [Network](#network) · [Logging](#logging) · [Firmware updates](#firmware-updates) · [Player API](#player-api) · [Troubleshooting](#troubleshooting) · [Limitations](#limitations)
---
## Getting started
1. **Connect** the board's Ethernet port to a PoE switch port, or to a normal port and power it over USB-C. Use the same network (or VLAN) as your AES67 devices and your PTP grandmaster.
2. **Wait about 20 seconds.** The board gets an address by DHCP and locks to PTP.
3. **Open the web page** at **http://p4-aes67.local/**. Alternatively, look up the board's IP in your router's DHCP list (the name is `p4-aes67`) and open `http://<IP>/`.
4. **Pick a source** under *Source* and click **Save** (see [Sources](#sources)).
5. **On the receiver**, either find the stream via SAP, or copy the SDP from the page's *SDP* section (see [AES67 output](#aes67-output)).
Out of the box the board streams **2-channel, 24-bit, 48 kHz audio with 1 ms packets to 239.69.1.10:5004** and announces it by SAP under the name "P4 AES67".
> **More than one board?** Each board needs its own hostname, stream name and multicast address, or they clash. Change them under *Network* and *AES67 output*, or let `tools/flash_board.py` ask for them when you flash a new board.
---
## The web page
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, 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. |
| **Firmware** | Update the firmware (see [Firmware updates](#firmware-updates)). |
**Saving:** *Save*, *Revert* (throw away unsaved edits) and *Reboot* stay at the bottom of the window while you scroll through the settings. Messages such as "Saved." or error texts appear next to them. Invalid values are rejected with the reason, and nothing is saved.
**Light / dark:** the page is dark by default. The **◐** button at the top right switches to light, and your browser remembers the choice.
---
## Sources
Choose the source under **Source → Source**:
| Source | What it plays |
|---|---|
| **Spotify Connect** | The board appears as a speaker in the Spotify app (default). Needs your own Spotify app credentials; see [Spotify](#spotify). |
| **HLS / M3U8 URL** | An internet radio stream, e.g. `https://…/playlist.m3u8`. Enter it under *Stream URL*. |
| **Spotify, fail over to HLS** | Spotify when someone plays to it, otherwise the stream (see below). |
| **Test tone** | 1 kHz sine at −18 dBFS, locked to PTP. For line-up and receiver tests. |
| **Pink noise** | Pink noise at −18 dBFS RMS (peaks around −6 dBFS), the same signal on both channels. For level and EQ checks. |
| **Off** | Silence. The stream keeps running. |
### Failover (Spotify, fail over to HLS)
- **Spotify playing:** the board plays Spotify, switching over within about a second of pressing play.
- **No Spotify connection:** after *Failover delay* seconds (default 5), the board switches to the HLS stream.
- **Paused:** with **Also fail over when paused** ticked, a paused Spotify counts as "not playing" and the stream takes over after the delay. Unticked (default), a pause gives silence and the board stays on Spotify.
- **Fades:** switches fade out and in over 30 ms, so there are no clicks.
- **Resuming:** Spotify is held while the stream plays. When you press play again, the song continues where you paused.
### Volume and gain
- **Volume (Player section):** the everyday volume. With Spotify it follows the app's volume slider, and the other way round.
- **Output gain (dB):** a fixed trim on top of the volume, −60 to +12 dB. Above 0 dB, loud passages can clip at full scale.
- **Neither affects** the test tone or pink noise. Those always come out at their calibrated level.
---
## Spotify
Since 2025, Spotify requires every Spotify Connect device to use a **developer app registered to your own account**. You need **Spotify Premium**. Setup takes about five minutes:
1. Go to **https://developer.spotify.com/dashboard** and log in.
2. **Create app**:
- Name and description: anything, e.g. "P4 AES67".
- Redirect URI: `http://127.0.0.1:8888/callback`. Spotify requires one, but this device never uses it.
- API: tick **Web API**, then save.
3. Open the app's **Settings**. Copy the **Client ID**, and click *View client secret* to copy the **Client secret**.
4. On the board's page under **Source**, paste them into **Spotify client ID** and **Spotify client secret**, then **Save**.
The secret is write-only: after saving, the field shows empty. Leave it empty to keep the stored secret, or type a new one to replace it.
**Connecting:** make sure your phone or computer is on the **same network** as the board. In the Spotify app, open the device list and pick **P4 AES67** (or the name you set under *Spotify device name*). Play, pause, skip, seek and volume all work from the app. The web page shows the track and position and can control playback too.
- **Spotify bitrate:** 96, 160 or 320 kbit/s (default 320).
- **Spotify device name:** the name shown in the app. Changing it disconnects a running session.
- **Status → Spotify** shows the state: e.g. *waiting for Spotify app*, *connected*, *no client credentials*, *login failed*.
---
## AES67 output
| Setting | Default | Notes |
|---|---|---|
| Stream name (s=) | P4 AES67 | The name receivers show. |
| Enabled | on | Off stops sending. |
| Discovery | SAP | **SAP**: announced every 30 s on 239.255.255.255:9875, so receivers that support SAP find it by themselves. **Manual**: no announcements; use the SDP. |
| RTP multicast IP / port | 239.69.1.10 : 5004 | Must be unique per stream on your network. |
| TTL | 32 | |
| DSCP (media) | 34 (AF41) | QoS marking for the audio packets. |
| Channels | 2 | 1 or 2. |
| Mono sum | off | Sends (left + right) / 2 as a 1-channel stream. Forces *Channels* to 1; it can't clip. |
| Bit depth | L24 | L24 or L16. |
| Sample rate | 48000 | Fixed; all sources are 48 kHz. |
| Packet time | 1 ms | 0.125, 0.25, 0.333, 1 or 4 ms. 1 ms is the AES67 default and works with nearly all receivers. |
| Payload type / SSRC / time stamp offset | 96 / 0 / 0 | Only change these if a receiver needs it. |
**Getting the SDP to a receiver:**
- **SAP:** receivers that listen for SAP (Riedel, many AES67 devices) list the stream by its name.
- **By hand:** in the *SDP* section, **Copy** or **Download .sdp** and import it on the receiver. The board also serves it at `http://p4-aes67.local/stream.sdp`.
- **When the SDP changes:** after a setting that changes the stream, the SDP gets a new version number. Re-import it on receivers that were set up by hand.
---
## PTP
The board locks its media clock to the PTP grandmaster on the network (IEEE 1588-2008, UDP/IPv4, E2E, always with hardware timestamps). *PTP status* shows the lock state and the grandmaster.
| Setting | Default | Notes |
|---|---|---|
| Mode | multicast | **hybrid** sends the delay requests unicast (fewer multicast packets). |
| Role | Auto | **TimeReceiver only**: never becomes grandmaster. **Auto**: becomes grandmaster only if there is none (fallback). To make this board the preferred grandmaster, keep *Auto* and set a low *Priority 1* (e.g. 100). |
| Domain | 0 | Must match your grandmaster. |
| Priority 1 / 2 | 250 / 250 | Lower wins the grandmaster election. |
| Intervals, timeout, DSCP (46) | | Presets below set them. |
**Presets:**
- **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.
Audio starts only when PTP is locked, usually about 20 s after power-up. When the board is itself the grandmaster (no other clock on the network), its clock starts at 1 January 1970. That's fine for AES67, but it isn't real time.
---
## Network
- **Hostname:** the name in DHCP, mDNS (`<hostname>.local`) and LLDP (the switch's neighbour list). Changing it applies at once.
- **DHCP (default):** with DHCP on, the greyed-out IP, netmask, gateway and DNS fields show the addresses DHCP handed out.
- **Static IP:** untick **DHCP**. The current addresses stay in the fields as a starting point; edit them and **Save**. The board **reboots** to apply them, and the page switches to the new address after 20 s. The page checks that the netmask is valid, that the IP isn't the subnet's network or broadcast address, and that the gateway is in the subnet.
- **A wrong static address makes the board unreachable** over the network. Double-check before saving; see [Troubleshooting](#troubleshooting) for recovery.
- With a static IP, your router's DNS name for the board can go stale. `http://<hostname>.local/` keeps working.
---
## Logging
The board can send its log to a syslog server (UDP):
- Tick **Send to syslog** and enter the server (hostname or IP) and port (default 514).
- **Minimum level:** Error, Warning, Info or Debug.
- **Facility and format:** RFC 5424 or RFC 3164 (BSD).
- **Send test message** checks the path.
The log shows PTP lock/unlock, source switches, stream errors and firmware updates, which makes it useful for troubleshooting.
---
## Firmware updates
**Over the network (normal way):** in the **Firmware** section, choose the new `aes67_p4.bin` and click **Upload & install**. The board installs it to its second firmware slot and reboots into it.
- The new firmware must **prove itself** within 60 s: network up and web page answering. Only then is it kept. If it fails or crashes, the board automatically goes back to the previous firmware.
- While the new firmware is on trial, the page offers **Keep this firmware** and **Roll back to previous**.
- During the upload the Spotify session ends and the stream pauses. Reconnect from the app afterwards.
- From a computer: `curl -f --data-binary @aes67_p4.bin -H 'Content-Type: application/octet-stream' http://p4-aes67.local/api/ota`
**Over USB (new board, or recovery):** `tools/flash_board.py` (needs ESP-IDF or `pip install esptool`, plus a build). It:
- checks the board (chip revision, 32 MB flash),
- optionally erases it (this clears all settings),
- flashes the firmware and finds the board's IP,
- asks for a hostname, stream name and multicast address.
**Making an update file:**
1. Set the version in **`version.txt`** (first line, e.g. `0.0.2`) and commit your changes.
2. Build: `idf.py build`. The firmware version becomes `<version>-<commit>`, e.g. `0.0.2-1d613b1`, as shown under *Firmware* and *Status*.
3. The update file is **`build/aes67_p4.bin`**. Upload it in the *Firmware* section, or with the `curl` command above.
The .bin is for ESP32-P4 chips of **revision v1.x** (the current Waveshare boards). A board with a v3 chip needs a build for that revision; the flash script refuses it with a hint.
---
## Player API
Everything the Player section does is also available over HTTP, e.g. for a control panel or Companion. Every command returns the current player state as JSON.
```
GET /api/player state, source, track, position, volume
POST /api/player/play | pause | toggle | stop | next | prev
POST /api/player/seek {"ms": 90000}
POST /api/player/volume {"value": 0-100} or {"delta": -5}
POST /api/player/source {"source": "spotify" | "hls" | "off" | "config"}
POST /api/player/url {"url": "https://…/playlist.m3u8"}
```
Example: `curl -X POST http://p4-aes67.local/api/player/next`
- **With Spotify:** the app follows every command. *stop* equals *pause*. *prev* restarts the song if it has played more than 3 s, like the Spotify apps.
- **With HLS (live radio):** *pause* stops downloading, and *play* rejoins the live stream. *next*, *prev* and *seek* return an error (409).
- **`source` and `url`:** temporary overrides; they're not saved, and a reboot or `"source": "config"` returns to the saved setting. `url` plays a stream right away.
- **Security:** there is no password. Keep the device on a trusted network.
The full device API (status, config, SDP, firmware) is described in `docs/aes67-core-base.md`.
---
## Troubleshooting
| Problem | What to try |
|---|---|
| **Can't find the page** | Try `http://p4-aes67.local/`, or the IP from your router's DHCP list. If the board has a static IP you no longer know, use `tools/flash_board.py` over USB with erase (resets all settings). |
| **Board doesn't appear in the Spotify app** | Phone or computer on the same network? *Status → Spotify* should say *waiting for Spotify app*. *no client credentials*: enter ID and secret. *login failed*: check them, and check that the account is Premium. |
| **"Can't connect" in Spotify on the first try** | Try again; a second attempt usually works. |
| **Spotify controls do nothing** after a long idle session | Quit and reopen the Spotify app, then connect again. |
| **No audio at the receiver** | *PTP status* locked? *Status → AES67 TX* counting packets? The receiver on the same grandmaster and domain? After changing stream settings, re-import the SDP. |
| **Stutter or dropouts** | *Status → underruns* rising? With HLS, check the internet connection. During a firmware upload short stutters are normal. |
| **HLS stream doesn't start** | Check the URL in a browser. *Status → Source* shows *buffering* while it loads, usually for a few seconds. |
---
## Limitations
These are known limitations in the current firmware:
- **Autoplay stream on boot:** the checkbox has no effect yet. HLS always starts on boot.
- **VLAN split** (separate VLANs for AES67 and internet) isn't available yet.
- **No password** on the web page or API.
- **HLS clock drift:** the radio station's clock and PTP drift slightly apart. Over many hours of continuous playback, the stream can skip ahead to catch up with the live edge.
+1
View File
@@ -0,0 +1 @@
0.0.1
+25 -17
View File
@@ -98,15 +98,14 @@ e.g. curl -X POST http://p4-aes67.local/api/player/next
<!-- ===== PROJECT END ===== -->
<fieldset><legend>PTP</legend>
<button type="button" onclick="ptpPreset('riedel')">Riedel SIC defaults</button>
<button type="button" onclick="ptpPreset('aes67')">AES67 media profile defaults</button>
<button type="button" onclick="ptpPreset('riedel')">Riedel PTP defaults</button>
<button type="button" onclick="ptpPreset('aes67')">AES67 Media PTP defaults</button>
<label><span>Mode</span><select name="ptp.mode">
<option value="multicast">multicast</option><option value="hybrid">hybrid</option></select>
<small>hybrid: Delay_Req/Resp unicast</small></label>
<label><span>Role</span><select name="ptp.role">
<option value="slave">TimeReceiver only</option>
<option value="auto">Auto (TimeTransmitter if none)</option>
<option value="master">Preferred TimeTransmitter</option></select></label>
<option value="auto">Auto (TimeTransmitter if none)</option></select></label>
<label><span>Domain</span><input name="ptp.domain" type="number" min="0" max="127"></label>
<label><span>Priority 1</span><input name="ptp.priority1" type="number" min="0" max="255"></label>
<label><span>Priority 2</span><input name="ptp.priority2" type="number" min="0" max="255"></label>
@@ -122,7 +121,6 @@ e.g. curl -X POST http://p4-aes67.local/api/player/next
<option value="-4">-4 (16/s)</option><option value="-3">-3 (8/s)</option><option value="-2">-2 (4/s)</option>
<option value="-1">-1 (2/s)</option><option value="0">0 (1/s)</option><option value="1">1 (every 2 s)</option></select></label>
<label><span>DSCP (PTP)</span><input name="ptp.dscp" type="number" min="0" max="63"></label>
<label><span>HW timestamping</span><input name="ptp.hw_ts" type="checkbox"></label>
<small>Transport UDP/IPv4 224.0.1.129 (ports 319/320), delay mechanism E2E.</small>
</fieldset>
@@ -145,7 +143,7 @@ e.g. curl -X POST http://p4-aes67.local/api/player/next
<small>(Ch 1 + Ch 2) / 2 → 1-channel stream</small></label>
<label><span>Bit depth</span><select name="aes67.encoding"><option>L24</option><option>L16</option></select>
<small>Default L24</small></label>
<label><span>Sample rate</span><select name="aes67.rate"><option>48000</option><option>96000</option></select></label>
<label><span>Sample rate</span><select name="aes67.rate"><option>48000</option></select></label>
<label><span>Packet time</span><select name="aes67.ptime">
<option value="0.125">0.125 ms</option><option value="0.25">0.250 ms</option><option value="0.333">0.333 ms</option>
<option value="1">1.000 ms</option><option value="4">4.000 ms</option></select>
@@ -284,7 +282,7 @@ function showPlayer(p) {
const CORE_DEF = {
// Intervals follow Riedel SIC AES67 defaults; role/priorities are the "fallback TimeTransmitter" setup.
ptp:{mode:'multicast',role:'auto',domain:0,priority1:250,priority2:250,log_sync:0,log_announce:1,
announce_timeout:3,log_delay_req:0,dscp:46,hw_ts:true},
announce_timeout:3,log_delay_req:0,dscp:46},
aes67:{name:'AES67',enabled:true,mcast:'239.69.1.10',port:5004,ttl:32,dscp:34,pt:96,encoding:'L24',
rate:'48000',channels:'2',ptime:'1',ssrc:0,clk_offset:0,mono_sum:false,discovery:'sap',session_id:1,session_ver:1},
net:{hostname:'aes67',vlan:false,web_on:'both',dhcp:true,ip:'',mask:'',gw:'',dns:''},
@@ -292,7 +290,7 @@ const CORE_DEF = {
log:{syslog:false,host:'',port:514,level:'info',facility:'16',format:'rfc5424'}
};
// Priority1 suggested when the role is changed in the UI (user can still edit it).
const ROLE_P1 = {auto:250, master:100};
const ROLE_P1 = {auto:250};
// PTP presets. riedel = Riedel Director defaults for a SIC AES67 card.
const PTP_PRESETS = {
riedel: {mode:'multicast', role:'slave', domain:0, priority1:128, priority2:126,
@@ -303,7 +301,7 @@ function ptpPreset(name) {
for (const [k, v] of Object.entries(PTP_PRESETS[name])) f['ptp.' + k].value = v;
toggle(); sdp(); msg.textContent = `Loaded ${name} PTP defaults – Save to apply.`;
}
const CLOCK_CLASS = {slave:255, auto:248, master:248};
const CLOCK_CLASS = {slave:255, auto:248};
const $ = id => document.getElementById(id);
const f = $('f'), sdpEl = $('sdp'), msg = $('msg');
@@ -460,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 '–';
@@ -470,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'}); }
@@ -478,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',
@@ -523,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);
@@ -544,7 +552,7 @@ async function otaInfo() {
try {
const o = await api('GET', '/api/ota');
table($('fwinfo'), {
'Version': o.version, 'Project': o.project, 'Built': o.build_date, 'ESP-IDF': o.idf,
'Version': o.version,
'Running slot': o.running, 'Previous slot': o.previous ? `${o.previous} (${o.previous_version ?? '?'})` : '–',
'State': o.pending_verify ? {cls:'warn', text:'New firmware on trial – confirm or roll back'} : 'Confirmed'
});