Release fw 0.8.4: doc sync, System UI, BT/FM polish.
Align README, manual, and backlog to 0.8.4; add Web UI System tab for OTA/DSP uploads, serial in health header, FM seek down, and BT1035 paired list plus auto-reconnect API. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -60,7 +60,7 @@ namespace net {
|
||||
|
||||
namespace {
|
||||
constexpr char kTag[] = "SetupWebServer";
|
||||
constexpr char kFirmwareVersion[] = "0.8.3";
|
||||
constexpr char kFirmwareVersion[] = "0.8.4";
|
||||
constexpr unsigned kRebootDelaySec = 3;
|
||||
|
||||
extern const uint8_t www_index_html_gz_start[] asm(
|
||||
@@ -402,12 +402,12 @@ esp_err_t tunerPlayPostHandler(httpd_req_t* req)
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief tunerSeekPostHandler — accept POST /api/tuner/seek (FM up).
|
||||
* @brief tunerSeekPostHandler — accept POST /api/tuner/seek (FM up/down).
|
||||
*
|
||||
* @dname tunerSeekPostHandler
|
||||
* @param req HTTP request handle from esp_http_server.
|
||||
* @return ESP_OK on success, or an esp_err_t error code.
|
||||
* @pubstate uses route context tuner service with SeekDirection::Up.
|
||||
* @pubstate uses route context tuner service with parsed SeekDirection.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
@@ -420,7 +420,18 @@ esp_err_t tunerSeekPostHandler(httpd_req_t* req)
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
|
||||
auto freq = ctx->tuner->seekFm(core::SeekDirection::Up);
|
||||
std::array<char, 128> body{};
|
||||
readRequestBody(req, body);
|
||||
const auto direction = core::parseTunerSeekJson(std::string_view(body.data()));
|
||||
if (!direction) {
|
||||
const std::string json =
|
||||
core::serializeTunerErrorJson(parseErrorToken(direction.error()));
|
||||
httpd_resp_set_status(req, "400 Bad Request");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
auto freq = ctx->tuner->seekFm(*direction);
|
||||
if (freq) {
|
||||
const std::string json = std::string("{\"frequency_khz\":")
|
||||
+ std::to_string(freq->value()) + "}";
|
||||
@@ -895,6 +906,58 @@ esp_err_t bluetoothDisconnectPostHandler(httpd_req_t* req)
|
||||
return httpd_resp_send(req, "{\"status\":\"disconnected\"}", 24);
|
||||
}
|
||||
|
||||
esp_err_t bluetoothPairedGetHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->bluetooth == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
auto devices = ctx->bluetooth->listPaired();
|
||||
if (!devices) {
|
||||
const std::string json =
|
||||
core::serializeBluetoothErrorJson(bt1035ErrorToken(devices.error()));
|
||||
httpd_resp_set_status(req, "500 Internal Server Error");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
const std::string json = core::serializeBluetoothPairedJson(*devices);
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
esp_err_t bluetoothAutoReconnectPostHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->bluetooth == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
|
||||
std::array<char, 128> body{};
|
||||
readRequestBody(req, body);
|
||||
const auto times =
|
||||
core::parseBluetoothAutoReconnectJson(std::string_view(body.data()));
|
||||
if (!times) {
|
||||
const std::string json =
|
||||
core::serializeBluetoothErrorJson(parseErrorToken(times.error()));
|
||||
httpd_resp_set_status(req, "400 Bad Request");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
if (auto result = ctx->bluetooth->setAutoReconnect(*times); !result) {
|
||||
const std::string json =
|
||||
core::serializeBluetoothErrorJson(bt1035ErrorToken(result.error()));
|
||||
httpd_resp_set_status(req, "500 Internal Server Error");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, "{\"status\":\"saved\"}", 18);
|
||||
}
|
||||
|
||||
esp_err_t stationsGetHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
@@ -1313,6 +1376,22 @@ std::expected<void, NetError> SetupWebServer::start(
|
||||
};
|
||||
httpd_register_uri_handler(server_, &bluetoothDisconnectUri);
|
||||
|
||||
const httpd_uri_t bluetoothPairedUri = {
|
||||
.uri = "/api/bluetooth/paired",
|
||||
.method = HTTP_GET,
|
||||
.handler = bluetoothPairedGetHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &bluetoothPairedUri);
|
||||
|
||||
const httpd_uri_t bluetoothAutoReconnectUri = {
|
||||
.uri = "/api/bluetooth/auto-reconnect",
|
||||
.method = HTTP_POST,
|
||||
.handler = bluetoothAutoReconnectPostHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &bluetoothAutoReconnectUri);
|
||||
|
||||
const httpd_uri_t stationsGetUri = {
|
||||
.uri = "/api/stations",
|
||||
.method = HTTP_GET,
|
||||
|
||||
@@ -131,6 +131,10 @@
|
||||
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%;
|
||||
@@ -270,6 +274,7 @@
|
||||
<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">
|
||||
@@ -305,7 +310,8 @@
|
||||
<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">Seek up</button>
|
||||
<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>
|
||||
@@ -364,13 +370,23 @@
|
||||
<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>
|
||||
@@ -387,6 +403,18 @@
|
||||
</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>
|
||||
@@ -412,12 +440,53 @@
|
||||
function api(path, opts) {
|
||||
opts = opts || {};
|
||||
return fetch(path, opts).then(function (r) {
|
||||
return r.json().then(function (d) {
|
||||
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;
|
||||
@@ -621,13 +690,19 @@
|
||||
}
|
||||
var rows = [
|
||||
["Booted", s.booted ? "yes" : "no"],
|
||||
["Name", s.device_name || "—"],
|
||||
["Pairing", s.pairing ? "yes" : "no"],
|
||||
["A2DP", s.a2dp || "—"]
|
||||
["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() {
|
||||
@@ -740,11 +815,36 @@
|
||||
});
|
||||
}
|
||||
|
||||
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>";
|
||||
'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.";
|
||||
@@ -820,19 +920,8 @@
|
||||
.catch(function () { showMsg($("tuner-msg"), "Tune failed.", false); });
|
||||
});
|
||||
|
||||
$("seek-fm").addEventListener("click", function () {
|
||||
api("/api/tuner/seek", { method: "POST" })
|
||||
.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 || "unknown"), false);
|
||||
}
|
||||
})
|
||||
.catch(function () { showMsg($("tuner-msg"), "Seek 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");
|
||||
@@ -952,6 +1041,57 @@
|
||||
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();
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user