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>
1138 lines
40 KiB
HTML
1138 lines
40 KiB
HTML
<!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">Wi‑Fi</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 (0–37)</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 (1–20, 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>Wi‑Fi</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 & 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>
|