Files
DigiRadio/Software/components/net/www/index.html
T
micheleandCursor 176cdd9319 Release fw 0.8.4: doc sync, System UI, BT/FM polish.
Align README, manual, and backlog to 0.8.4; add Web UI System tab for OTA/DSP uploads, serial in health header, FM seek down, and BT1035 paired list plus auto-reconnect API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 09:56:44 +02:00

1138 lines
40 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>DigiRadio</title>
<style>
:root {
--space-1: 0.5rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2.5rem;
--text-sm: 0.8125rem;
--text-base: 1rem;
--text-lg: 1.25rem;
--text-xl: 1.75rem;
--text-2xl: 2.25rem;
--accent: #c47a2c;
--accent-dim: #8f5a1f;
--bg: #0f1114;
--surface: #1a1d22;
--surface-2: #242830;
--text: #e8eaed;
--muted: #9aa0a6;
--ok: #7cb342;
--err: #e57373;
--radius: 0.5rem;
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
font-size: var(--text-base);
line-height: 1.5;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: var(--space-2);
}
.shell {
max-width: 40rem;
margin: 0 auto;
}
header {
background: var(--surface);
border-radius: var(--radius);
padding: var(--space-3);
border-top: 3px solid var(--accent);
margin-bottom: var(--space-2);
}
header h1 { font-size: var(--text-xl); font-weight: 600; }
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1) var(--space-2);
margin-top: var(--space-1);
font-size: var(--text-sm);
color: var(--muted);
}
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
}
.chips { display: flex; flex-wrap: wrap; gap: var(--space-1); }
.chip {
font-size: 0.75rem;
padding: 0.15rem 0.45rem;
border-radius: 999px;
background: var(--surface-2);
color: var(--muted);
}
.chip.on { color: var(--ok); border: 1px solid var(--ok); }
.chip.off { opacity: 0.55; }
nav.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: var(--space-2);
}
nav.tabs button {
flex: 1 1 auto;
min-width: 4.5rem;
padding: 0.55rem 0.65rem;
border: 1px solid #333;
border-radius: var(--radius);
background: var(--surface);
color: var(--muted);
font: inherit;
font-size: var(--text-sm);
cursor: pointer;
}
nav.tabs button.active {
color: var(--text);
border-color: var(--accent);
background: var(--surface-2);
}
.panel {
display: none;
background: var(--surface);
border-radius: var(--radius);
padding: var(--space-3);
}
.panel.active { display: block; }
h2 {
font-size: var(--text-lg);
font-weight: 600;
margin-bottom: var(--space-1);
}
.lede {
color: var(--muted);
font-size: var(--text-sm);
margin-bottom: var(--space-2);
}
label {
display: block;
font-size: var(--text-sm);
color: var(--muted);
margin-bottom: 0.25rem;
}
input, select {
width: 100%;
padding: var(--space-1) var(--space-2);
margin-bottom: var(--space-2);
border: 1px solid #333;
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
}
input[type="file"] {
padding: var(--space-1);
font-size: var(--text-sm);
}
input[type="range"] { padding: 0; accent-color: var(--accent); }
button.action {
width: 100%;
padding: var(--space-2);
border: none;
border-radius: var(--radius);
background: var(--accent);
color: var(--bg);
font: inherit;
font-weight: 600;
cursor: pointer;
}
button.action.secondary {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
}
button.action:disabled { opacity: 0.55; cursor: wait; }
button.action.compact {
width: auto;
padding: 0.35rem 0.65rem;
font-size: var(--text-sm);
font-weight: 500;
}
.row { display: flex; gap: var(--space-2); margin-bottom: var(--space-2); }
.row .action { flex: 1; }
.msg {
font-size: var(--text-sm);
margin-top: var(--space-2);
min-height: 1.25rem;
}
.msg.ok { color: var(--ok); }
.msg.err { color: var(--err); }
code {
font-size: 0.9em;
background: var(--bg);
padding: 0.1em 0.35em;
border-radius: 0.25rem;
}
.now-title {
font-size: var(--text-2xl);
font-weight: 600;
line-height: 1.2;
margin-bottom: var(--space-1);
word-break: break-word;
}
.now-sub {
color: var(--muted);
font-size: var(--text-base);
margin-bottom: var(--space-2);
min-height: 1.5rem;
word-break: break-word;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.stat {
background: var(--bg);
border-radius: var(--radius);
padding: var(--space-1) var(--space-2);
font-size: var(--text-sm);
}
.stat span { display: block; color: var(--muted); font-size: 0.75rem; }
.band-panel { display: none; }
.band-panel.show { display: block; }
ul.list {
list-style: none;
margin-bottom: var(--space-2);
max-height: 14rem;
overflow-y: auto;
}
ul.list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) 0;
border-bottom: 1px solid #2a2d33;
font-size: var(--text-sm);
}
ul.list li .label {
flex: 1;
min-width: 0;
word-break: break-word;
}
ul.list li .actions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
justify-content: flex-end;
}
.eq-band {
display: grid;
grid-template-columns: 4rem 1fr 3rem;
align-items: center;
gap: var(--space-1);
margin-bottom: var(--space-1);
}
.eq-band label { margin: 0; font-size: 0.75rem; }
.eq-band output {
font-size: var(--text-sm);
text-align: right;
color: var(--muted);
}
.slider-row {
display: grid;
grid-template-columns: 1fr 3.5rem;
align-items: center;
gap: var(--space-1);
margin-bottom: var(--space-1);
}
.slider-row output {
font-size: var(--text-sm);
text-align: right;
color: var(--muted);
}
</style>
</head>
<body>
<div class="shell">
<header>
<h1>DigiRadio</h1>
<div class="meta" id="health-meta">
<span class="dot" aria-hidden="true"></span>
<span id="health-text">Checking…</span>
<div class="chips" id="chip-badges" hidden></div>
</div>
</header>
<nav class="tabs" role="tablist" aria-label="Sections">
<button type="button" class="active" data-tab="now" role="tab">Now</button>
<button type="button" data-tab="radio" role="tab">Radio</button>
<button type="button" data-tab="presets" role="tab">Presets</button>
<button type="button" data-tab="audio" role="tab">Audio</button>
<button type="button" data-tab="bt" role="tab">BT</button>
<button type="button" data-tab="wifi" role="tab">WiFi</button>
<button type="button" data-tab="system" role="tab">System</button>
</nav>
<section id="panel-now" class="panel active" role="tabpanel">
<h2>Now playing</h2>
<p class="lede">Live metadata from RDS (FM) or Dynamic Label (DAB).</p>
<div class="now-title" id="now-title"></div>
<div class="now-sub" id="now-sub"></div>
<div class="stat-grid" id="now-stats"></div>
<div class="row">
<button type="button" class="action secondary" id="now-refresh">Refresh</button>
</div>
</section>
<section id="panel-radio" class="panel" role="tabpanel" hidden>
<h2>Tuner</h2>
<p class="lede">DAB ensemble tune, service play, or FM frequency and seek.</p>
<label for="band">Band</label>
<select id="band">
<option value="dab">DAB</option>
<option value="fm">FM</option>
</select>
<div id="panel-dab" class="band-panel show">
<label for="dab-index">Ensemble index (037)</label>
<input id="dab-index" type="number" min="0" max="37" value="12">
<button type="button" class="action" id="tune-dab">Tune DAB</button>
<div class="row">
<button type="button" class="action secondary" id="load-services">Load services</button>
</div>
<ul class="list" id="service-list"></ul>
</div>
<div id="panel-fm" class="band-panel">
<label for="fm-khz">Frequency (kHz)</label>
<input id="fm-khz" type="number" min="64000" max="108000" step="100" value="101500">
<button type="button" class="action" id="tune-fm">Tune FM</button>
<div class="row">
<button type="button" class="action secondary" id="seek-fm-up">Seek up</button>
<button type="button" class="action secondary" id="seek-fm-down">Seek down</button>
</div>
</div>
<p class="msg" id="tuner-msg" aria-live="polite"></p>
</section>
<section id="panel-presets" class="panel" role="tabpanel" hidden>
<h2>Presets</h2>
<p class="lede">Saved stations in NVS — recall runs tuner + audio profile.</p>
<ul class="list" id="preset-list"></ul>
<label for="preset-name">Name</label>
<input id="preset-name" maxlength="32" placeholder="My station">
<label for="preset-slot">Preset slot (120, optional)</label>
<input id="preset-slot" type="number" min="1" max="20">
<button type="button" class="action" id="save-preset">Save current tune target</button>
<p class="msg" id="preset-msg" aria-live="polite"></p>
</section>
<section id="panel-audio" class="panel" role="tabpanel" hidden>
<h2>Audio</h2>
<p class="lede">ADAU1701 mixer, six-band EQ, and enhancement overlays.</p>
<div class="slider-row">
<label for="master-db">Master (dB)</label>
<output id="master-db-out">0</output>
</div>
<input id="master-db" type="range" min="-60" max="12" step="0.5" value="0">
<div class="slider-row">
<label for="si4684-db">Radio path (dB)</label>
<output id="si4684-db-out">0</output>
</div>
<input id="si4684-db" type="range" min="-60" max="12" step="0.5" value="0">
<div class="slider-row">
<label for="esp32-db">ESP32 path (dB)</label>
<output id="esp32-db-out">0</output>
</div>
<input id="esp32-db" type="range" min="-60" max="12" step="0.5" value="0">
<h2 style="margin-top: var(--space-2); font-size: var(--text-base);">EQ bands</h2>
<div id="eq-sliders"></div>
<div class="slider-row">
<label for="stereo-level">Stereo depth</label>
<output id="stereo-level-out">0</output>
</div>
<input id="stereo-level" type="range" min="0" max="100" step="1" value="0">
<div class="slider-row">
<label for="bass-level">Bass enhance</label>
<output id="bass-level-out">0</output>
</div>
<input id="bass-level" type="range" min="0" max="100" step="1" value="0">
<div class="row">
<button type="button" class="action" id="save-audio">Save profile</button>
<button type="button" class="action secondary" id="reset-audio">Reset flat</button>
</div>
<p class="msg" id="audio-msg" aria-live="polite"></p>
</section>
<section id="panel-bt" class="panel" role="tabpanel" hidden>
<h2>Bluetooth</h2>
<p class="lede">FSC-BT1035 aptX — pair headphones or speakers.</p>
<div class="stat-grid" id="bt-stats"></div>
<ul class="list" id="bt-paired-list"></ul>
<div class="slider-row">
<label for="bt-auto-reconnect">Auto-reconnect</label>
<output id="bt-auto-reconnect-out">0</output>
</div>
<input id="bt-auto-reconnect" type="range" min="0" max="15" step="1" value="0">
<div class="row">
<button type="button" class="action" id="bt-refresh">Refresh</button>
<button type="button" class="action secondary" id="bt-pair">Pair</button>
</div>
<div class="row">
<button type="button" class="action secondary" id="bt-load-paired">Paired list</button>
<button type="button" class="action secondary" id="bt-stop-pair">Stop pairing</button>
</div>
<div class="row">
<button type="button" class="action secondary" id="bt-disconnect">Disconnect</button>
<button type="button" class="action secondary" id="bt-save-reconnect">Save reconnect</button>
</div>
<p class="msg" id="bt-msg" aria-live="polite"></p>
</section>
<section id="panel-wifi" class="panel" role="tabpanel" hidden>
<h2>WiFi</h2>
<p class="lede">Provision STA credentials (setup mode: join <code>DigiRadio-setup</code>, open <code>192.168.4.1</code>).</p>
<form id="wifi-form">
<label for="ssid">Network name (SSID)</label>
<input id="ssid" name="ssid" autocomplete="off" required maxlength="32">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="off" maxlength="63">
<button type="submit" class="action" id="save-btn">Save &amp; connect</button>
</form>
<p class="msg" id="wifi-msg" aria-live="polite"></p>
</section>
<section id="panel-system" class="panel" role="tabpanel" hidden>
<h2>System</h2>
<p class="lede">Firmware and ADAU1701 program updates. Device reboots after a successful upload.</p>
<label for="ota-file">Firmware image (.bin)</label>
<input id="ota-file" type="file" accept=".bin,application/octet-stream">
<button type="button" class="action" id="ota-upload">Upload firmware OTA</button>
<label for="dsp-file" style="margin-top: var(--space-2);">ADAU1701 program (DRAD blob)</label>
<input id="dsp-file" type="file" accept=".bin,.dsp,application/octet-stream">
<button type="button" class="action secondary" id="dsp-upload">Upload DSP program</button>
<p class="msg" id="system-msg" aria-live="polite"></p>
</section>
</div>
<script>
var audioProfile = null;
var lastTunerStatus = null;
var pollTimer = null;
function $(id) { return document.getElementById(id); }
function showMsg(el, text, ok) {
el.textContent = text;
el.className = "msg " + (ok ? "ok" : "err");
}
function bindSlider(inputId, outId) {
var input = $(inputId);
var out = $(outId);
function sync() { out.textContent = input.value; }
input.addEventListener("input", sync);
sync();
}
function api(path, opts) {
opts = opts || {};
return fetch(path, opts).then(function (r) {
if (r.status === 204 || r.headers.get("content-length") === "0") {
return { ok: r.ok, status: r.status, d: {} };
}
return r.text().then(function (text) {
var d = {};
if (text) {
try { d = JSON.parse(text); } catch (e) { d = { raw: text }; }
}
return { ok: r.ok, status: r.status, d: d };
});
});
}
function uploadBinary(path, file) {
return fetch(path, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: file
}).then(function (r) {
return r.text().then(function (text) {
var d = {};
if (text) {
try { d = JSON.parse(text); } catch (e) { d = { raw: text }; }
}
return { ok: r.ok, status: r.status, d: d };
});
});
}
function seekFm(direction) {
api("/api/tuner/seek", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ direction: direction })
})
.then(function (res) {
if (res.ok && res.d.frequency_khz != null) {
$("fm-khz").value = res.d.frequency_khz;
showMsg($("tuner-msg"), "Seeked to " + res.d.frequency_khz + " kHz.", true);
refreshTunerStatus();
} else {
showMsg($("tuner-msg"), "Seek failed: " + (res.d.reason || res.d.error || "unknown"), false);
}
})
.catch(function () { showMsg($("tuner-msg"), "Seek failed.", false); });
}
function setTab(name) {
document.querySelectorAll("nav.tabs button").forEach(function (btn) {
var on = btn.getAttribute("data-tab") === name;
btn.classList.toggle("active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
document.querySelectorAll(".panel").forEach(function (panel) {
var on = panel.id === "panel-" + name;
panel.classList.toggle("active", on);
panel.hidden = !on;
});
}
document.querySelectorAll("nav.tabs button").forEach(function (btn) {
btn.addEventListener("click", function () {
setTab(btn.getAttribute("data-tab"));
});
});
function renderChips(chips) {
var wrap = $("chip-badges");
if (!chips) {
wrap.hidden = true;
return;
}
wrap.hidden = false;
wrap.innerHTML = "";
[
["Si4684", chips.si4684],
["ADAU1701", chips.adau1701],
["BT1035", chips.bt1035]
].forEach(function (pair) {
var span = document.createElement("span");
span.className = "chip " + (pair[1] ? "on" : "off");
span.textContent = pair[0] + (pair[1] ? " ✓" : " —");
wrap.appendChild(span);
});
}
function nowPlayingFromStatus(s) {
if (!s || !s.booted) {
return { title: "Tuner not ready", sub: "" };
}
if (s.band === "fm" && s.fm) {
return {
title: s.fm.station_name || (s.fm.frequency_khz + " kHz"),
sub: s.fm.radiotext || ""
};
}
if (s.band === "dab" && s.dab) {
return {
title: s.dab.dynamic_label || ("Ensemble " + s.dab.freq_index),
sub: s.dab.playing_service_id != null
? "Service " + s.dab.playing_service_id : ""
};
}
return { title: "—", sub: "" };
}
function renderNowPlaying(s) {
lastTunerStatus = s;
var np = nowPlayingFromStatus(s);
$("now-title").textContent = np.title;
$("now-sub").textContent = np.sub;
var stats = [];
if (s && s.booted != null) {
stats.push(["Band", s.band]);
stats.push(["Locked", s.locked ? "yes" : "no"]);
stats.push(["Volume", s.volume]);
if (s.dab) {
if (s.dab.freq_index != null) stats.push(["DAB idx", s.dab.freq_index]);
if (s.dab.fic_quality != null) stats.push(["FIC", s.dab.fic_quality]);
if (s.dab.cnr_db != null) stats.push(["CNR", s.dab.cnr_db + " dB"]);
}
if (s.fm) {
if (s.fm.frequency_khz != null) stats.push(["FM", s.fm.frequency_khz + " kHz"]);
if (s.fm.rssi_dbuv != null) stats.push(["RSSI", s.fm.rssi_dbuv + " dBµV"]);
if (s.fm.stereo != null) stats.push(["Stereo", s.fm.stereo ? "yes" : "no"]);
}
} else {
stats.push(["Status", "unavailable"]);
}
$("now-stats").innerHTML = stats.map(function (pair) {
return '<div class="stat"><span>' + pair[0] +
'</span>' + pair[1] + "</div>";
}).join("");
}
function refreshTunerStatus() {
return api("/api/tuner/status")
.then(function (res) {
if (res.ok && res.d.booted != null) {
renderNowPlaying(res.d);
if (res.d.band === "fm") {
$("band").value = "fm";
} else if (res.d.band === "dab") {
$("band").value = "dab";
}
syncBandPanels();
showMsg($("tuner-msg"), "", true);
} else {
showMsg($("tuner-msg"), "Status: " + (res.d.reason || "error"), false);
}
})
.catch(function () {
showMsg($("tuner-msg"), "Status request failed.", false);
});
}
function syncBandPanels() {
var dab = $("panel-dab");
var fm = $("panel-fm");
if ($("band").value === "dab") {
dab.classList.add("show");
fm.classList.remove("show");
} else {
fm.classList.add("show");
dab.classList.remove("show");
}
}
$("band").addEventListener("change", syncBandPanels);
function buildEqSliders(eq) {
var root = $("eq-sliders");
root.innerHTML = "";
(eq || []).forEach(function (band, i) {
var row = document.createElement("div");
row.className = "eq-band";
var hz = band.center_hz >= 1000
? (band.center_hz / 1000) + "k" : band.center_hz;
row.innerHTML =
'<label for="eq-' + i + '">' + hz + " Hz</label>" +
'<input id="eq-' + i + '" type="range" min="-12" max="12" step="0.5" value="' +
band.gain_db + '">' +
'<output id="eq-' + i + '-out">' + band.gain_db + "</output>";
root.appendChild(row);
bindSlider("eq-" + i, "eq-" + i + "-out");
});
}
function loadAudioProfile() {
return api("/api/audio/profile")
.then(function (res) {
if (!res.ok) {
showMsg($("audio-msg"), "Load failed.", false);
return;
}
audioProfile = res.d;
$("master-db").value = res.d.master.left_db;
$("si4684-db").value = res.d.mixer.si4684_left_db;
$("esp32-db").value = res.d.mixer.esp32_left_db;
$("master-db-out").textContent = res.d.master.left_db;
$("si4684-db-out").textContent = res.d.mixer.si4684_left_db;
$("esp32-db-out").textContent = res.d.mixer.esp32_left_db;
if (res.d.enhancements) {
$("stereo-level").value = res.d.enhancements.stereo_level;
$("bass-level").value = res.d.enhancements.bass_level;
$("stereo-level-out").textContent = res.d.enhancements.stereo_level;
$("bass-level-out").textContent = res.d.enhancements.bass_level;
}
buildEqSliders(res.d.eq);
showMsg($("audio-msg"), "", true);
})
.catch(function () {
showMsg($("audio-msg"), "Audio profile load failed.", false);
});
}
function collectAudioProfile() {
if (!audioProfile) return null;
var master = parseFloat($("master-db").value, 10);
var si4684 = parseFloat($("si4684-db").value, 10);
var esp32 = parseFloat($("esp32-db").value, 10);
audioProfile.master.left_db = master;
audioProfile.master.right_db = master;
audioProfile.mixer.si4684_left_db = si4684;
audioProfile.mixer.si4684_right_db = si4684;
audioProfile.mixer.esp32_left_db = esp32;
audioProfile.mixer.esp32_right_db = esp32;
if (!audioProfile.enhancements) {
audioProfile.enhancements = { stereo_level: 0, bass_level: 0 };
}
audioProfile.enhancements.stereo_level =
parseInt($("stereo-level").value, 10);
audioProfile.enhancements.bass_level =
parseInt($("bass-level").value, 10);
if (audioProfile.eq) {
audioProfile.eq.forEach(function (band, i) {
var el = $("eq-" + i);
if (el) band.gain_db = parseFloat(el.value, 10);
});
}
return audioProfile;
}
function renderBtStats(s) {
if (!s || s.booted == null) {
$("bt-stats").innerHTML = "";
return;
}
var rows = [
["Booted", s.booted ? "yes" : "no"],
["Name", s.device_name || "—"],
["Pairing", s.pairing ? "yes" : "no"],
["A2DP", s.a2dp || "—"],
["Auto-reconnect", s.auto_reconnect != null ? s.auto_reconnect : "—"]
];
$("bt-stats").innerHTML = rows.map(function (pair) {
return '<div class="stat"><span>' + pair[0] +
'</span>' + pair[1] + "</div>";
}).join("");
if (s.auto_reconnect != null) {
$("bt-auto-reconnect").value = s.auto_reconnect;
$("bt-auto-reconnect-out").textContent = s.auto_reconnect;
}
}
function refreshBluetooth() {
return api("/api/bluetooth/status")
.then(function (res) {
if (res.ok && res.d.booted != null) {
renderBtStats(res.d);
showMsg($("bt-msg"), "", true);
} else {
showMsg($("bt-msg"), "BT: " + (res.d.reason || "error"), false);
}
})
.catch(function () {
showMsg($("bt-msg"), "BT request failed.", false);
});
}
function renderPresets(data) {
var list = $("preset-list");
list.innerHTML = "";
(data.stations || []).forEach(function (s, idx) {
var li = document.createElement("li");
var label = document.createElement("span");
label.className = "label";
var text = s.name + " · " + s.band;
if (s.fm_frequency_khz) text += " · " + s.fm_frequency_khz + " kHz";
if (s.dab_freq_index != null) text += " · idx " + s.dab_freq_index;
if (s.dab_service_id != null) text += " · svc " + s.dab_service_id;
label.textContent = text;
var actions = document.createElement("div");
actions.className = "actions";
function mkBtn(caption, secondary, fn) {
var b = document.createElement("button");
b.type = "button";
b.className = "action compact" + (secondary ? " secondary" : "");
b.textContent = caption;
b.addEventListener("click", fn);
return b;
}
actions.appendChild(mkBtn("Tune", false, function () {
api("/api/stations/tune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ index: idx })
}).then(function () { refreshTunerStatus(); setTab("now"); });
}));
actions.appendChild(mkBtn("Up", true, function () {
if (idx === 0) return;
api("/api/stations/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: idx, to: idx - 1 })
}).then(function () { loadPresets(); });
}));
actions.appendChild(mkBtn("Dn", true, function () {
api("/api/stations/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: idx, to: idx + 1 })
}).then(function () { loadPresets(); });
}));
actions.appendChild(mkBtn("Del", true, function () {
api("/api/stations/remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ index: idx })
}).then(function () { loadPresets(); });
}));
li.appendChild(label);
li.appendChild(actions);
list.appendChild(li);
});
}
function loadPresets() {
return api("/api/stations")
.then(function (res) {
if (res.ok) renderPresets(res.d);
})
.catch(function () {});
}
function buildPresetBody(name, band, status) {
var body = { name: name, band: band };
if (band === "fm") {
body.fm_frequency_khz = status && status.fm && status.fm.frequency_khz != null
? status.fm.frequency_khz
: parseInt($("fm-khz").value, 10);
} else {
body.dab_freq_index = status && status.dab && status.dab.freq_index != null
? status.dab.freq_index
: parseInt($("dab-index").value, 10);
if (status && status.dab && status.dab.playing_service_id != null) {
body.dab_service_id = status.dab.playing_service_id;
}
if (status && status.dab && status.dab.playing_component_id != null) {
body.dab_component_id = status.dab.playing_component_id;
}
}
var slotVal = $("preset-slot").value;
if (slotVal) body.preset_slot = parseInt(slotVal, 10);
return body;
}
function postEnhance(url, level) {
return api(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ level: level })
});
}
function loadBluetoothPaired() {
return api("/api/bluetooth/paired")
.then(function (res) {
var list = $("bt-paired-list");
list.innerHTML = "";
if (!res.ok || !res.d.devices) {
showMsg($("bt-msg"), "Paired list failed.", false);
return;
}
res.d.devices.forEach(function (device) {
var li = document.createElement("li");
var label = document.createElement("span");
label.className = "label";
label.textContent = device.index + " · " + device.mac +
(device.name ? " · " + device.name : "");
li.appendChild(label);
list.appendChild(li);
});
showMsg($("bt-msg"), res.d.devices.length + " paired device(s).", true);
})
.catch(function () { showMsg($("bt-msg"), "Paired list failed.", false); });
}
api("/api/health")
.then(function (res) {
if (res.ok) {
var serial = res.d.serialNumber || "unknown";
$("health-text").innerHTML =
'Status <code>' + res.d.status + "</code> · FW <code>" + res.d.fw +
"</code> · SN <code>" + serial + "</code>";
renderChips(res.d.chips);
} else {
$("health-text").textContent = "Health check failed.";
}
})
.catch(function () {
$("health-text").textContent = "Health check failed.";
});
$("wifi-form").addEventListener("submit", function (e) {
e.preventDefault();
var btn = $("save-btn");
var msg = $("wifi-msg");
btn.disabled = true;
showMsg(msg, "Saving…", true);
api("/api/wifi", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ssid: $("ssid").value,
password: $("password").value
})
})
.then(function (res) {
if (res.ok && res.d.status === "saved") {
showMsg(msg, "Saved. Rebooting in " + res.d.reboot_in_sec + "s…", true);
} else {
showMsg(msg, "Error: " + (res.d.reason || "unknown"), false);
btn.disabled = false;
}
})
.catch(function () {
showMsg(msg, "Request failed.", false);
btn.disabled = false;
});
});
$("now-refresh").addEventListener("click", refreshTunerStatus);
$("tune-dab").addEventListener("click", function () {
var idx = parseInt($("dab-index").value, 10);
api("/api/tuner/tune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ band: "dab", freq_index: idx })
})
.then(function (res) {
if (res.ok && res.d.booted != null) {
renderNowPlaying(res.d);
showMsg($("tuner-msg"), "DAB tuned.", true);
} else {
showMsg($("tuner-msg"), "Tune failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg($("tuner-msg"), "Tune failed.", false); });
});
$("tune-fm").addEventListener("click", function () {
var khz = parseInt($("fm-khz").value, 10);
api("/api/tuner/tune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ band: "fm", frequency_khz: khz })
})
.then(function (res) {
if (res.ok && res.d.booted != null) {
renderNowPlaying(res.d);
showMsg($("tuner-msg"), "FM tuned.", true);
} else {
showMsg($("tuner-msg"), "Tune failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg($("tuner-msg"), "Tune failed.", false); });
});
$("seek-fm-up").addEventListener("click", function () { seekFm("up"); });
$("seek-fm-down").addEventListener("click", function () { seekFm("down"); });
$("load-services").addEventListener("click", function () {
var list = $("service-list");
list.innerHTML = "";
api("/api/tuner/services")
.then(function (res) {
if (!res.ok || !res.d.services) {
showMsg($("tuner-msg"), "Services: " + (res.d.reason || "error"), false);
return;
}
res.d.services.forEach(function (svc) {
var li = document.createElement("li");
var label = document.createElement("span");
label.className = "label";
label.textContent = svc.label || ("SID " + svc.service_id);
var btn = document.createElement("button");
btn.type = "button";
btn.className = "action compact";
btn.textContent = "Play";
btn.addEventListener("click", function () {
api("/api/tuner/play", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
service_id: svc.service_id,
component_id: svc.component_id
})
})
.then(function (res) {
if (res.ok && res.d.status === "playing") {
showMsg($("tuner-msg"), "Playing " + (svc.label || svc.service_id), true);
refreshTunerStatus();
} else {
showMsg($("tuner-msg"), "Play failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg($("tuner-msg"), "Play failed.", false); });
});
li.appendChild(label);
li.appendChild(btn);
list.appendChild(li);
});
showMsg($("tuner-msg"), res.d.services.length + " service(s).", true);
})
.catch(function () { showMsg($("tuner-msg"), "Services failed.", false); });
});
$("save-audio").addEventListener("click", function () {
var body = collectAudioProfile();
if (!body) {
showMsg($("audio-msg"), "Profile not loaded.", false);
return;
}
api("/api/audio/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
})
.then(function (res) {
if (res.ok && res.d.status === "saved") {
showMsg($("audio-msg"), "Audio profile saved.", true);
} else {
showMsg($("audio-msg"), "Save failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg($("audio-msg"), "Save failed.", false); });
});
$("stereo-level").addEventListener("change", function () {
var level = parseInt($("stereo-level").value, 10);
postEnhance("/api/audio/stereo-enhance", level)
.then(function (res) {
showMsg($("audio-msg"), res.ok ? "Stereo overlay applied." : "Enhance failed.", res.ok);
});
});
$("bass-level").addEventListener("change", function () {
var level = parseInt($("bass-level").value, 10);
postEnhance("/api/audio/bass-enhance", level)
.then(function (res) {
showMsg($("audio-msg"), res.ok ? "Bass overlay applied." : "Enhance failed.", res.ok);
});
});
$("reset-audio").addEventListener("click", function () {
api("/api/audio/reset", { method: "POST" })
.then(function (res) {
if (res.ok && res.d.status === "saved") {
showMsg($("audio-msg"), "Factory-flat profile applied.", true);
loadAudioProfile();
} else {
showMsg($("audio-msg"), "Reset failed.", false);
}
})
.catch(function () { showMsg($("audio-msg"), "Reset failed.", false); });
});
$("bt-refresh").addEventListener("click", refreshBluetooth);
$("bt-pair").addEventListener("click", function () {
api("/api/bluetooth/pair", { method: "POST" })
.then(function (res) {
showMsg($("bt-msg"), res.ok ? "Discoverable — pair from your phone." : "Pair failed.", res.ok);
if (res.ok) refreshBluetooth();
});
});
$("bt-stop-pair").addEventListener("click", function () {
api("/api/bluetooth/pair/stop", { method: "POST" })
.then(function (res) {
showMsg($("bt-msg"), res.ok ? "Pairing stopped." : "Stop failed.", res.ok);
if (res.ok) refreshBluetooth();
});
});
$("bt-disconnect").addEventListener("click", function () {
api("/api/bluetooth/disconnect", { method: "POST" })
.then(function (res) {
showMsg($("bt-msg"), res.ok ? "A2DP disconnected." : "Disconnect failed.", res.ok);
if (res.ok) refreshBluetooth();
});
});
$("bt-load-paired").addEventListener("click", loadBluetoothPaired);
bindSlider("bt-auto-reconnect", "bt-auto-reconnect-out");
$("bt-save-reconnect").addEventListener("click", function () {
var level = parseInt($("bt-auto-reconnect").value, 10);
api("/api/bluetooth/auto-reconnect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ times: level })
}).then(function (res) {
showMsg($("bt-msg"), res.ok ? "Auto-reconnect saved." : "Save failed.", res.ok);
if (res.ok) refreshBluetooth();
});
});
$("ota-upload").addEventListener("click", function () {
var file = $("ota-file").files[0];
var msg = $("system-msg");
if (!file) {
showMsg(msg, "Choose a firmware .bin file.", false);
return;
}
showMsg(msg, "Uploading firmware…", true);
uploadBinary("/api/system/ota", file)
.then(function (res) {
if (res.ok && res.d.status === "stored") {
showMsg(msg, "Firmware stored. Rebooting in " + res.d.reboot_sec + "s…", true);
} else {
showMsg(msg, "OTA failed: " + (res.d.error || res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg(msg, "OTA upload failed.", false); });
});
$("dsp-upload").addEventListener("click", function () {
var file = $("dsp-file").files[0];
var msg = $("system-msg");
if (!file) {
showMsg(msg, "Choose a DRAD blob.", false);
return;
}
showMsg(msg, "Uploading DSP program…", true);
uploadBinary("/api/dsp/program", file)
.then(function (res) {
if (res.ok && res.d.status === "stored") {
showMsg(msg, "DSP program stored. Rebooting in " + res.d.reboot_sec + "s…", true);
} else {
showMsg(msg, "DSP upload failed: " + (res.d.error || res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg(msg, "DSP upload failed.", false); });
});
$("save-preset").addEventListener("click", function () {
var name = $("preset-name").value.trim();
if (!name) {
showMsg($("preset-msg"), "Enter a name.", false);
return;
}
var band = $("band").value;
api("/api/tuner/status")
.then(function (res) { return res.d; })
.then(function (status) {
return api("/api/stations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildPresetBody(name, band, status))
});
})
.then(function (res) {
if (res.ok && res.d.status === "saved") {
showMsg($("preset-msg"), "Preset saved.", true);
loadPresets();
} else {
showMsg($("preset-msg"), "Save failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg($("preset-msg"), "Save failed.", false); });
});
bindSlider("master-db", "master-db-out");
bindSlider("si4684-db", "si4684-db-out");
bindSlider("esp32-db", "esp32-db-out");
bindSlider("stereo-level", "stereo-level-out");
bindSlider("bass-level", "bass-level-out");
refreshTunerStatus();
refreshBluetooth();
loadPresets();
loadAudioProfile();
pollTimer = setInterval(refreshTunerStatus, 5000);
</script>
</body>
</html>