c4468c4af0
With DHCP on, the greyed-out IP/netmask/gateway/DNS fields show the addresses in use. Unticking DHCP keeps them as the starting point for a static setup. (Static addressing itself is not applied yet.) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
580 lines
29 KiB
HTML
580 lines
29 KiB
HTML
<!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{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>
|
||
</head>
|
||
<body>
|
||
<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')">⏮</button>
|
||
<button onclick="ctl('toggle')">⏯</button>
|
||
<button onclick="ctl('next')">⏭</button>
|
||
<button onclick="ctl('stop')">⏹</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><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>
|
||
|
||
<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 & 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,'&').replace(/</g,'<');
|
||
// 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);
|
||
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);
|
||
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>
|