Upload files to "web"

This commit is contained in:
2026-09-23 21:52:46 +10:00
parent 67f76aed93
commit 221b95a950
+546
View File
@@ -0,0 +1,546 @@
<!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>
:root{color-scheme:light dark}
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{min-height:1.4em}
.ok{color:#2a2}.warn{color:#d80}.bad{color:#e33}
</style>
</head>
<body>
<h2 id="title">AES67 device</h2>
<p id="msg"></p>
<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>Volume</span><input id="vol" type="range" min="0" max="100"
onchange="ctl('volume',{value:+this.value})"></label>
<label><span>Force source</span><select id="force" onchange="ctl('source',{source:this.value})">
<option value="config">As configured</option><option value="spotify">Spotify</option>
<option value="hls">HLS</option><option value="off">Off</option></select></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="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>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><option>96000</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>
<label><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>
<button>Save</button>
<button type="button" onclick="load()">Revert</button>
<button type="button" onclick="confirm('Reboot device?')&&api('POST','/api/reboot')">Reboot</button>
</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},
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.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 {}
}
function showPlayer(p) {
if (!p || !p.state) return;
const t = s => s == null ? '' : `${Math.floor(s/60000)}:${String(Math.floor(s/1000)%60).padStart(2,'0')}`;
document.getElementById('np').textContent =
`[${p.source}] ${p.state}: ${[p.artist, p.title].filter(Boolean).join(' – ') || '–'}` +
(p.duration_ms ? ` ${t(p.position_ms)} / ${t(p.duration_ms)}` : '');
const v = document.getElementById('vol');
if (document.activeElement !== v) v.value = p.volume;
document.getElementById('force').value = p.forced || 'config';
}
/* ===== 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;
}
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;
// 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);
cfg = c; fill(); msg.textContent = 'Saved.';
}
// 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);
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>