Files
aes67-ESP32-P4/web/index.html
T
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

602 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>AES67 device</title>
<!--
AES67 device web UI – reusable base.
CORE : status, PTP, AES67 output, network/VLAN, logging, SDP. Keep identical across projects.
PROJECT: blocks between "PROJECT START/END" markers (HTML and the PROJECT object in the script).
Form fields bind to config JSON by name="group.key". Add a field = add an input; no JS needed.
-->
<style>
/* Dark by default; the toggle (top right) switches to light and is remembered in this browser. */
:root{color-scheme:dark}
:root.light{color-scheme:light}
#theme{position:fixed;top:8px;right:8px;font-size:18px;line-height:1;padding:4px 8px;cursor:pointer}
[hidden]{display:none!important} /* else label{display:block} overrides it */
body{font:14px/1.4 system-ui,sans-serif;max-width:720px;margin:0 auto;padding:0 16px 32px}
fieldset{margin:12px 0}
label{display:block;margin:4px 0}
label span{display:inline-block;width:11em}
input:not([type=checkbox]),select{width:14em;max-width:100%}
textarea{width:100%;height:14em;font:12px monospace}
table td:first-child{padding-right:1em;opacity:.7}
pre{overflow-x:auto}
#msg{margin-left:1em}
/* Save bar: stays at the bottom of the window while the settings form is in view */
.bar{position:sticky;bottom:0;background:Canvas;padding:8px 0;border-top:1px solid color-mix(in srgb,CanvasText 20%,transparent)}
.ok{color:#2a2}.warn{color:#d80}.bad{color:#e33}
</style>
<script>
// Before the page is drawn, so a light choice doesn't flash dark first (and vice versa).
try { if (localStorage.getItem('theme') === 'light') document.documentElement.classList.add('light'); } catch {}
function toggleTheme() {
const light = document.documentElement.classList.toggle('light');
try { localStorage.setItem('theme', light ? 'light' : 'dark'); } catch {}
}
</script>
</head>
<body>
<button id="theme" type="button" onclick="toggleTheme()" title="Light / dark">&#x25D0;</button>
<h2 id="title">AES67 device</h2>
<fieldset><legend>Status</legend><table id="status"></table></fieldset>
<fieldset><legend>PTP status</legend><table id="ptpstat"></table>
<details><summary>Details</summary><table id="ptpmore"></table></details></fieldset>
<!-- ===== PROJECT START: live controls (outside the config form) ===== -->
<fieldset><legend>Player</legend>
<p id="np">–</p>
<button onclick="ctl('prev')">&#x23EE;</button>
<button onclick="ctl('toggle')">&#x23EF;</button>
<button onclick="ctl('next')">&#x23ED;</button>
<button onclick="ctl('stop')">&#x23F9;</button>
<label><span>Position</span><input id="pos" type="range" min="0" max="0" value="0" step="1000" disabled
oninput="seekDrag=true; showPos()" onchange="seekDrag=false; ctl('seek',{ms:+this.value})">
<small id="postime"></small></label>
<label><span>Volume</span><input id="vol" type="range" min="0" max="100"
onchange="ctl('volume',{value:+this.value})"></label>
<details><summary>API</summary><pre>
GET /api/player state, 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://…m3u8"} play a URL now
e.g. curl -X POST http://p4-aes67.local/api/player/next
</pre></details>
</fieldset>
<!-- ===== PROJECT END ===== -->
<form id="f" onsubmit="save(event)">
<!-- ===== PROJECT START: config sections ===== -->
<fieldset><legend>Source</legend>
<label><span>Source</span><select name="source.mode">
<option value="spotify">Spotify Connect</option>
<option value="hls">HLS / M3U8 URL</option>
<option value="auto">Spotify, fail over to HLS</option>
<option value="tone">Test tone (1 kHz, -18 dBFS)</option>
<option value="pink">Pink noise (-18 dBFS RMS)</option>
<option value="off">Off (silence)</option></select></label>
<label><span>Failover delay (s)</span><input name="source.failover_delay_s" type="number" min="0" max="3600"></label>
<label><span>Also fail over when paused</span><input name="source.failover_on_pause" type="checkbox"></label>
<label><span>Spotify device name</span><input name="source.spotify_name"></label>
<label><span>Spotify bitrate</span><select name="source.spotify_bitrate">
<option>96</option><option>160</option><option>320</option></select></label>
<label><span>Spotify client ID</span><input name="source.spotify_client_id" autocomplete="off"></label>
<label><span>Spotify client secret</span><input name="source.spotify_client_secret" type="password"
autocomplete="new-password" placeholder="stored; leave empty to keep"></label>
<small>Your own app from <a href="https://developer.spotify.com/dashboard" target="_blank">developer.spotify.com</a>
(Premium account). The secret is never shown again once saved.</small>
<label><span>Stream URL</span><input name="source.hls_url" type="url" placeholder="https://…/playlist.m3u8"></label>
<label><span>Autoplay stream on boot</span><input name="source.autoplay" type="checkbox"></label>
<label><span>Output gain (dB)</span><input name="source.gain_db" type="number" min="-60" max="12" step="0.5"></label>
</fieldset>
<!-- ===== 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>
<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>
<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>
<label><span>Clock class</span><output id="cclass"></output></label>
<label><span>Sync interval</span><select name="ptp.log_sync">
<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>Announce interval</span><select name="ptp.log_announce">
<option value="0">0 (1 s)</option><option value="1">1 (2 s)</option><option value="2">2 (4 s)</option>
<option value="3">3 (8 s)</option><option value="4">4 (16 s)</option></select></label>
<label><span>Announce timeout</span><input name="ptp.announce_timeout" type="number" min="2" max="10"></label>
<label><span>Delay req interval</span><select name="ptp.log_delay_req">
<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>
<fieldset><legend>AES67 output</legend>
<label><span>Stream name (s=)</span><input name="aes67.name" maxlength="63"></label>
<label><span>Enabled</span><input name="aes67.enabled" type="checkbox"></label>
<label><span>Discovery</span><select name="aes67.discovery">
<option value="manual">Manual</option><option value="sap">SAP</option></select>
<small>Manual: SDP export only</small></label>
<label><span>RTP multicast IP</span><input name="aes67.mcast">
<small>224.0.2.0 – 239.255.255.255</small></label>
<label><span>RTP multicast port</span><input name="aes67.port" type="number" min="1024" max="65535">
<small>Default 5004, 1024–65535</small></label>
<label><span>TTL</span><input name="aes67.ttl" type="number" min="1" max="255"></label>
<label><span>DSCP (media)</span><input name="aes67.dscp" type="number" min="0" max="63">
<small>Default 34 (AF41)</small></label>
<label><span>Channels</span><select name="aes67.channels"><option>1</option><option>2</option></select>
<small>Channels per stream</small></label>
<label><span>Mono sum</span><input name="aes67.mono_sum" type="checkbox">
<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></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>
<small>Audio content per packet</small></label>
<label><span>Payload type</span><input name="aes67.pt" type="number" min="96" max="127">
<small>Default 96, 96–127</small></label>
<label><span>SSRC</span><input name="aes67.ssrc" type="number" min="0" max="4294967295">
<small>Default 0, 32 bit</small></label>
<label><span>Time stamp offset</span><input name="aes67.clk_offset" type="number" min="0" max="4294967295">
<small>Default 0, 32 bit</small></label>
</fieldset>
<fieldset><legend>Network</legend>
<label><span>Hostname</span><input name="net.hostname"></label>
<!-- VLAN split: not implemented yet (phase 2); hidden until the firmware applies it. -->
<label hidden><span>VLAN split</span><input name="net.vlan" type="checkbox">
<small>off: one untagged interface for everything</small></label>
<p id="netlbl" hidden><b>AES67 interface (untagged)</b></p>
<label><span>DHCP</span><input name="net.dhcp" type="checkbox"></label>
<label><span>IP</span><input name="net.ip"></label>
<label><span>Netmask</span><input name="net.mask"></label>
<label><span>Gateway</span><input name="net.gw"></label>
<label><span>DNS</span><input name="net.dns"></label>
<div id="inet" hidden>
<p><b>Internet interface (tagged)</b></p>
<label><span>VLAN ID</span><input name="inet.vlan_id" type="number" min="1" max="4094"></label>
<label><span>802.1p priority</span><input name="inet.pcp" type="number" min="0" max="7"></label>
<label><span>DHCP</span><input name="inet.dhcp" type="checkbox"></label>
<label><span>IP</span><input name="inet.ip"></label>
<label><span>Netmask</span><input name="inet.mask"></label>
<label><span>Gateway</span><input name="inet.gw"></label>
<label><span>DNS</span><input name="inet.dns"></label>
<label><span>Web UI / API on</span><select name="net.web_on">
<option value="both">Both</option><option value="inet">Internet only</option>
<option value="aes67">AES67 only</option></select></label>
</div>
</fieldset>
<fieldset><legend>Logging</legend>
<label><span>Send to syslog</span><input name="log.syslog" type="checkbox"></label>
<label><span>Server</span><input name="log.host" placeholder="hostname or IP"></label>
<label><span>UDP port</span><input name="log.port" type="number" min="1" max="65535"></label>
<label><span>Minimum level</span><select name="log.level">
<option value="error">Error</option><option value="warn">Warning</option>
<option value="info">Info</option><option value="debug">Debug</option></select></label>
<label><span>Facility</span><select name="log.facility">
<option value="1">user</option><option value="3">daemon</option>
<option value="16">local0</option><option value="17">local1</option><option value="18">local2</option>
<option value="19">local3</option><option value="20">local4</option><option value="21">local5</option>
<option value="22">local6</option><option value="23">local7</option></select></label>
<label><span>Format</span><select name="log.format">
<option value="rfc5424">RFC 5424</option><option value="rfc3164">RFC 3164 (BSD)</option></select></label>
<button type="button" onclick="api('POST','/api/log/test').then(()=>msg.textContent='Test message sent.',()=>{})">Send test message</button>
</fieldset>
<div class="bar">
<button>Save</button>
<button type="button" onclick="load()">Revert</button>
<button type="button" onclick="confirm('Reboot device?')&&api('POST','/api/reboot')">Reboot</button>
<span id="msg"></span>
</div>
</form>
<fieldset><legend>SDP</legend>
<textarea id="sdp" readonly></textarea><br>
<button onclick="dl()">Download .sdp</button>
<button onclick="navigator.clipboard.writeText(sdpEl.value)">Copy</button>
<small>Preview is built from the form; the device serves the live one at <a href="/stream.sdp">/stream.sdp</a>.</small>
</fieldset>
<fieldset><legend>Firmware</legend>
<table id="fwinfo"></table>
<label><span>Upload .bin</span><input id="fwfile" type="file" accept=".bin"></label>
<button onclick="otaUpload()">Upload &amp; install</button>
<p><progress id="fwprog" max="100" value="0" hidden></progress> <span id="fwmsg"></span></p>
<button id="fwconfirm" onclick="otaAction('confirm')" hidden>Keep this firmware</button>
<button id="fwrollback" onclick="otaAction('rollback')" hidden>Roll back to previous</button>
<small>Installs to the inactive slot and reboots. New firmware must pass its self-test or it rolls back automatically on the next boot.</small>
</fieldset>
<script>
/* ===== PROJECT START ===== */
const PROJECT = {
title: 'P4 AES67 Streamer',
def: {
source:{mode:'spotify',spotify_name:'P4 AES67',spotify_bitrate:'320',hls_url:'',autoplay:false,gain_db:0,
failover_delay_s:5,failover_on_pause:false,spotify_client_id:'',spotify_client_secret:''},
aes67:{name:'P4 AES67'},
net:{hostname:'p4-aes67'}
},
toggle(f) {
const m = f['source.mode'].value;
const sp = m === 'spotify' || m === 'auto', hl = m === 'hls' || m === 'auto';
f['source.spotify_name'].disabled = f['source.spotify_bitrate'].disabled = !sp;
f['source.spotify_client_id'].disabled = f['source.spotify_client_secret'].disabled = !sp;
f['source.hls_url'].disabled = !hl;
f['source.autoplay'].disabled = m !== 'hls';
f['source.failover_delay_s'].disabled = f['source.failover_on_pause'].disabled = m !== 'auto';
},
statusRows: st => ({
'Source': `${st.active_source ?? '–'} (${st.source_state ?? '–'})`,
'Spotify': st.spotify_state ?? '–',
'Buffer': (st.buffer_ms ?? '–') + ' ms'
}),
poll() { api('GET', '/api/player').then(showPlayer, () => {}); }
};
async function ctl(cmd, body) {
try { showPlayer(await api('POST', '/api/player/' + cmd, body)); } catch {}
}
// Progress bar: follows the polled position, moves on locally while playing, seeks on release.
let np = null, npAt = 0, seekDrag = false;
const mmss = ms => `${Math.floor(ms/60000)}:${String(Math.floor(ms/1000)%60).padStart(2,'0')}`;
function showPos() {
const pos = document.getElementById('pos'), dur = np?.duration_ms;
pos.disabled = !(dur && np.can?.seek);
if (!seekDrag) {
const at = !dur ? 0 : np.state === 'playing' ? Math.min(np.position_ms + Date.now() - npAt, dur) : np.position_ms;
pos.max = dur || 0;
pos.value = at;
}
document.getElementById('postime').textContent = dur ? `${mmss(+pos.value)} / ${mmss(dur)}` : '';
}
setInterval(showPos, 500);
function showPlayer(p) {
if (!p || !p.state) return;
np = p; npAt = Date.now(); showPos();
document.getElementById('np').textContent =
`[${p.source}] ${p.state}: ${[p.artist, p.title].filter(Boolean).join(' – ') || '–'}`;
const v = document.getElementById('vol');
if (document.activeElement !== v) v.value = p.volume;
}
/* ===== PROJECT END ===== */
/* ===== CORE ===== */
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},
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:''},
inet:{vlan_id:10,pcp:0,dhcp:true,ip:'',mask:'',gw:'',dns:''},
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};
// 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,
log_announce:1, log_sync:0, announce_timeout:3, log_delay_req:0},
aes67: {mode:'multicast', domain:0, log_announce:1, log_sync:-3, announce_timeout:3, log_delay_req:-3}
};
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 $ = id => document.getElementById(id);
const f = $('f'), sdpEl = $('sdp'), msg = $('msg');
let cfg = {}, st = {};
const merge = (a, b) => {
const o = structuredClone(a);
for (const [k, v] of Object.entries(b || {}))
o[k] = v && typeof v === 'object' && !Array.isArray(v) ? merge(o[k] || {}, v) : v;
return o;
};
const DEF = merge(CORE_DEF, PROJECT.def);
const esc = v => String(v ?? '–').replace(/&/g,'&amp;').replace(/</g,'&lt;');
// Row values are escaped text, or {cls, text} for a coloured value (ok|warn|bad).
const table = (el, rows) => el.innerHTML = Object.entries(rows)
.map(([k, v]) => `<tr><td>${k}</td><td>${v && typeof v === 'object'
? `<span class="${v.cls}">${esc(v.text)}</span>` : esc(v)}</td></tr>`).join('');
const n = (v, d = 0) => v == null ? '–' : Number(v).toFixed(d);
async function api(method, url, body) {
try {
const r = await fetch(url, {method, headers:{'Content-Type':'application/json'},
body: body ? JSON.stringify(body) : undefined});
if (!r.ok) throw new Error(r.status + ' ' + (await r.text()));
const t = await r.text(); return t ? JSON.parse(t) : {};
} catch (e) { msg.textContent = method + ' ' + url + ': ' + e.message; throw e; }
}
function fill() {
for (const el of f.elements) {
if (!el.name) continue;
const [g, k] = el.name.split('.'), v = cfg[g]?.[k];
if (el.type === 'checkbox') el.checked = !!v; else el.value = v ?? '';
el.disabled = false; // toggle() re-applies; avoids stale state after Revert
}
toggle(); sdp();
}
function read() {
const out = structuredClone(cfg);
for (const el of f.elements) {
if (!el.name) continue;
const [g, k] = el.name.split('.');
out[g] ??= {};
// Numbers go out as JSON numbers, including numeric <select> values (ptime, log intervals, rate…).
const numeric = el.type === 'number' || (el.tagName === 'SELECT' && /^-?\d+(\.\d+)?$/.test(el.value));
out[g][k] = el.type === 'checkbox' ? el.checked : numeric ? Number(el.value) : el.value;
}
return out;
}
// With DHCP on, the greyed-out static fields show what DHCP gave us. Unticking DHCP leaves the values
// there (a starting point for static addressing) until they are edited.
function netLive() {
if (!f['net.dhcp'].checked || !st.ip) return;
for (const k of ['ip','mask','gw','dns']) f['net.' + k].value = st[k] ?? '';
}
function toggle() {
PROJECT.toggle?.(f);
const role = f['ptp.role'].value;
f['ptp.priority1'].disabled = f['ptp.priority2'].disabled = role === 'slave';
$('cclass').textContent = CLOCK_CLASS[role] + (role === 'slave' ? ' (slave-only)' : ' (default, free-running)');
const dhcp = f['net.dhcp'].checked, vlan = f['net.vlan'].checked, idhcp = f['inet.dhcp'].checked;
$('inet').hidden = $('netlbl').hidden = !vlan;
for (const k of ['ip','mask','gw','dns']) {
f['net.' + k].disabled = dhcp;
f['inet.' + k].disabled = idhcp;
}
// VLAN split: default route lives on the internet VLAN, so the AES67 side gets no gateway/DNS.
if (vlan) f['net.gw'].disabled = f['net.dns'].disabled = true;
netLive();
// RTP multicast must be 224.0.2.0 – 239.255.255.255 (same range Riedel Director enforces).
const m = f['aes67.mcast'], o = m.value.split('.').map(Number);
const ok = o.length === 4 && o.every(x => Number.isInteger(x) && x >= 0 && x <= 255) &&
o[0] >= 224 && o[0] <= 239 && !(o[0] === 224 && o[1] === 0 && o[2] < 2);
m.setCustomValidity(ok ? '' : 'Multicast address must be 224.0.2.0 – 239.255.255.255');
// Mono sum forces a 1-channel stream; unticking restores the channel count it replaced.
const ch = f['aes67.channels'], mono = f['aes67.mono_sum'].checked;
if (mono && !ch.disabled) { ch.dataset.prev = ch.value; ch.value = '1'; }
if (!mono && ch.disabled) ch.value = ch.dataset.prev > 1 ? ch.dataset.prev : '2';
ch.disabled = mono;
const sl = f['log.syslog'].checked;
for (const k of ['host','port','level','facility','format']) f['log.' + k].disabled = !sl;
}
// AES67 SDP (RFC 4566 + RFC 7273). Firmware stamps RTP as (PTP_ns*rate/1e9 + offset) mod 2^32.
function sdp() {
const c = read(), a = c.aes67, dom = c.ptp.domain,
ip = st.aes67_ip || st.ip || c.net.ip || '0.0.0.0',
gm = st.ptp?.gm_id || '00-00-00-00-00-00-00-00';
sdpEl.value = [
'v=0',
`o=- ${a.session_id} ${a.session_ver} IN IP4 ${ip}`,
`s=${a.name}`,
`c=IN IP4 ${a.mcast}/${a.ttl}`,
't=0 0',
'a=clock-domain:PTPv2 ' + dom,
`m=audio ${a.port} RTP/AVP ${a.pt}`,
`i=${String(a.channels) === '1' ? 'mono' : String(a.channels) === '2' ? 'stereo' : a.channels + ' ch'}`,
`a=rtpmap:${a.pt} ${a.encoding}/${a.rate}/${a.channels}`,
'a=recvonly',
`a=ptime:${a.ptime}`,
`a=ts-refclk:ptp=IEEE1588-2008:${gm}:${dom}`,
`a=mediaclk:direct=${a.clk_offset}`,
''
].join('\r\n');
}
function dl() {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([sdpEl.value], {type:'application/sdp'}));
a.download = (read().aes67.name || 'stream').replace(/[^\w.-]+/g, '_') + '.sdp';
a.click(); URL.revokeObjectURL(a.href);
}
async function load() {
try { cfg = merge(DEF, await api('GET', '/api/config')); msg.textContent = ''; }
catch { cfg = structuredClone(DEF); msg.textContent = 'No device API – showing defaults.'; }
fill();
}
async function save(e) {
e.preventDefault();
const c = read();
c.aes67.session_ver = (Number(cfg.aes67?.session_ver) || 0) + 1; // SDP version bump per RFC 4566
await api('POST', '/api/config', c);
// A changed IP setup reboots the device: follow it to the new address.
const addr = n => n.dhcp ? 'dhcp' : [n.ip, n.mask, n.gw, n.dns].join('/');
const moved = addr(cfg.net || {}) !== addr(c.net);
cfg = c; fill(); msg.textContent = 'Saved.';
if (moved) {
const to = c.net.dhcp ? location.host : c.net.ip;
msg.textContent = `Rebooting with the new IP setup. Opening http://${to}/ in 20 s.`;
setTimeout(() => location.href = `http://${to}/`, 20000);
}
}
// PTP display follows the Riedel (Bolero/Artist) status layout, IEEE 1588-2019 terms.
const PTP_STATE = {MASTER:'TimeTransmitter', SLAVE:'TimeReceiver', PASSIVE:'Passive', LISTENING:'Listening',
UNCALIBRATED:'Uncalibrated', INITIALIZING:'Initializing', FAULTY:'Faulty', DISABLED:'Disabled'};
// "00-1D-C1-FF-FE-12-34-56" -> "00:1D:C1:12:34:56 (FFFE)" (EUI-64 from MAC); anything else shown as-is.
function clockId(id) {
const b = String(id || '').toUpperCase().split(/[-:.]/);
if (b.length !== 8) return id || '–';
return b[3] + b[4] === 'FFFE' ? `${[...b.slice(0, 3), ...b.slice(5)].join(':')} (FFFE)` : b.join(':');
}
// Bucketed like Riedel so the value doesn't flicker; exact figure in brackets.
function offsetBucket(ns) {
if (ns == null) return '–';
const a = Math.abs(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)`};
}
function freqBucket(ppb) {
if (ppb == null) return '–';
const a = Math.abs(ppb);
const t = a < 100 ? '< 100 ppb' : a < 500 ? '100-500 ppb' : a < 1e3 ? '500 ppb - 1 ppm'
: a < 1e4 ? '1-10 ppm' : a < 5e4 ? '10-50 ppm' : '> 50 ppm';
return `${t} (${n(ppb)} ppb)`;
}
const yn = v => v == null ? '–' : v ? 'Yes' : 'No';
function showPtp(p) {
if (!p) { table($('ptpmore'), {}); return table($('ptpstat'), {'PTP': 'no data'}); }
const gmSelf = p.gm_id && p.gm_id === p.clock_id;
table($('ptpstat'), {
'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),
'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),
'TT class / accuracy': `${p.gm_class ?? '–'} / 0x${(p.gm_accuracy ?? 0).toString(16).toUpperCase()}`,
'TT priority 1 / 2': `${p.gm_p1 ?? '–'} / ${p.gm_p2 ?? '–'}`,
// 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',
'Delay req / resp': `${p.delay_req ?? '–'} / ${p.delay_resp ?? '–'}`,
'Timestamping': p.hw_ts ? 'hardware' : 'software'
});
}
// status.temps = [{name, c, min_c, max_c, warn_c}] – any number of sensors (SoC on-die, external I2C, …).
function temps(list) {
const o = {};
for (const t of list || []) {
const hot = t.warn_c != null && t.c >= t.warn_c;
const text = `${n(t.c, 1)} °C${hot ? ' HIGH' : ''}` +
(t.min_c != null ? ` (min ${n(t.min_c, 1)} / max ${n(t.max_c, 1)} since boot)` : '');
o[`Temp ${t.name}`] = hot ? {cls:'bad', text} : text;
}
return o;
}
async function poll() {
PROJECT.poll?.();
try {
st = await api('GET', '/api/status');
table($('status'), {
...(st.model ? {'Type': st.model} : {}),
...(st.fw ? {'Firmware': st.fw} : {}),
'IP / MAC': `${st.ip} / ${st.mac}`,
...(st.inet_ip ? {'Internet VLAN IP': `${st.inet_ip} (VLAN ${st.inet_vlan})`} : {}),
'Link': st.link,
...(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}`,
...temps(st.temps)
});
showPtp(st.ptp);
netLive();
sdp();
} catch { table($('status'), {'Device': 'offline'}); showPtp(null); }
}
f['ptp.role'].addEventListener('change', e => {
if (ROLE_P1[e.target.value] != null) f['ptp.priority1'].value = ROLE_P1[e.target.value];
});
f.addEventListener('input', () => { toggle(); sdp(); });
/* ----- Firmware update (OTA) ----- */
const fwMsg = t => $('fwmsg').textContent = t;
function fwProgress(p) { $('fwprog').hidden = p == null; if (p != null) $('fwprog').value = p; }
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,
'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'
});
$('fwconfirm').hidden = !o.pending_verify;
$('fwrollback').hidden = !o.can_rollback;
return o;
} catch { table($('fwinfo'), {'Firmware': 'no data'}); }
}
// After an install the device reboots: wait for it, then show the running version.
async function otaWaitReboot(oldVersion) {
fwMsg('Rebooting…'); fwProgress(null);
const t0 = Date.now();
await new Promise(r => setTimeout(r, 3000));
while (Date.now() - t0 < 120000) {
try {
const r = await fetch('/api/ota', {cache:'no-store'});
if (r.ok) {
const o = await r.json(); await otaInfo();
return fwMsg(o.version === oldVersion ? `Back up, still on ${o.version} (update rejected or rolled back).`
: `Updated to ${o.version}.`);
}
} catch {}
await new Promise(r => setTimeout(r, 2000));
}
fwMsg('Device did not come back within 2 minutes – check the serial log.');
}
async function otaUpload() {
const file = $('fwfile').files[0];
if (!file) return fwMsg('Choose a .bin file first.');
if (!confirm(`Install ${file.name} (${(file.size / 1024).toFixed(0)} KiB) and reboot?`)) return;
const old = (await otaInfo())?.version;
const x = new XMLHttpRequest();
x.open('POST', '/api/ota');
x.setRequestHeader('Content-Type', 'application/octet-stream');
x.upload.onprogress = e => { if (e.lengthComputable) { fwProgress(100 * e.loaded / e.total); fwMsg('Uploading…'); } };
x.onload = () => x.status === 200 ? otaWaitReboot(old) : (fwProgress(null), fwMsg(`Rejected: ${x.status} ${x.responseText}`));
x.onerror = () => { fwProgress(null); fwMsg('Upload failed (connection lost).'); };
fwProgress(0); x.send(file);
}
async function otaAction(cmd) {
if (cmd === 'rollback' && !confirm('Roll back to the previous firmware and reboot?')) return;
const old = (await otaInfo())?.version;
try { await api('POST', '/api/ota/' + cmd); } catch { return; }
cmd === 'rollback' ? otaWaitReboot(old) : (otaInfo(), fwMsg('Firmware confirmed.'));
}
document.title = $('title').textContent = PROJECT.title;
load(); poll(); otaInfo(); setInterval(poll, 2000);
</script>
</body>
</html>