Compare commits
8 Commits
5d1627f8d0
...
d4aa9c7be1
| Author | SHA1 | Date | |
|---|---|---|---|
| d4aa9c7be1 | |||
| 5f27262b27 | |||
| ee03d9f9f1 | |||
| ec65374e8d | |||
| 214d6b2c29 | |||
| 1d613b16c1 | |||
| 05f4ce45c3 | |||
| 62b32c31ff |
@@ -3,3 +3,4 @@ sdkconfig
|
|||||||
sdkconfig.old
|
sdkconfig.old
|
||||||
managed_components/
|
managed_components/
|
||||||
build-*/
|
build-*/
|
||||||
|
tools/__pycache__/
|
||||||
|
|||||||
@@ -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.
|
- 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`.
|
- 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.
|
- 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):
|
- 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`
|
`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`.
|
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).
|
- On-die temperature sensor only (driver/temperature_sensor.h).
|
||||||
|
|
||||||
## Build order / status
|
## 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] 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] 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).
|
- [x] 2a. Finding the device: mDNS (hostname.local + _http._tcp), then LLDP (switch shows name + IP).
|
||||||
|
|||||||
+11
-9
@@ -1,19 +1,21 @@
|
|||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
|
||||||
# Firmware version = git describe (tags, dirty flag). Reported by esp_app_get_description()
|
# Firmware version = "<version.txt>-<git short hash>", e.g. 0.0.1-1d613b1. The first part is set by
|
||||||
# and in status.fw / GET /api/ota. CMake re-runs when git's HEAD log or index changes (commit,
|
# hand in version.txt; the hash says which commit was built (no dirty flag: the build always patches
|
||||||
# checkout, add), so the version follows. A new tag or unstaged edits need `idf.py reconfigure`.
|
# 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
|
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
||||||
${CMAKE_CURRENT_LIST_DIR}/.git/logs/HEAD
|
${CMAKE_CURRENT_LIST_DIR}/version.txt
|
||||||
${CMAKE_CURRENT_LIST_DIR}/.git/index)
|
${CMAKE_CURRENT_LIST_DIR}/.git/logs/HEAD)
|
||||||
|
file(STRINGS ${CMAKE_CURRENT_LIST_DIR}/version.txt PROJECT_VER LIMIT_COUNT 1)
|
||||||
execute_process(
|
execute_process(
|
||||||
COMMAND git describe --tags --always --dirty
|
COMMAND git rev-parse --short HEAD
|
||||||
WORKING_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}
|
WORKING_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}
|
||||||
OUTPUT_VARIABLE PROJECT_VER
|
OUTPUT_VARIABLE GIT_HASH
|
||||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||||
ERROR_QUIET)
|
ERROR_QUIET)
|
||||||
if(NOT PROJECT_VER)
|
if(GIT_HASH)
|
||||||
set(PROJECT_VER "0.0.0-nogit")
|
set(PROJECT_VER "${PROJECT_VER}-${GIT_HASH}")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
# aes67-ESP32-P4
|
# 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)
|
**Using it:** see the [user guide](docs/user-guide.md).
|
||||||
- `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
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
#include "aes67_ptp.h"
|
#include "aes67_ptp.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
#include "aes67_cfg.h"
|
#include "aes67_cfg.h"
|
||||||
#include "aes67_web.h"
|
#include "aes67_web.h"
|
||||||
|
#include "esp_log.h"
|
||||||
#include "ptp_clock.h"
|
#include "ptp_clock.h"
|
||||||
#include "ptp_hw.h"
|
#include "ptp_hw.h"
|
||||||
|
|
||||||
@@ -9,12 +12,12 @@
|
|||||||
static const char PTP_DEFAULTS[] =
|
static const char PTP_DEFAULTS[] =
|
||||||
"{\"mode\":\"multicast\",\"role\":\"auto\",\"domain\":0,\"priority1\":250,\"priority2\":250,"
|
"{\"mode\":\"multicast\",\"role\":\"auto\",\"domain\":0,\"priority1\":250,\"priority2\":250,"
|
||||||
"\"log_sync\":0,\"log_announce\":1,\"announce_timeout\":3,\"log_delay_req\":0,"
|
"\"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 bool ptp_validate(const cJSON *g, char *err, size_t n)
|
||||||
{
|
{
|
||||||
static const char *const modes[] = { "multicast", "hybrid", NULL };
|
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) &&
|
return cfg_check_enum(g, "mode", modes, err, n) &&
|
||||||
cfg_check_enum(g, "role", roles, err, n) &&
|
cfg_check_enum(g, "role", roles, err, n) &&
|
||||||
cfg_check_int(g, "domain", 0, 127, 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)
|
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)
|
esp_err_t aes67_ptp_start(esp_eth_handle_t eth)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
#define MAX_DRIFT_PPB 500000.0
|
#define MAX_DRIFT_PPB 500000.0
|
||||||
#define WINDOW 64 // samples for interval/delay statistics
|
#define WINDOW 64 // samples for interval/delay statistics
|
||||||
#define SUMMARY_US (60 * 1000000LL)
|
#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_PTP_TIMESCALE 0x0008 // flagField octet 1 bit 3
|
||||||
#define FLAG_UNICAST 0x0400 // flagField octet 0 bit 2
|
#define FLAG_UNICAST 0x0400 // flagField octet 0 bit 2
|
||||||
#define UTC_OFFSET 37 // TAI - UTC (s), announced as information only
|
#define UTC_OFFSET 37 // TAI - UTC (s), announced as information only
|
||||||
@@ -102,8 +103,48 @@ static struct {
|
|||||||
uint32_t delay_req, delay_resp;
|
uint32_t delay_req, delay_resp;
|
||||||
uint8_t own_class;
|
uint8_t own_class;
|
||||||
int64_t sum_max_ns, sum_start_us;
|
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;
|
} 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)
|
static void win_add(window_t *w, int64_t v)
|
||||||
{
|
{
|
||||||
w->v[w->next] = v;
|
w->v[w->next] = v;
|
||||||
@@ -309,6 +350,7 @@ static void servo(int64_t offset)
|
|||||||
s.stepped = true;
|
s.stepped = true;
|
||||||
s.offset_ns = 0; // the pre-step offset is history: not for status or the summary
|
s.offset_ns = 0; // the pre-step offset is history: not for status or the summary
|
||||||
s.sum_max_ns = 0;
|
s.sum_max_ns = 0;
|
||||||
|
avg_clear();
|
||||||
s.sum_start_us = 0;
|
s.sum_start_us = 0;
|
||||||
s.prev_t2 = 0; // rate across the step is meaningless
|
s.prev_t2 = 0; // rate across the step is meaningless
|
||||||
s.bad = 0;
|
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.drift_ppb = fmax(-MAX_DRIFT_PPB, fmin(MAX_DRIFT_PPB, s.drift_ppb + ki * offset));
|
||||||
s.freq_ppb = -ppb;
|
s.freq_ppb = -ppb;
|
||||||
ptp_hw_adj_freq(s.freq_ppb);
|
ptp_hw_adj_freq(s.freq_ppb);
|
||||||
|
avg_add(offset);
|
||||||
|
|
||||||
if (llabs(offset) < LOCK_NS) {
|
if (llabs(offset) < LOCK_NS) {
|
||||||
s.bad = 0;
|
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_sync = cJSON_GetObjectItem(c, "log_sync")->valueint;
|
||||||
s.cfg_log_announce = cJSON_GetObjectItem(c, "log_announce")->valueint;
|
s.cfg_log_announce = cJSON_GetObjectItem(c, "log_announce")->valueint;
|
||||||
s.cfg_log_dreq = cJSON_GetObjectItem(c, "log_delay_req")->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
|
// Roles (docs): slave = clockClass 255, never transmits; auto = 248 with the configured priorities
|
||||||
// priorities (auto 250/250 by default, master e.g. p1 100).
|
// (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.slave_only = strcmp(cJSON_GetObjectItem(c, "role")->valuestring, "slave") == 0;
|
||||||
s.hybrid = strcmp(cJSON_GetObjectItem(c, "mode")->valuestring, "hybrid") == 0;
|
s.hybrid = strcmp(cJSON_GetObjectItem(c, "mode")->valuestring, "hybrid") == 0;
|
||||||
s.own_class = s.slave_only ? 255 : 248;
|
s.own_class = s.slave_only ? 255 : 248;
|
||||||
@@ -711,7 +754,7 @@ static void ptp_task(void *arg)
|
|||||||
int tos2 = s.dscp << 2;
|
int tos2 = s.dscp << 2;
|
||||||
setsockopt(s.gen, IPPROTO_IP, IP_TOS, &tos2, sizeof(tos2));
|
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",
|
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);
|
s.own.p1, s.own.p2, s.domain);
|
||||||
// Re-run the decision: a foreign GM worse than our new dataset is dropped; as
|
// Re-run the decision: a foreign GM worse than our new dataset is dropped; as
|
||||||
// TimeTransmitter with role slave we stop.
|
// 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);
|
cJSON_AddBoolToObject(p, "gm_freq_traceable", s.gm.flags & 0x20);
|
||||||
if (s.stepped) {
|
if (s.stepped) {
|
||||||
cJSON_AddNumberToObject(p, "offset_ns", s.offset_ns);
|
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));
|
cJSON_AddNumberToObject(p, "freq_ppb", round(s.freq_ppb));
|
||||||
}
|
}
|
||||||
if (s.delays.n) {
|
if (s.delays.n) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
#include "lwip/inet.h"
|
#include "lwip/inet.h"
|
||||||
#include "lwip/sockets.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_FRAMES 192 // 4 ms at 48 kHz
|
||||||
#define MAX_CH 2
|
#define MAX_CH 2
|
||||||
#define RTP_HDR 12
|
#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 discovery[] = { "manual", "sap", NULL };
|
||||||
static const char *const encodings[] = { "L16", "L24", 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 };
|
static const double ptimes[] = { 0.125, 0.25, 0.333, 1, 4 };
|
||||||
|
|
||||||
bool ok = cfg_check_str(g, "name", 1, 63, err, n) &&
|
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, "dscp", 0, 63, err, n) &&
|
||||||
cfg_check_int(g, "channels", 1, 2, err, n) &&
|
cfg_check_int(g, "channels", 1, 2, err, n) &&
|
||||||
cfg_check_enum(g, "encoding", encodings, 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_num_in(g, "ptime", ptimes, 5, err, n) &&
|
||||||
cfg_check_int(g, "pt", 96, 127, err, n) &&
|
cfg_check_int(g, "pt", 96, 127, err, n) &&
|
||||||
cfg_check_int(g, "ssrc", 0, 4294967295.0, 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;
|
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
|
// 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).
|
// above 9 Hz). The scale gives -18 dBFS RMS (measured over 10 s; peaks about -6 dBFS).
|
||||||
#define PINK_SCALE 0.0714f
|
#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 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;
|
static int table_rate;
|
||||||
int period = rate / TONE_HZ;
|
int period = rate / TONE_HZ;
|
||||||
if (table_rate != rate) {
|
if (table_rate != rate) {
|
||||||
@@ -361,5 +362,13 @@ esp_err_t aes67_tx_init(void)
|
|||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
return err;
|
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);
|
return status_register(tx_status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,6 +295,37 @@ esp_err_t cfg_set_number(const char *group, const char *key, double value, bool
|
|||||||
return err;
|
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 ----- */
|
/* ----- HTTP ----- */
|
||||||
|
|
||||||
static esp_err_t config_get(httpd_req_t *req)
|
static esp_err_t config_get(httpd_req_t *req)
|
||||||
|
|||||||
@@ -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.
|
// 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).
|
// 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);
|
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.
|
// 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);
|
bool cfg_check_num(const cJSON *g, const char *key, double min, double max, char *err, size_t n);
|
||||||
|
|||||||
+8
-12
@@ -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.
|
- 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.
|
- 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).
|
- 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.
|
- 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)
|
## 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}
|
- 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, ptime, pt, ssrc, clk_offset, session_id, session_ver}
|
- 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)
|
- 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)
|
- 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}
|
- 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:
|
- Roles / BMCA dataset:
|
||||||
- slave (TimeReceiver only): clockClass 255, never transmits time.
|
- 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.
|
- 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).
|
- clockAccuracy 0xFE (unknown), offsetScaledLogVariance 0xFFFF, timeSource 0xA0 (internal oscillator).
|
||||||
- clockIdentity = EUI-64 from MAC (xx-xx-xx-FF-FE-xx-xx-xx).
|
- clockIdentity = EUI-64 from MAC (xx-xx-xx-FF-FE-xx-xx-xx).
|
||||||
- Note: Riedel Bolero reports its own clock class as 228.
|
- 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/… |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 "–".
|
When this device is the GM, offset/frequency/delay show "–".
|
||||||
|
|
||||||
## AES67 TX
|
## AES67 TX
|
||||||
@@ -125,7 +121,7 @@ When this device is the GM, offset/frequency/delay show "–".
|
|||||||
|
|
||||||
## Firmware update (OTA)
|
## 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.
|
- 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:
|
- API:
|
||||||
- GET /api/ota -> {version, project, build_date, idf, running, previous, previous_version, pending_verify, can_rollback}
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0.0.1
|
||||||
+25
-17
@@ -98,15 +98,14 @@ e.g. curl -X POST http://p4-aes67.local/api/player/next
|
|||||||
<!-- ===== PROJECT END ===== -->
|
<!-- ===== PROJECT END ===== -->
|
||||||
|
|
||||||
<fieldset><legend>PTP</legend>
|
<fieldset><legend>PTP</legend>
|
||||||
<button type="button" onclick="ptpPreset('riedel')">Riedel SIC defaults</button>
|
<button type="button" onclick="ptpPreset('riedel')">Riedel PTP defaults</button>
|
||||||
<button type="button" onclick="ptpPreset('aes67')">AES67 media profile defaults</button>
|
<button type="button" onclick="ptpPreset('aes67')">AES67 Media PTP defaults</button>
|
||||||
<label><span>Mode</span><select name="ptp.mode">
|
<label><span>Mode</span><select name="ptp.mode">
|
||||||
<option value="multicast">multicast</option><option value="hybrid">hybrid</option></select>
|
<option value="multicast">multicast</option><option value="hybrid">hybrid</option></select>
|
||||||
<small>hybrid: Delay_Req/Resp unicast</small></label>
|
<small>hybrid: Delay_Req/Resp unicast</small></label>
|
||||||
<label><span>Role</span><select name="ptp.role">
|
<label><span>Role</span><select name="ptp.role">
|
||||||
<option value="slave">TimeReceiver only</option>
|
<option value="slave">TimeReceiver only</option>
|
||||||
<option value="auto">Auto (TimeTransmitter if none)</option>
|
<option value="auto">Auto (TimeTransmitter if none)</option></select></label>
|
||||||
<option value="master">Preferred TimeTransmitter</option></select></label>
|
|
||||||
<label><span>Domain</span><input name="ptp.domain" type="number" min="0" max="127"></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 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>
|
<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="-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>
|
<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>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>
|
<small>Transport UDP/IPv4 224.0.1.129 (ports 319/320), delay mechanism E2E.</small>
|
||||||
</fieldset>
|
</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>
|
<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>
|
<label><span>Bit depth</span><select name="aes67.encoding"><option>L24</option><option>L16</option></select>
|
||||||
<small>Default L24</small></label>
|
<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">
|
<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="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>
|
<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 = {
|
const CORE_DEF = {
|
||||||
// Intervals follow Riedel SIC AES67 defaults; role/priorities are the "fallback TimeTransmitter" setup.
|
// 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,
|
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',
|
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},
|
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:''},
|
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'}
|
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).
|
// 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.
|
// PTP presets. riedel = Riedel Director defaults for a SIC AES67 card.
|
||||||
const PTP_PRESETS = {
|
const PTP_PRESETS = {
|
||||||
riedel: {mode:'multicast', role:'slave', domain:0, priority1:128, priority2:126,
|
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;
|
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.`;
|
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 $ = id => document.getElementById(id);
|
||||||
const f = $('f'), sdpEl = $('sdp'), msg = $('msg');
|
const f = $('f'), sdpEl = $('sdp'), msg = $('msg');
|
||||||
@@ -460,7 +458,7 @@ function offsetBucket(ns) {
|
|||||||
// Same brackets and colours as Riedel Bolero.
|
// 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']
|
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'];
|
: a < 1e4 ? ['1000-10000 ns','bad'] : ['> 10000 ns','bad'];
|
||||||
return {cls, text: `${t} (${n(ns)} ns)`};
|
return {cls, text: t};
|
||||||
}
|
}
|
||||||
function freqBucket(ppb) {
|
function freqBucket(ppb) {
|
||||||
if (ppb == null) return '–';
|
if (ppb == null) return '–';
|
||||||
@@ -470,6 +468,13 @@ function freqBucket(ppb) {
|
|||||||
return `${t} (${n(ppb)} ppb)`;
|
return `${t} (${n(ppb)} ppb)`;
|
||||||
}
|
}
|
||||||
const yn = v => v == null ? '–' : v ? 'Yes' : 'No';
|
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) {
|
function showPtp(p) {
|
||||||
if (!p) { table($('ptpmore'), {}); return table($('ptpstat'), {'PTP': 'no data'}); }
|
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,
|
'PTP State': PTP_STATE[p.state] || p.state,
|
||||||
'Lock State': p.locked ? {cls:'ok', text:'Locked'} : {cls:'bad', text:'Unlocked'},
|
'Lock State': p.locked ? {cls:'ok', text:'Locked'} : {cls:'bad', text:'Unlocked'},
|
||||||
'TimeTransmitter': clockId(p.gm_id) + (gmSelf ? ' (this device)' : ''),
|
'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),
|
'Frequency Deviation': gmSelf ? '–' : freqBucket(p.freq_ppb),
|
||||||
'Network Delay': gmSelf ? '–' : `${n(p.path_delay_ns)} ± ${n(p.path_delay_sd_ns)} ns`,
|
'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'), {
|
table($('ptpmore'), {
|
||||||
'This clock': clockId(p.clock_id),
|
'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 class / accuracy': `${p.gm_class ?? '–'} / 0x${(p.gm_accuracy ?? 0).toString(16).toUpperCase()}`,
|
||||||
'TT priority 1 / 2': `${p.gm_p1 ?? '–'} / ${p.gm_p2 ?? '–'}`,
|
'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).
|
// 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)`,
|
'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',
|
'Announce interval avg': n(p.announce_avg_ms, 1) + ' ms',
|
||||||
@@ -523,7 +531,7 @@ async function poll() {
|
|||||||
...(st.power ? {'Power source': st.power} : {}),
|
...(st.power ? {'Power source': st.power} : {}),
|
||||||
...PROJECT.statusRows?.(st),
|
...PROJECT.statusRows?.(st),
|
||||||
'AES67 TX': `${st.tx_packets} pkts, ${st.underruns} underruns`,
|
'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)
|
...temps(st.temps)
|
||||||
});
|
});
|
||||||
showPtp(st.ptp);
|
showPtp(st.ptp);
|
||||||
@@ -544,7 +552,7 @@ async function otaInfo() {
|
|||||||
try {
|
try {
|
||||||
const o = await api('GET', '/api/ota');
|
const o = await api('GET', '/api/ota');
|
||||||
table($('fwinfo'), {
|
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 ?? '?'})` : '–',
|
'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'
|
'State': o.pending_verify ? {cls:'warn', text:'New firmware on trial – confirm or roll back'} : 'Confirmed'
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user