Files
DigiRadio/Software/components/net/www/index.html
T
micheleandClaude Sonnet 5 6f7b6dd12c Add internet radio streaming with runtime API, modernize web UI, remove auto-tune/beep at boot
Streaming (main feature this session):
- New WebRadioConfig/WebRadioJson core types, ISecureStore-backed persistence
- New webradio::WebRadioService (thread-safe live config) + GET/POST /api/streaming
- web_radio_stream task now runtime-toggleable (no reboot), no hardcoded URL
- Content-Type diagnostic: warns clearly when a URL is a webpage, not an audio stream

Boot cleanup:
- Removed boot-time auto FM/DAB tune, auto-beep, and the (now-concluded) Si4684
  crystal IBIAS/CTUN empirical sweep from main.cpp — tuning/beep are on-demand
  via the existing REST API only

Web UI:
- Modernized styling (cards, gradients, toggle switches, light/dark theme)
- New Stream tab wired to /api/streaming

Fixes found via real idf.py build (not just clangd):
- Restored wrongly-removed si4684/Si4684Tuner.hpp include in main.cpp
- Fixed MP3Decode() argument types in web_radio_stream.cpp (unsigned char**/int*)

Quality-gate fixes:
- Host-test stub headers (esp_log.h, freertos/*) so TunerService.cpp's
  scanForStation logging/pacing compiles for station_service_test /
  integration_service_test instead of running stale binaries
- Added WifiScanner and WebRadioService manual sections; filled in missing
  Doxygen docs on BluetoothService, i2s_sdata_probe, test_firmware, Bt1035At
- Ignore clangd's .cache/ index directory

Also includes prior uncommitted work carried in the tree: Wi-Fi/Bluetooth
device scan REST API and UI (WifiScanner, BT scan), SigmaStudio TCP bridge,
and the current ADAU1701 SigmaStudio DSP program export.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 21:15:22 +02:00

1774 lines
66 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: #ff9a3c;
--accent-2: #ff6a88;
--accent-dim: #c4701f;
--bg: #0b0c10;
--surface: #16181d;
--surface-2: #1e2128;
--surface-3: #262a33;
--border: #2a2e37;
--text: #f1f2f4;
--muted: #9aa0ac;
--ok: #6fd08c;
--err: #ff7a7a;
--radius: 0.85rem;
--radius-sm: 0.55rem;
--shadow: 0 1px 2px rgba(0,0,0,0.3), 0 8px 24px -12px rgba(0,0,0,0.5);
--font: "SF Pro Display", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
:root[data-theme="light"] {
--bg: #f3f2ef;
--surface: #ffffff;
--surface-2: #f6f5f2;
--surface-3: #ececea;
--border: #e4e2dd;
--text: #1c1c1e;
--muted: #6b6f76;
--shadow: 0 1px 2px rgba(20,20,20,0.05), 0 10px 24px -16px rgba(20,20,20,0.25);
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg: #f3f2ef;
--surface: #ffffff;
--surface-2: #f6f5f2;
--surface-3: #ececea;
--border: #e4e2dd;
--text: #1c1c1e;
--muted: #6b6f76;
--shadow: 0 1px 2px rgba(20,20,20,0.05), 0 10px 24px -16px rgba(20,20,20,0.25);
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
font-size: var(--text-base);
line-height: 1.5;
background:
radial-gradient(1200px 480px at 15% -10%, color-mix(in srgb, var(--accent) 14%, transparent), transparent),
var(--bg);
color: var(--text);
min-height: 100vh;
padding: var(--space-2);
-webkit-font-smoothing: antialiased;
}
.shell {
max-width: 42rem;
margin: 0 auto;
}
header {
background: linear-gradient(155deg, var(--surface) 0%, var(--surface-2) 100%);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-3);
margin-bottom: var(--space-2);
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
header::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
}
header h1 {
font-size: var(--text-xl);
font-weight: 700;
letter-spacing: -0.02em;
background: linear-gradient(90deg, var(--text), var(--text) 60%, var(--accent));
-webkit-background-clip: text;
background-clip: text;
}
.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.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
}
.chips { display: flex; flex-wrap: wrap; gap: var(--space-1); }
.chip {
font-size: 0.75rem;
font-weight: 500;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: var(--surface-3);
color: var(--muted);
border: 1px solid var(--border);
}
.chip.on { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, var(--border)); }
.chip.off { opacity: 0.5; }
nav.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-bottom: var(--space-2);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.3rem;
box-shadow: var(--shadow);
}
nav.tabs button {
flex: 1 1 auto;
min-width: 4rem;
padding: 0.55rem 0.6rem;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
font: inherit;
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
nav.tabs button:hover { color: var(--text); }
nav.tabs button.active {
color: var(--bg);
background: linear-gradient(135deg, var(--accent), var(--accent-2));
font-weight: 600;
box-shadow: 0 4px 12px -4px color-mix(in srgb, var(--accent) 60%, transparent);
}
.panel {
display: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-3);
box-shadow: var(--shadow);
animation: panel-in 0.18s ease-out;
}
@keyframes panel-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.panel.active { display: block; }
h2 {
font-size: var(--text-lg);
font-weight: 700;
letter-spacing: -0.01em;
margin-bottom: var(--space-1);
}
h3.subhead {
font-size: var(--text-base);
font-weight: 600;
margin: var(--space-3) 0 var(--space-1);
padding-top: var(--space-2);
border-top: 1px solid var(--border);
}
.lede {
color: var(--muted);
font-size: var(--text-sm);
margin-bottom: var(--space-2);
}
.hint {
color: var(--muted);
font-size: var(--text-sm);
margin-bottom: var(--space-1);
}
.bt-guide {
margin-bottom: var(--space-2);
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-2);
font-size: var(--text-sm);
}
.bt-guide summary { cursor: pointer; font-weight: 600; }
.bt-guide ol {
margin: var(--space-1) 0 0 1.25rem;
padding: 0;
}
.bt-guide li { margin-bottom: 0.35rem; }
label {
display: block;
font-size: var(--text-sm);
font-weight: 500;
color: var(--muted);
margin-bottom: 0.3rem;
}
input, select {
width: 100%;
padding: 0.65rem var(--space-2);
margin-bottom: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
font: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
input:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
input[type="file"] {
padding: var(--space-1);
font-size: var(--text-sm);
}
input[type="range"] { padding: 0; accent-color: var(--accent); }
input[type="checkbox"] {
width: auto;
margin: 0 0.4rem 0 0;
accent-color: var(--accent);
vertical-align: middle;
}
button.action {
width: 100%;
padding: 0.75rem var(--space-2);
border: none;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #1a1206;
font: inherit;
font-weight: 700;
cursor: pointer;
transition: transform 0.1s, box-shadow 0.15s, opacity 0.15s;
box-shadow: 0 4px 14px -6px color-mix(in srgb, var(--accent) 55%, transparent);
}
button.action:hover { transform: translateY(-1px); }
button.action:active { transform: translateY(0); }
button.action.secondary {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
box-shadow: none;
}
button.action.secondary:hover { border-color: var(--accent); }
button.action:disabled { opacity: 0.5; cursor: wait; transform: none; }
button.action.compact {
width: auto;
padding: 0.35rem 0.65rem;
font-size: var(--text-sm);
font-weight: 600;
box-shadow: none;
}
.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(--surface-3);
padding: 0.1em 0.4em;
border-radius: 0.3rem;
}
.now-title {
font-size: var(--text-2xl);
font-weight: 700;
letter-spacing: -0.02em;
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(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.6rem var(--space-2);
font-size: var(--text-sm);
font-weight: 600;
}
.stat span { display: block; color: var(--muted); font-size: 0.7rem; font-weight: 500; margin-bottom: 0.1rem; }
.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: 0.6rem 0;
border-bottom: 1px solid var(--border);
font-size: var(--text-sm);
}
ul.list li:last-child { border-bottom: none; }
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;
}
ul.pick-list {
list-style: none;
margin-bottom: var(--space-2);
max-height: 16rem;
overflow-y: auto;
}
ul.pick-list li.pick-item {
display: block;
padding: var(--space-2);
margin-bottom: var(--space-1);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-2);
cursor: pointer;
font-size: var(--text-sm);
transition: border-color 0.15s, background 0.15s;
}
ul.pick-list li.pick-item:hover {
border-color: var(--accent);
}
ul.pick-list li.pick-item.selected {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--surface-2));
box-shadow: inset 0 0 0 1px var(--accent);
}
ul.pick-list li.pick-item .pick-title {
font-weight: 600;
word-break: break-word;
}
ul.pick-list li.pick-item .pick-meta {
margin-top: 0.2rem;
color: var(--muted);
font-size: 0.75rem;
}
ul.pick-list li.pick-hint {
padding: var(--space-2);
color: var(--muted);
font-size: var(--text-sm);
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
text-align: center;
}
.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);
}
.switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.switch-row .switch-label { flex: 1; }
.switch-row .switch-label strong { display: block; color: var(--text); font-size: var(--text-base); font-weight: 600; }
.switch-row .switch-label span { display: block; color: var(--muted); font-size: var(--text-sm); margin-top: 0.15rem; }
.switch {
position: relative;
display: inline-block;
width: 3rem;
height: 1.75rem;
flex-shrink: 0;
}
.switch input {
opacity: 0;
width: 100%;
height: 100%;
margin: 0;
position: absolute;
inset: 0;
cursor: pointer;
z-index: 1;
}
.switch .track {
position: absolute;
inset: 0;
background: var(--surface-3);
border: 1px solid var(--border);
border-radius: 999px;
transition: background 0.2s;
}
.switch .thumb {
position: absolute;
top: 0.2rem;
left: 0.2rem;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--muted);
transition: transform 0.2s, background 0.2s;
}
.switch input:checked ~ .track {
background: color-mix(in srgb, var(--accent) 35%, var(--surface-3));
border-color: var(--accent);
}
.switch input:checked ~ .thumb {
transform: translateX(1.25rem);
background: linear-gradient(135deg, var(--accent), var(--accent-2));
}
</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="stream" role="tab">Stream</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>
<h3 class="subhead">Test — cerca stazione</h3>
<p class="hint">FM: seek automatico fino a trovare segnale (e nome RDS se indicato). DAB: prova gli ensemble.</p>
<label for="scan-name">Nome stazione (opzionale)</label>
<input id="scan-name" type="text" maxlength="32" placeholder="es. monte carlo">
<button type="button" class="action secondary" id="tuner-scan">Cerca stazione</button>
<p class="msg" id="scan-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-stream" class="panel" role="tabpanel" hidden>
<h2>Internet radio streaming</h2>
<p class="lede">Streams an HTTP MP3 URL, decodes it on the ESP32, and plays it through the ADAU1701 — independent of Si4684 FM/DAB. Takes effect immediately, no reboot needed.</p>
<div class="switch-row">
<div class="switch-label">
<strong>Streaming</strong>
<span id="stream-status-text">Loading…</span>
</div>
<label class="switch">
<input type="checkbox" id="stream-enabled">
<span class="track"></span>
<span class="thumb"></span>
</label>
</div>
<label for="stream-url">Stream URL (http://)</label>
<input id="stream-url" type="text" autocomplete="off" placeholder="http://example.com/stream.mp3">
<button type="button" class="action" id="stream-save">Save &amp; apply</button>
<p class="hint">No sample-rate conversion — best results with 48 kHz MP3 streams.</p>
<p class="msg" id="stream-msg" aria-live="polite"></p>
</section>
<section id="panel-bt" class="panel" role="tabpanel" hidden>
<h2>Bluetooth</h2>
<p class="lede">Soundbar senza display (Bose): <strong>salva il MAC</strong> e connetti con <code>AT+A2DPCONN</code> — lo scan spesso non la trova.</p>
<h3 class="subhead">Soundbar predefinita (NVS)</h3>
<p class="hint" id="bt-saved-speaker">Caricamento…</p>
<div class="row">
<button type="button" class="action secondary" id="bt-reconnect-saved">Reconnect saved</button>
<button type="button" class="action secondary" id="bt-clear-saved">Rimuovi salvata</button>
</div>
<label><input type="checkbox" id="bt-save-default" checked> Salva come soundbar predefinita dopo Connect</label>
<details class="bt-guide" open>
<summary>Bose Solo II — accoppiamento (solo telecomando)</summary>
<ol>
<li>Sul telecomando Bose: tieni premuto <strong>Bluetooth</strong> finché la spia Bluetooth <strong>lampeggia blu</strong> (non bianca).</li>
<li>Sul telefono nelle vicinanze: <strong>Bluetooth spento</strong> (evita che la Bose si connetta al phone).</li>
<li>Spegni o allontana altri dispositivi BT (proiettori, casse).</li>
<li>Avvia <strong>Scan</strong> qui sotto entro 10 secondi.</li>
<li>Se compare <strong>Bose Solo</strong> o <strong>(no name)</strong>, selezionala e premi <strong>Connect</strong>.</li>
<li>Se non compare in scan: inserisci il <strong>MAC</strong> sotto e premi <strong>Connect MAC</strong> (con Bose in blu lampeggiante).</li>
<li>Spunta <strong>Salva come predefinita</strong> — all'accensione il DigiRadio farà <code>A2DPCONN</code> automatico.</li>
</ol>
<p class="hint">La spia arancione a sinistra = Dialogue mode (audio), non centra con il Bluetooth. Discoverable sul DigiRadio <em>non</em> serve per la Bose (non può scegliere dispositivi senza schermo).</p>
</details>
<div class="stat-grid" id="bt-stats"></div>
<h3 class="subhead">Nearby devices</h3>
<ul class="pick-list" id="bt-scan-list"></ul>
<div class="row">
<button type="button" class="action" id="bt-scan">Scan</button>
<button type="button" class="action" id="bt-connect-selected" disabled>Connect</button>
</div>
<h3 class="subhead">Connessione manuale (MAC)</h3>
<p class="hint">12 caratteri hex, senza due punti — es. <code>A1B2C3D4E5F6</code></p>
<div class="row">
<input id="bt-mac-manual" type="text" autocomplete="off" maxlength="17"
placeholder="MAC Bluetooth soundbar" pattern="[0-9A-Fa-f:]{12,17}">
<input id="bt-speaker-name" type="text" autocomplete="off" maxlength="31"
placeholder="Nome (es. Bose Solo II)">
<button type="button" class="action secondary" id="bt-connect-mac">Connect MAC</button>
</div>
<h3 class="subhead">Paired devices</h3>
<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 secondary" id="bt-load-paired">Paired list</button>
<button type="button" class="action secondary" id="bt-refresh">Refresh</button>
<button type="button" class="action secondary" id="bt-pair">Discoverable</button>
<button type="button" class="action secondary" id="bt-stop-pair">Stop discoverable</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">Join the DigiRadio SoftAP and open <code>192.168.4.1</code>. Tap a network to select it, enter the password, then save.</p>
<h3 class="subhead">Nearby networks</h3>
<ul class="pick-list" id="wifi-scan-list"></ul>
<div class="row">
<button type="button" class="action" id="wifi-scan">Scan</button>
</div>
<h3 class="subhead">Selected network</h3>
<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>
<h3 class="subhead">ADAU1701 test tone</h3>
<p class="lede">Beep1 generator in the DSP program — bring-up aid, not saved to the audio profile.</p>
<button type="button" class="action secondary" id="beep-on">Start tone</button>
<button type="button" class="action secondary" id="beep-off">Stop tone</button>
<p class="msg" id="system-msg" aria-live="polite"></p>
</section>
</div>
<script>
var audioProfile = null;
var lastTunerStatus = null;
var pollTimer = null;
var selectedBtDevice = null;
var selectedWifiSsid = 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;
});
if (name === "wifi") {
loadWifiScan();
}
if (name === "stream") {
loadStreamingConfig();
}
}
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);
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 loadStreamingConfig() {
return api("/api/streaming")
.then(function (res) {
if (!res.ok) {
$("stream-status-text").textContent = "Unavailable.";
return;
}
$("stream-enabled").checked = !!res.d.enabled;
$("stream-url").value = res.d.url || "";
$("stream-status-text").textContent = res.d.enabled
? "Playing — audio replaces the DSP mixer's ESP32 input."
: "Off.";
})
.catch(function () {
$("stream-status-text").textContent = "Load failed.";
});
}
$("stream-save").addEventListener("click", function () {
var url = $("stream-url").value.trim();
var enabled = $("stream-enabled").checked;
if (enabled && url.indexOf("http://") !== 0) {
showMsg($("stream-msg"), "URL must start with http://", false);
return;
}
api("/api/streaming", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: enabled, url: url })
})
.then(function (res) {
if (res.ok) {
showMsg($("stream-msg"), "Saved and applied.", true);
loadStreamingConfig();
} else {
showMsg($("stream-msg"), "Save failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg($("stream-msg"), "Save request failed.", false); });
});
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);
})
.then(function () { return loadSavedSpeaker(); });
}
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 () {
$("band").value = s.band;
syncBandPanels();
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); });
}
function showPickHint(listEl, text) {
listEl.innerHTML = "";
var li = document.createElement("li");
li.className = "pick-hint";
li.textContent = text;
listEl.appendChild(li);
}
function markPickSelected(listEl, keyAttr, keyValue) {
listEl.querySelectorAll(".pick-item").forEach(function (item) {
item.classList.toggle("selected", item.getAttribute(keyAttr) === keyValue);
});
}
function updateBtConnectButton() {
var btn = $("bt-connect-selected");
btn.disabled = !selectedBtDevice;
btn.textContent = selectedBtDevice
? "Connect · " + (selectedBtDevice.name || selectedBtDevice.mac)
: "Connect";
}
function normalizeBtMac(raw) {
return String(raw || "").replace(/[^0-9A-Fa-f]/g, "").toUpperCase();
}
function loadSavedSpeaker() {
return api("/api/bluetooth/speaker")
.then(function (res) {
var el = $("bt-saved-speaker");
if (!res.ok || !res.d.configured) {
el.textContent = "Nessuna soundbar salvata — usa Connect MAC o Scan + Salva.";
return;
}
el.textContent = (res.d.name || "(no name)") + " · " + res.d.mac;
if (!$("bt-mac-manual").value) {
$("bt-mac-manual").value = res.d.mac;
}
})
.catch(function () {
$("bt-saved-speaker").textContent = "Impossibile leggere soundbar salvata.";
});
}
function connectBluetooth(mac, name) {
var normalized = normalizeBtMac(mac);
if (normalized.length !== 12) {
showMsg($("bt-msg"), "MAC non valido — servono 12 caratteri hex.", false);
return Promise.resolve();
}
var save = $("bt-save-default").checked;
var label = name || $("bt-speaker-name").value || normalized;
showMsg($("bt-msg"), "Connecting to " + label + "… (~30 s)", true);
return api("/api/bluetooth/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mac: normalized, name: label, save: save })
})
.then(function (res) {
if (res.ok) {
showMsg($("bt-msg"), "Connesso e " + (save ? "salvato in NVS." : "OK."), true);
loadSavedSpeaker();
return refreshBluetooth();
}
showMsg($("bt-msg"), "Connect failed: " + (res.d.reason || "error"), false);
})
.catch(function () { showMsg($("bt-msg"), "Connect request failed.", false); });
}
function loadBluetoothScan() {
var btn = $("bt-scan");
btn.disabled = true;
selectedBtDevice = null;
updateBtConnectButton();
showMsg($("bt-msg"), "Scanning classic Bluetooth (~45 s) — speaker must be in pairing mode now.", true);
console.log("[bt] scan start");
return api("/api/bluetooth/scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seconds: 45 })
})
.then(function (res) {
console.log("[bt] scan response", res.status, res.d);
var list = $("bt-scan-list");
list.innerHTML = "";
if (!res.ok || !res.d.devices) {
showPickHint(list, "Scan failed — press Scan to retry.");
showMsg($("bt-msg"), "Scan failed: " + (res.d.reason || "error"), false);
return;
}
if (res.d.devices.length === 0) {
showPickHint(list, "No devices found — put the speaker in pairing mode (Bose: Bluetooth button until blue blink), then scan again.");
showMsg($("bt-msg"), "No devices found.", false);
return;
}
res.d.devices.forEach(function (device) {
var li = document.createElement("li");
li.className = "pick-item";
li.setAttribute("data-mac", device.mac);
li.setAttribute("role", "button");
li.setAttribute("tabindex", "0");
var title = document.createElement("div");
title.className = "pick-title";
title.textContent = device.name || "(no name)";
var meta = document.createElement("div");
meta.className = "pick-meta";
var rssi = device.rssi_dbm != null ? device.rssi_dbm + " dBm · " : "";
meta.textContent = rssi + device.mac;
li.appendChild(title);
li.appendChild(meta);
function selectDevice() {
selectedBtDevice = device;
markPickSelected(list, "data-mac", device.mac);
updateBtConnectButton();
showMsg($("bt-msg"), "Selected \"" + (device.name || device.mac) + "\" — press Connect.", true);
}
li.addEventListener("click", selectDevice);
li.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectDevice();
}
});
list.appendChild(li);
});
showMsg($("bt-msg"), res.d.devices.length + " device(s) — tap one to select.", true);
})
.catch(function () {
showPickHint($("bt-scan-list"), "Scan request failed — press Scan to retry.");
showMsg($("bt-msg"), "Scan request failed.", false);
})
.finally(function () { btn.disabled = false; });
}
function refreshHealth() {
return api("/api/health")
.then(function (res) {
if (res.ok && res.d.status === "ok") {
var serial = res.d.serialNumber || "unknown";
var ipLine = res.d.ip ? ' · IP <code>' + res.d.ip + "</code>" : "";
$("health-text").innerHTML =
'Status <code>' + res.d.status + "</code> · FW <code>" + res.d.fw +
"</code> · SN <code>" + serial + "</code>" + ipLine;
renderChips(res.d.chips);
} else {
$("health-text").textContent = "Health check failed — retrying…";
}
})
.catch(function () {
$("health-text").textContent = "Health check failed — retrying…";
});
}
refreshHealth()
.then(function () { return refreshTunerStatus(); })
.then(function () { return refreshBluetooth(); })
.then(function () { return loadPresets(); })
.then(function () { return loadAudioProfile(); })
.then(function () { return loadStreamingConfig(); });
setInterval(refreshHealth, 5000);
pollTimer = setInterval(function () { refreshTunerStatus(); }, 5000);
function loadWifiScan() {
var btn = $("wifi-scan");
btn.disabled = true;
showMsg($("wifi-msg"), "Scanning nearby networks (~5 s)…", true);
return api("/api/wifi/scan", { method: "POST" })
.then(function (res) {
var list = $("wifi-scan-list");
list.innerHTML = "";
if (!res.ok || !res.d.networks) {
showPickHint(list, "Scan failed — press Scan to retry.");
showMsg($("wifi-msg"), "Scan failed: " + (res.d.reason || "error"), false);
return;
}
if (res.d.networks.length === 0) {
showPickHint(list, "No networks found — press Scan to retry.");
showMsg($("wifi-msg"), "No networks found.", false);
return;
}
res.d.networks.forEach(function (network) {
var li = document.createElement("li");
li.className = "pick-item";
li.setAttribute("data-ssid", network.ssid);
li.setAttribute("role", "button");
li.setAttribute("tabindex", "0");
var title = document.createElement("div");
title.className = "pick-title";
title.textContent = network.ssid;
var meta = document.createElement("div");
meta.className = "pick-meta";
var rssi = network.rssi_dbm != null ? network.rssi_dbm + " dBm · " : "";
var auth = network.auth || "unknown";
var channel = network.channel != null ? " · ch " + network.channel : "";
meta.textContent = rssi + auth + channel;
li.appendChild(title);
li.appendChild(meta);
function selectNetwork() {
selectedWifiSsid = network.ssid;
$("ssid").value = network.ssid;
markPickSelected(list, "data-ssid", network.ssid);
$("password").focus();
showMsg($("wifi-msg"), "Selected \"" + network.ssid + "\" — enter password and save.", true);
}
li.addEventListener("click", selectNetwork);
li.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectNetwork();
}
});
list.appendChild(li);
});
showMsg($("wifi-msg"), res.d.networks.length + " network(s) — tap one to select.", true);
})
.catch(function () {
showPickHint($("wifi-scan-list"), "Scan request failed — press Scan to retry.");
showMsg($("wifi-msg"), "Scan request failed.", false);
})
.finally(function () { btn.disabled = false; });
}
showPickHint($("wifi-scan-list"), "Press Scan or open this tab to search networks.");
showPickHint($("bt-scan-list"), "Press Scan to search Bluetooth devices.");
$("wifi-scan").addEventListener("click", loadWifiScan);
$("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") {
var ssid = $("ssid").value;
showMsg(msg,
"Saved. DigiRadio reboots in " + res.d.reboot_in_sec +
"s — then connect THIS phone/PC to WiFi \"" + ssid +
"\" and open http://digiradio-CC4DB4.local/ (or the IP in serial log).",
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", function () { 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"); });
$("tuner-scan").addEventListener("click", function () {
var btn = $("tuner-scan");
var band = $("band").value;
var name = $("scan-name").value.trim();
btn.disabled = true;
showMsg($("scan-msg"),
band === "fm"
? "Scansione FM in corso (~3060 s)…"
: "Scansione DAB in corso (~12 min)…",
true);
var body = { band: band, max_steps: band === "fm" ? 45 : 38 };
if (name) body.name = name;
api("/api/tuner/scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
})
.then(function (res) {
btn.disabled = false;
if (!res.ok) {
showMsg($("scan-msg"), "Scan failed: " + (res.d.reason || "error"), false);
return;
}
if (res.d.status === "found") {
var label = res.d.station_name || "(no name)";
if (band === "fm" && res.d.frequency_khz) {
$("fm-khz").value = res.d.frequency_khz;
showMsg($("scan-msg"),
"Trovata: " + label + " · " + res.d.frequency_khz + " kHz (" + res.d.steps + " step)",
true);
} else if (band === "dab" && res.d.freq_index != null) {
$("dab-index").value = res.d.freq_index;
showMsg($("scan-msg"),
"Trovata: " + label + " · ensemble " + res.d.freq_index + " (" + res.d.steps + " step)",
true);
} else {
showMsg($("scan-msg"), "Trovata: " + label, true);
}
refreshTunerStatus();
setTab("now");
return;
}
showMsg($("scan-msg"),
"Nessuna stazione trovata dopo " + (res.d.steps || "?") + " tentativi.",
false);
})
.catch(function () {
btn.disabled = false;
showMsg($("scan-msg"), "Scan request failed.", false);
});
});
$("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-scan").addEventListener("click", loadBluetoothScan);
$("bt-connect-selected").addEventListener("click", function () {
if (!selectedBtDevice) return;
connectBluetooth(selectedBtDevice.mac, selectedBtDevice.name);
});
$("bt-connect-mac").addEventListener("click", function () {
connectBluetooth($("bt-mac-manual").value, $("bt-speaker-name").value);
});
$("bt-reconnect-saved").addEventListener("click", function () {
showMsg($("bt-msg"), "Reconnect saved speaker…", true);
api("/api/bluetooth/reconnect", { method: "POST" })
.then(function (res) {
showMsg($("bt-msg"), res.ok ? "Reconnect OK." : ("Failed: " + (res.d.reason || "error")), res.ok);
if (res.ok) refreshBluetooth();
})
.catch(function () { showMsg($("bt-msg"), "Reconnect failed.", false); });
});
$("bt-clear-saved").addEventListener("click", function () {
api("/api/bluetooth/speaker", { method: "DELETE" })
.then(function (res) {
showMsg($("bt-msg"), res.ok ? "Soundbar salvata rimossa." : "Clear failed.", res.ok);
loadSavedSpeaker();
});
});
$("bt-pair").addEventListener("click", function () {
api("/api/bluetooth/pair", { method: "POST" })
.then(function (res) {
showMsg($("bt-msg"), res.ok
? "Discoverable attivo — utile solo se un altro dispositivo deve trovare il DigiRadio (non per Bose)."
: "Discoverable mode 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 ? "Discoverable mode 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); });
});
function postBeep(enabled) {
var msg = $("system-msg");
return api("/api/audio/beep", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: enabled })
}).then(function (res) {
if (res.ok) {
showMsg(msg, enabled ? "Tone started." : "Tone stopped.", true);
} else {
showMsg(msg, "Beep failed: " + (res.d.error || res.d.reason || "unknown"), false);
}
}).catch(function () { showMsg(msg, "Beep request failed.", false); });
}
$("beep-on").addEventListener("click", function () { postBeep(true); });
$("beep-off").addEventListener("click", function () { postBeep(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");
</script>
</body>
</html>