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>
This commit is contained in:
2026-08-08 21:15:22 +02:00
co-authored by Claude Sonnet 5
parent 8a8523515d
commit 6f7b6dd12c
131 changed files with 20309 additions and 1702 deletions
+3 -1
View File
@@ -4,10 +4,12 @@ idf_component_register(
"src/SoftApHost.cpp"
"src/StaClient.cpp"
"src/SetupWebServer.cpp"
"src/SigmaStudioTcpServer.cpp"
"src/WifiScanner.cpp"
"src/NetBootstrap.cpp"
INCLUDE_DIRS "include"
EMBED_FILES "www/index.html.gz"
REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server mdns secure_store tuner audio bluetooth station integration ota bt1035 adau1701
REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server mdns lwip secure_store tuner audio bluetooth station integration ota bt1035 adau1701 webradio
)
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
@@ -23,6 +23,7 @@
#include "net/NetError.hpp"
#include "net/NetState.hpp"
#include "net/SetupWebServer.hpp"
#include "net/SigmaStudioTcpServer.hpp"
#include "net/SoftApHost.hpp"
#include "net/StaClient.hpp"
@@ -53,6 +54,10 @@ namespace tuner {
class TunerService;
} // namespace tuner
namespace webradio {
class WebRadioService;
} // namespace webradio
namespace net {
/**
@@ -60,8 +65,9 @@ namespace net {
*
* @dname NetBootstrap
* @return n/a (type)
* @pubstate Owns optional softAp_, optional sta_, and webServer_. Must
* outlive app_main; keep one instance alive for process lifetime.
* @pubstate Owns optional softAp_, optional sta_, webServer_, and
* sigmaStudio_. Must outlive app_main; keep one instance alive
* for process lifetime.
*
* @author Michele Bigi
* @date 2026-07-06
@@ -79,6 +85,7 @@ public:
* @param stations Station preset service for REST routes.
* @param integration Application orchestration for preset recall.
* @param ota Firmware OTA service for POST /api/system/ota.
* @param webRadio Streaming config for GET/POST /api/streaming.
* @param companionChips Boot flags exposed on GET /api/health.
* @param deviceIdentity EEPROM-derived SSID, hostname, and serial.
* @return NetBootstrap on success, or a NetError.
@@ -93,6 +100,7 @@ public:
station::StationService& stations,
integration::IntegrationService& integration,
ota::OtaService& ota,
webradio::WebRadioService& webRadio,
core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity);
@@ -106,12 +114,14 @@ public:
* @param softAp Optional SoftAP mode host.
* @param sta Optional STA client instance.
* @param webServer HTTP server instance.
* @param sigmaStudio SigmaStudio TCP:8086 bridge instance.
* @param state Initial network state.
* @pubstate Transfers ownership of optional network resources.
*/
NetBootstrap(std::optional<SoftApHost> softAp,
std::optional<StaClient> sta,
SetupWebServer webServer,
SigmaStudioTcpServer sigmaStudio,
NetState state);
/**
@@ -119,7 +129,7 @@ public:
*
* @dname NetBootstrap
* @param other Source instance; left empty after the move.
* @pubstate transfers softAp_, sta_, and webServer_ from other.
* @pubstate transfers softAp_, sta_, webServer_, and sigmaStudio_ from other.
*
* @author Michele Bigi
* @date 2026-07-06
@@ -132,7 +142,7 @@ public:
* @dname operator=
* @param other Source instance; left empty after the move.
* @return Reference to this instance.
* @pubstate transfers softAp_, sta_, and webServer_ from other.
* @pubstate transfers softAp_, sta_, webServer_, and sigmaStudio_ from other.
*
* @author Michele Bigi
* @date 2026-07-06
@@ -143,7 +153,7 @@ public:
* @brief ~NetBootstrap — tear down network subsystems.
*
* @dname ~NetBootstrap
* @pubstate destroys softAp_, sta_, and webServer_.
* @pubstate destroys softAp_, sta_, webServer_, and sigmaStudio_.
*
* @author Michele Bigi
* @date 2026-07-06
@@ -166,6 +176,7 @@ private:
std::optional<SoftApHost> softAp_;
std::optional<StaClient> sta_;
SetupWebServer webServer_;
SigmaStudioTcpServer sigmaStudio_;
NetState state_;
};
@@ -37,10 +37,12 @@ enum class NetError {
WifiConfigFailed,
WifiStartFailed,
HttpServerStartFailed,
TcpServerStartFailed,
StaConnectTimeout,
StaConnectFailed,
StoreSaveFailed,
CredentialsNotFound,
WifiScanFailed,
};
} // namespace net
@@ -50,6 +50,10 @@ namespace tuner {
class TunerService;
} // namespace tuner
namespace webradio {
class WebRadioService;
} // namespace webradio
struct httpd_handle;
namespace net {
@@ -73,6 +77,7 @@ struct HttpRouteContext {
station::StationService* stations; ///< Preset list REST routes.
integration::IntegrationService* integration; ///< Preset recall orchestration.
ota::OtaService* ota; ///< Firmware OTA streaming.
webradio::WebRadioService* webRadio; ///< Streaming config REST routes.
core::CompanionChipStatus companionChips; ///< Boot flags for /api/health.
core::DeviceIdentity deviceIdentity; ///< EEPROM-derived unit identity.
};
@@ -152,6 +157,7 @@ public:
* @param stations Station preset service for list REST routes.
* @param integration Application orchestration for preset recall.
* @param ota Firmware OTA service for POST /api/system/ota.
* @param webRadio Streaming config for GET/POST /api/streaming.
* @param companionChips Boot flags for GET /api/health.
* @param deviceIdentity Unit identity for /api/health serialNumber.
* @return Ok on success, or NetError::HttpServerStartFailed.
@@ -167,6 +173,7 @@ public:
station::StationService& stations,
integration::IntegrationService& integration,
ota::OtaService& ota,
webradio::WebRadioService& webRadio,
core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity);
@@ -179,7 +186,6 @@ private:
bluetooth::BluetoothService* bluetooth_;
station::StationService* stations_;
integration::IntegrationService* integration_;
HttpRouteContext routeContext_;
};
} // namespace net
@@ -0,0 +1,133 @@
/**
* @file SigmaStudioTcpServer.hpp
* @brief TCP bridge exposing the ADAU1701 to SigmaStudio's Remote Connection.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Implements the subset of SigmaStudio's TCPi wire protocol needed for
* Connect, Link Compile Download, register read/write, and runtime
* safeload parameter updates, ported from the ADAU1701-TCPi-ESP32
* reference project (https://github.com/rarranzb/ADAU1701-TCPi-ESP32,
* MIT) onto DigiRadio's existing I2C plumbing
* (components/drivers/adau1701/src/SigmaStudioFW.c). A completed Link
* Compile Download is persisted to the `dsp` flash partition via
* adau1701::FlashDspProgramSource::storeBlob(), so it becomes the
* program DigiRadio boots with next time — same mechanism as
* POST /api/dsp/program.
*
* @author Michele Bigi
* @date 2026-08-07
*/
#pragma once
#include "net/NetError.hpp"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <expected>
namespace net {
/**
* @brief SigmaStudioTcpServer — port 8086 bridge to the ADAU1701.
*
* @dname SigmaStudioTcpServer
* @return n/a (type)
* @pubstate Owns a listening socket and a FreeRTOS task for the process
* lifetime once started(); stop()/destructor tear both down.
* Requires adau1701::Adau1701Driver::boot() to have already
* bound the I2C device handle via sigma_studio_set_device().
* Only one instance may be started at a time: the accept task
* reads the listen fd from a process-lifetime singleton (see
* activeListenFd() in the .cpp) rather than a captured `this`,
* since instances are constructed as locals and move-relocated
* into NetBootstrap — same rationale as SetupWebServer's
* routeContextStorage().
*
* @author Michele Bigi
* @date 2026-08-07
*/
class SigmaStudioTcpServer {
public:
/**
* @brief SigmaStudioTcpServer — construct an unstarted server.
*
* @dname SigmaStudioTcpServer
* @pubstate clears listenFd_ and task_.
*
* @author Michele Bigi
* @date 2026-08-07
*/
SigmaStudioTcpServer();
/**
* @brief ~SigmaStudioTcpServer — stop the server if running.
*
* @dname ~SigmaStudioTcpServer
* @pubstate deletes task_ and closes listenFd_ when started.
*
* @author Michele Bigi
* @date 2026-08-07
*/
~SigmaStudioTcpServer();
SigmaStudioTcpServer(const SigmaStudioTcpServer&) = delete;
SigmaStudioTcpServer& operator=(const SigmaStudioTcpServer&) = delete;
/**
* @brief SigmaStudioTcpServer — move-construct, transferring ownership.
*
* @dname SigmaStudioTcpServer
* @param other Source server; left stopped after the move.
* @pubstate takes ownership of other.listenFd_ and other.task_.
*
* @author Michele Bigi
* @date 2026-08-07
*/
SigmaStudioTcpServer(SigmaStudioTcpServer&& other) noexcept;
/**
* @brief operator= — move-assign, transferring ownership.
*
* @dname operator=
* @param other Source server; left stopped after the move.
* @return Reference to this instance.
* @pubstate takes ownership of other.listenFd_ and other.task_.
*
* @author Michele Bigi
* @date 2026-08-07
*/
SigmaStudioTcpServer& operator=(SigmaStudioTcpServer&& other) noexcept;
/**
* @brief start — open the listening socket and spawn the server task.
*
* @dname start
* @return Ok on success, or NetError::TcpServerStartFailed.
* @pubstate writes listenFd_ and task_ on success.
*
* @author Michele Bigi
* @date 2026-08-07
*/
[[nodiscard]] std::expected<void, NetError> start();
private:
void stop() noexcept;
/** @brief FreeRTOS task entry: accepts and serves one client at a time. */
static void acceptLoopTask(void* arg);
int listenFd_;
TaskHandle_t task_;
};
} // namespace net
@@ -20,6 +20,8 @@
#include "core/WifiCredentials.hpp"
#include "net/NetError.hpp"
#include "esp_event.h"
#include <expected>
#include <string_view>
@@ -107,6 +109,8 @@ public:
private:
bool connected_;
esp_event_handler_instance_t wifiHandler_;
esp_event_handler_instance_t ipHandler_;
};
} // namespace net
@@ -0,0 +1,50 @@
/**
* @file WifiScanner.hpp
* @brief Blocking Wi-Fi scan wrapper for the setup web UI.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-08-05
*/
#pragma once
#include "core/WifiScannedNetwork.hpp"
#include "net/NetError.hpp"
#include <expected>
#include <vector>
namespace net {
/**
* @brief WifiScanner — runs esp_wifi scan and maps results to core DTOs.
*
* @dname WifiScanner
* @return n/a (type)
* @pubstate Stateless shell helper; assumes esp_wifi_init() already ran.
*
* @author Michele Bigi
* @date 2026-08-05
*/
class WifiScanner {
public:
/**
* @brief scanNearby — list visible access points sorted by RSSI.
*
* @dname scanNearby
* @return Deduped networks on success, or NetError::WifiScanFailed.
* @pubstate Temporarily switches AP-only mode to APSTA when required.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] static std::expected<std::vector<core::WifiScannedNetwork>,
NetError>
scanNearby();
};
} // namespace net
+26 -7
View File
@@ -27,6 +27,7 @@
#include "secure_store/NvsPlatformInit.hpp"
#include "station/StationService.hpp"
#include "tuner/TunerService.hpp"
#include "webradio/WebRadioService.hpp"
namespace net {
@@ -102,10 +103,12 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner,
station::StationService& stations,
integration::IntegrationService& integration,
ota::OtaService& ota,
webradio::WebRadioService& webRadio,
core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity)
{
esp_netif_create_default_wifi_ap();
esp_netif_create_default_wifi_sta();
SoftApHost softAp(SoftApConfig::forSsid(deviceIdentity.softApSsid()));
if (auto apResult = softAp.start(); !apResult) {
@@ -115,17 +118,23 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner,
SetupWebServer webServer;
if (auto webResult =
webServer.start(store, NetState::SoftApSetup, tuner, audio,
bluetooth, stations, integration, ota,
bluetooth, stations, integration, ota, webRadio,
companionChips, deviceIdentity);
!webResult) {
return std::unexpected(webResult.error());
}
SigmaStudioTcpServer sigmaStudio;
if (auto sigmaResult = sigmaStudio.start(); !sigmaResult) {
ESP_LOGW(kTag, "SigmaStudio TCP bridge failed to start — continuing "
"without it");
}
ESP_LOGI(kTag, "setup mode ready — SSID %.*s",
static_cast<int>(deviceIdentity.softApSsid().size()),
deviceIdentity.softApSsid().data());
return NetBootstrap(std::move(softAp), std::nullopt, std::move(webServer),
NetState::SoftApSetup);
std::move(sigmaStudio), NetState::SoftApSetup);
}
/**
@@ -146,6 +155,7 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner,
station::StationService& stations,
integration::IntegrationService& integration,
ota::OtaService& ota,
webradio::WebRadioService& webRadio,
core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity)
{
@@ -175,17 +185,23 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner,
SetupWebServer webServer;
if (auto webResult =
webServer.start(store, NetState::StaConnected, tuner, audio,
bluetooth, stations, integration, ota,
bluetooth, stations, integration, ota, webRadio,
companionChips, deviceIdentity);
!webResult) {
return std::unexpected(webResult.error());
}
SigmaStudioTcpServer sigmaStudio;
if (auto sigmaResult = sigmaStudio.start(); !sigmaResult) {
ESP_LOGW(kTag, "SigmaStudio TCP bridge failed to start — continuing "
"without it");
}
ESP_LOGI(kTag, "STA mode ready — hostname %.*s.local",
static_cast<int>(deviceIdentity.hostname().size()),
deviceIdentity.hostname().data());
return NetBootstrap(std::nullopt, std::move(sta), std::move(webServer),
NetState::StaConnected);
std::move(sigmaStudio), NetState::StaConnected);
}
} // namespace
@@ -197,6 +213,7 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner,
station::StationService& stations,
integration::IntegrationService& integration,
ota::OtaService& ota,
webradio::WebRadioService& webRadio,
core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity)
{
@@ -210,8 +227,8 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner,
if (store.hasWifiCredentials()) {
auto staResult = startStaMode(store, tuner, audio, bluetooth, stations,
integration, ota, companionChips,
deviceIdentity);
integration, ota, webRadio,
companionChips, deviceIdentity);
if (staResult) {
return staResult;
}
@@ -223,16 +240,18 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner,
}
return startSetupMode(store, tuner, audio, bluetooth, stations, integration,
ota, companionChips, deviceIdentity);
ota, webRadio, companionChips, deviceIdentity);
}
NetBootstrap::NetBootstrap(std::optional<SoftApHost> softAp,
std::optional<StaClient> sta,
SetupWebServer webServer,
SigmaStudioTcpServer sigmaStudio,
NetState state)
: softAp_(std::move(softAp))
, sta_(std::move(sta))
, webServer_(std::move(webServer))
, sigmaStudio_(std::move(sigmaStudio))
, state_(state)
{
}
+606 -44
View File
@@ -21,11 +21,9 @@
#include "core/AudioProfile.hpp"
#include "core/AudioProfileJson.hpp"
#include "core/BluetoothJson.hpp"
#include "core/Bt1035ScannedDevice.hpp"
#include "core/CompanionChipStatus.hpp"
#include "core/DspProgramBlob.hpp"
#include "core/FirmwareVersion.hpp"
#include "core/HealthStatus.hpp"
#include "core/HealthStatusJson.hpp"
#include "core/IntegrationError.hpp"
#include "core/ParseError.hpp"
#include "core/SeekDirection.hpp"
@@ -33,6 +31,8 @@
#include "core/StoreError.hpp"
#include "core/TunerJson.hpp"
#include "core/WifiProvisionJson.hpp"
#include "core/WifiScanJson.hpp"
#include "net/WifiScanner.hpp"
#include "tuner/TunerService.hpp"
#include "audio/AudioService.hpp"
#include "bluetooth/BluetoothService.hpp"
@@ -42,10 +42,13 @@
#include "ota/OtaService.hpp"
#include "ota/OtaError.hpp"
#include "core/OtaAppDescriptor.hpp"
#include "core/WebRadioJson.hpp"
#include "bt1035/Bt1035Error.hpp"
#include "webradio/WebRadioService.hpp"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -53,6 +56,8 @@
#include <array>
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
@@ -68,6 +73,49 @@ extern const uint8_t index_html_gz_start[] asm(
extern const uint8_t index_html_gz_end[] asm(
"_binary_index_html_gz_end");
/**
* @brief routeContextStorage — app-lifetime HTTP handler dependencies.
*
* @dname routeContextStorage
* @return Reference to the singleton route context.
* @pubstate Populated in SetupWebServer::start(); stable across moves so
* esp_http_server user_ctx never goes stale.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] HttpRouteContext& routeContextStorage() noexcept
{
static HttpRouteContext ctx{
.store = nullptr,
.tuner = nullptr,
.audio = nullptr,
.bluetooth = nullptr,
.stations = nullptr,
.integration = nullptr,
.ota = nullptr,
.webRadio = nullptr,
.companionChips = {},
.deviceIdentity = core::DeviceIdentity::unknown(),
};
return ctx;
}
/**
* @brief routeContextReady — whether start() populated route dependencies.
*
* @dname routeContextReady
* @return true after SetupWebServer::start() succeeds.
* @pubstate reads routeContextStorage().store.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] bool routeContextReady() noexcept
{
return routeContextStorage().store != nullptr;
}
/**
* @brief routeContextFrom — read handler dependencies from user_ctx.
*
@@ -81,7 +129,10 @@ extern const uint8_t index_html_gz_end[] asm(
*/
[[nodiscard]] HttpRouteContext* routeContextFrom(httpd_req_t* req) noexcept
{
return static_cast<HttpRouteContext*>(req->user_ctx);
if (req != nullptr && req->user_ctx != nullptr) {
return static_cast<HttpRouteContext*>(req->user_ctx);
}
return routeContextReady() ? &routeContextStorage() : nullptr;
}
/**
@@ -216,21 +267,64 @@ template <std::size_t N>
esp_err_t healthGetHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
const core::CompanionChipStatus chips =
ctx != nullptr
? ctx->companionChips
: core::CompanionChipStatus{
.si4684Ready = false,
.adau1701Ready = false,
.bt1035Ready = false,
};
const core::HealthStatus status = core::HealthStatus::ok(
core::FirmwareVersion(kFirmwareVersion), chips,
ctx != nullptr ? ctx->deviceIdentity.serialNumber()
: std::string_view("unknown"));
const std::string json = core::serializeHealthStatusJson(status);
bool si4684Ready = false;
bool adau1701Ready = false;
bool bt1035Ready = false;
const char* serialNumber = "unknown";
if (ctx != nullptr) {
si4684Ready = ctx->companionChips.si4684Ready;
adau1701Ready = ctx->companionChips.adau1701Ready;
bt1035Ready = ctx->companionChips.bt1035Ready;
const std::string_view serial = ctx->deviceIdentity.serialNumber();
if (!serial.empty() && serial.size() <= 32U) {
serialNumber = serial.data();
}
}
char ipAddr[16] = {};
esp_netif_t* staNetif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (staNetif != nullptr) {
esp_netif_ip_info_t ipInfo{};
if (esp_netif_get_ip_info(staNetif, &ipInfo) == ESP_OK
&& ipInfo.ip.addr != 0U) {
std::snprintf(ipAddr, sizeof(ipAddr), IPSTR, IP2STR(&ipInfo.ip));
}
}
if (ipAddr[0] == '\0') {
esp_netif_t* apNetif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
if (apNetif != nullptr) {
esp_netif_ip_info_t ipInfo{};
if (esp_netif_get_ip_info(apNetif, &ipInfo) == ESP_OK
&& ipInfo.ip.addr != 0U) {
std::snprintf(ipAddr, sizeof(ipAddr), IPSTR, IP2STR(&ipInfo.ip));
}
}
}
char json[320];
int jsonLen = 0;
if (ipAddr[0] != '\0') {
jsonLen = std::snprintf(
json, sizeof(json),
R"({"status":"ok","fw":"%s","serialNumber":"%s","ip":"%s","chips":{"si4684":%s,"adau1701":%s,"bt1035":%s}})",
kFirmwareVersion, serialNumber, ipAddr,
si4684Ready ? "true" : "false", adau1701Ready ? "true" : "false",
bt1035Ready ? "true" : "false");
} else {
jsonLen = std::snprintf(
json, sizeof(json),
R"({"status":"ok","fw":"%s","serialNumber":"%s","chips":{"si4684":%s,"adau1701":%s,"bt1035":%s}})",
kFirmwareVersion, serialNumber, si4684Ready ? "true" : "false",
adau1701Ready ? "true" : "false", bt1035Ready ? "true" : "false");
}
if (jsonLen <= 0 || jsonLen >= static_cast<int>(sizeof(json))) {
httpd_resp_set_status(req, "500 Internal Server Error");
return httpd_resp_send(req, nullptr, 0);
}
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
return httpd_resp_send(req, json, static_cast<std::size_t>(jsonLen));
}
/**
@@ -446,6 +540,61 @@ esp_err_t tunerSeekPostHandler(httpd_req_t* req)
return httpd_resp_send(req, json.c_str(), json.size());
}
/**
* @brief tunerScanPostHandler — automatic FM/DAB station search (test mode).
*
* @dname tunerScanPostHandler
* @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; may block tens of seconds.
*
* @author Michele Bigi
* @date 2026-08-05
*/
esp_err_t tunerScanPostHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->tuner == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
std::array<char, 512> body{};
if (!readRequestBody(req, body)) {
httpd_resp_set_status(req, "400 Bad Request");
return httpd_resp_send(req, nullptr, 0);
}
const auto parsed =
core::parseTunerScanJson(std::string_view(body.data()));
if (!parsed) {
const std::string json =
core::serializeTunerErrorJson(parseErrorToken(parsed.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());
}
ESP_LOGI(kTag, "tuner scan HTTP band=%s max_steps=%u name='%.*s'",
parsed->band == core::TunerBand::Fm ? "fm" : "dab",
static_cast<unsigned>(parsed->maxSteps),
static_cast<int>(parsed->nameFilter.size()),
parsed->nameFilter.c_str());
auto result = ctx->tuner->scanForStation(*parsed);
if (!result) {
const std::string json =
core::serializeTunerErrorJson(tunerErrorToken(result.error()));
httpd_resp_set_status(req, "409 Conflict");
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
const std::string json = core::serializeTunerScanJson(*result);
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
/**
* @brief audioProfileGetHandler — serve GET /api/audio/profile JSON.
*
@@ -608,6 +757,125 @@ esp_err_t audioBassEnhancePostHandler(httpd_req_t* req)
return audioEnhancePostHandler(req, false);
}
/**
* @brief audioBeepPostHandler — toggle the ADAU1701 Beep1 tone generator.
*
* @dname audioBeepPostHandler
* @param req HTTP request handle from esp_http_server.
* @return ESP_OK on success, or an esp_err_t error code.
* @pubstate live-only safeload; does not touch AudioProfile or NVS.
*
* @author Michele Bigi
* @date 2026-08-07
*/
esp_err_t audioBeepPostHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->audio == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
std::array<char, 128> body{};
if (!readRequestBody(req, body)) {
httpd_resp_set_status(req, "400 Bad Request");
return httpd_resp_send(req, nullptr, 0);
}
const auto parsed = core::parseBeepEnabledJson(std::string_view(body.data()));
if (!parsed) {
const std::string json =
core::serializeAudioErrorJson(parseErrorToken(parsed.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 applied = ctx->audio->setBeepEnabled(*parsed); !applied) {
const std::string json = core::serializeAudioErrorJson("dsp_failed");
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::serializeAudioSavedJson();
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
/**
* @brief streamingGetHandler — serve GET /api/streaming as JSON.
*
* @dname streamingGetHandler
* @param req HTTP request handle from esp_http_server.
* @return ESP_OK on success, or an esp_err_t error code.
* @pubstate reads route context web radio service snapshot.
*
* @author Michele Bigi
* @date 2026-08-08
*/
esp_err_t streamingGetHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->webRadio == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
const std::string json =
core::serializeWebRadioConfigJson(ctx->webRadio->config());
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
/**
* @brief streamingPostHandler — accept POST /api/streaming JSON.
*
* @dname streamingPostHandler
* @param req HTTP request handle from esp_http_server.
* @return ESP_OK on success, or an esp_err_t error code.
* @pubstate persists config to NVS and updates the live streaming task
* config; takes effect without a reboot.
*
* @author Michele Bigi
* @date 2026-08-08
*/
esp_err_t streamingPostHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->webRadio == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
std::array<char, 512> body{};
if (!readRequestBody(req, body)) {
httpd_resp_set_status(req, "400 Bad Request");
return httpd_resp_send(req, nullptr, 0);
}
const auto parsed =
core::parseWebRadioConfigJson(std::string_view(body.data()));
if (!parsed) {
const std::string json =
core::serializeWebRadioErrorJson(parseErrorToken(parsed.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 applied = ctx->webRadio->setConfig(*parsed); !applied) {
const std::string json =
core::serializeWebRadioErrorJson("store_failed");
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::serializeWebRadioConfigJson(*parsed);
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
/**
* @brief indexGetHandler — serve gzipped setup page from flash.
* @param req HTTP request handle from esp_http_server.
@@ -699,6 +967,35 @@ esp_err_t wifiPostHandler(httpd_req_t* req)
return ESP_OK;
}
/**
* @brief wifiScanPostHandler — list nearby Wi-Fi access points.
*
* @dname wifiScanPostHandler
* @param req HTTP request handle from esp_http_server.
* @return ESP_OK on success, or an esp_err_t error code.
* @pubstate runs esp_wifi scan via WifiScanner.
*
* @author Michele Bigi
* @date 2026-08-05
*/
esp_err_t wifiScanPostHandler(httpd_req_t* req)
{
(void)req;
auto networks = WifiScanner::scanNearby();
if (!networks) {
const std::string json =
core::serializeWifiScanErrorJson("scan_failed");
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::serializeWifiScanJson(*networks);
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
/**
* @brief dspProgramPostHandler — store validated ADAU program blob to flash.
*
@@ -939,6 +1236,191 @@ esp_err_t bluetoothPairedGetHandler(httpd_req_t* req)
return httpd_resp_send(req, json.c_str(), json.size());
}
esp_err_t bluetoothScanPostHandler(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);
}
ESP_LOGI(kTag, "bluetooth scan HTTP request");
std::uint8_t scanSeconds = 45U;
std::array<char, 128> body{};
if (readRequestBody(req, body)) {
const std::string_view payload(body.data());
const std::string needle = "\"seconds\":";
const std::size_t start = payload.find(needle);
if (start != std::string_view::npos) {
char* end = nullptr;
const unsigned long raw = std::strtoul(
payload.data() + start + needle.size(), &end, 10);
if (end != payload.data() + start + needle.size() && raw >= 1U
&& raw <= 255U) {
scanSeconds = static_cast<std::uint8_t>(raw);
}
}
}
ESP_LOGI(kTag, "bluetooth scan HTTP seconds=%u",
static_cast<unsigned>(scanSeconds));
auto devices = ctx->bluetooth->scanNearby(scanSeconds);
if (!devices) {
ESP_LOGW(kTag, "bluetooth scan failed: %s",
bt1035ErrorToken(devices.error()));
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());
}
ESP_LOGI(kTag, "bluetooth scan HTTP OK: %u device(s)",
static_cast<unsigned>(devices->size()));
for (const core::Bt1035ScannedDevice& device : *devices) {
ESP_LOGI(kTag, "bluetooth scan HTTP result: mac=%s name=%s rssi=%d",
device.mac.c_str(),
device.name.empty() ? "(no name)" : device.name.c_str(),
static_cast<int>(device.rssiDbm));
}
const std::string json = core::serializeBluetoothScanJson(*devices);
ESP_LOGI(kTag, "bluetooth scan JSON length=%u",
static_cast<unsigned>(json.size()));
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
esp_err_t bluetoothConnectPostHandler(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, 256> body{};
if (!readRequestBody(req, body)) {
httpd_resp_set_status(req, "400 Bad Request");
return httpd_resp_send(req, nullptr, 0);
}
const core::BluetoothConnectRequest request =
core::parseBluetoothConnectRequest(std::string_view(body.data()));
if (request.mac.empty()) {
const std::string json =
core::serializeBluetoothErrorJson("invalid_mac");
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());
}
ESP_LOGI(kTag, "bluetooth connect HTTP mac=%s save=%s",
request.mac.c_str(), request.save ? "yes" : "no");
if (auto result = ctx->bluetooth->connectTo(request); !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\":\"connected\"}", 24);
}
esp_err_t bluetoothSpeakerGetHandler(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);
}
if (!ctx->bluetooth->hasSavedSpeaker()) {
const std::string json = core::serializeBluetoothSpeakerJson(nullptr);
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
const auto target = ctx->bluetooth->loadSavedSpeaker();
if (!target) {
httpd_resp_set_status(req, "500 Internal Server Error");
return httpd_resp_send(req, nullptr, 0);
}
const std::string json = core::serializeBluetoothSpeakerJson(&(*target));
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
esp_err_t bluetoothSpeakerPostHandler(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, 256> body{};
if (!readRequestBody(req, body)) {
httpd_resp_set_status(req, "400 Bad Request");
return httpd_resp_send(req, nullptr, 0);
}
const auto target = core::parseBluetoothSpeakerJson(std::string_view(body.data()));
if (!target) {
const std::string json =
core::serializeBluetoothErrorJson(parseErrorToken(target.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 saved = ctx->bluetooth->saveSpeaker(*target); !saved) {
httpd_resp_set_status(req, "500 Internal Server Error");
return httpd_resp_send(req, nullptr, 0);
}
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, "{\"status\":\"saved\"}", 18);
}
esp_err_t bluetoothSpeakerDeleteHandler(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);
}
(void)ctx->bluetooth->clearSavedSpeaker();
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, "{\"status\":\"cleared\"}", 20);
}
esp_err_t bluetoothReconnectPostHandler(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);
}
ESP_LOGI(kTag, "bluetooth reconnect HTTP request");
if (auto result = ctx->bluetooth->reconnectSavedSpeaker(true); !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\":\"connected\"}", 24);
}
esp_err_t bluetoothAutoReconnectPostHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
@@ -1124,9 +1606,6 @@ SetupWebServer::SetupWebServer()
, bluetooth_(nullptr)
, stations_(nullptr)
, integration_(nullptr)
, routeContext_{nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
nullptr, core::CompanionChipStatus{false, false, false},
core::DeviceIdentity::unknown()}
{
}
@@ -1139,7 +1618,6 @@ SetupWebServer::SetupWebServer(SetupWebServer&& other) noexcept
, bluetooth_(other.bluetooth_)
, stations_(other.stations_)
, integration_(other.integration_)
, routeContext_(other.routeContext_)
{
other.server_ = nullptr;
other.store_ = nullptr;
@@ -1149,8 +1627,6 @@ SetupWebServer::SetupWebServer(SetupWebServer&& other) noexcept
other.bluetooth_ = nullptr;
other.stations_ = nullptr;
other.integration_ = nullptr;
other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr,
nullptr, nullptr, {}, core::DeviceIdentity::unknown()};
}
SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept
@@ -1167,7 +1643,6 @@ SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept
bluetooth_ = other.bluetooth_;
stations_ = other.stations_;
integration_ = other.integration_;
routeContext_ = other.routeContext_;
other.server_ = nullptr;
other.store_ = nullptr;
other.netState_ = NetState::Uninitialized;
@@ -1176,9 +1651,6 @@ SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept
other.bluetooth_ = nullptr;
other.stations_ = nullptr;
other.integration_ = nullptr;
other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr,
nullptr, nullptr, core::CompanionChipStatus{false, false, false},
core::DeviceIdentity::unknown()};
}
return *this;
}
@@ -1189,9 +1661,6 @@ SetupWebServer::~SetupWebServer()
httpd_stop(server_);
server_ = nullptr;
}
routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
nullptr, core::CompanionChipStatus{false, false, false},
core::DeviceIdentity::unknown()};
}
std::expected<void, NetError> SetupWebServer::start(
@@ -1203,6 +1672,7 @@ std::expected<void, NetError> SetupWebServer::start(
station::StationService& stations,
integration::IntegrationService& integration,
ota::OtaService& ota,
webradio::WebRadioService& webRadio,
core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity)
{
@@ -1217,30 +1687,34 @@ std::expected<void, NetError> SetupWebServer::start(
bluetooth_ = &bluetooth;
stations_ = &stations;
integration_ = &integration;
routeContext_.store = &store;
routeContext_.tuner = &tuner;
routeContext_.audio = &audio;
routeContext_.bluetooth = &bluetooth;
routeContext_.stations = &stations;
routeContext_.integration = &integration;
routeContext_.ota = &ota;
routeContext_.companionChips = companionChips;
routeContext_.deviceIdentity = deviceIdentity;
auto& routeContext = routeContextStorage();
routeContext.store = &store;
routeContext.tuner = &tuner;
routeContext.audio = &audio;
routeContext.bluetooth = &bluetooth;
routeContext.stations = &stations;
routeContext.integration = &integration;
routeContext.ota = &ota;
routeContext.webRadio = &webRadio;
routeContext.companionChips = companionChips;
routeContext.deviceIdentity = deviceIdentity;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = 12288;
config.max_open_sockets = 3;
config.max_uri_handlers = 40;
config.server_port = 80;
config.lru_purge_enable = true;
config.recv_wait_timeout = 60;
config.send_wait_timeout = 60;
if (httpd_start(&server_, &config) != ESP_OK) {
ESP_LOGE(kTag, "httpd_start failed");
routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
nullptr, core::CompanionChipStatus{false, false, false},
core::DeviceIdentity::unknown()};
routeContext.store = nullptr;
return std::unexpected(NetError::HttpServerStartFailed);
}
void* routeCtx = &routeContext_;
void* routeCtx = &routeContext;
const httpd_uri_t healthUri = {
.uri = "/api/health",
@@ -1266,6 +1740,14 @@ std::expected<void, NetError> SetupWebServer::start(
};
httpd_register_uri_handler(server_, &wifiUri);
const httpd_uri_t wifiScanUri = {
.uri = "/api/wifi/scan",
.method = HTTP_POST,
.handler = wifiScanPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &wifiScanUri);
const httpd_uri_t tunerStatusUri = {
.uri = "/api/tuner/status",
.method = HTTP_GET,
@@ -1306,6 +1788,14 @@ std::expected<void, NetError> SetupWebServer::start(
};
httpd_register_uri_handler(server_, &tunerSeekUri);
const httpd_uri_t tunerScanUri = {
.uri = "/api/tuner/scan",
.method = HTTP_POST,
.handler = tunerScanPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &tunerScanUri);
const httpd_uri_t audioProfileGetUri = {
.uri = "/api/audio/profile",
.method = HTTP_GET,
@@ -1346,6 +1836,30 @@ std::expected<void, NetError> SetupWebServer::start(
};
httpd_register_uri_handler(server_, &audioBassEnhanceUri);
const httpd_uri_t audioBeepUri = {
.uri = "/api/audio/beep",
.method = HTTP_POST,
.handler = audioBeepPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &audioBeepUri);
const httpd_uri_t streamingGetUri = {
.uri = "/api/streaming",
.method = HTTP_GET,
.handler = streamingGetHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &streamingGetUri);
const httpd_uri_t streamingPostUri = {
.uri = "/api/streaming",
.method = HTTP_POST,
.handler = streamingPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &streamingPostUri);
const httpd_uri_t dspProgramUri = {
.uri = "/api/dsp/program",
.method = HTTP_POST,
@@ -1402,6 +1916,54 @@ std::expected<void, NetError> SetupWebServer::start(
};
httpd_register_uri_handler(server_, &bluetoothPairedUri);
const httpd_uri_t bluetoothScanUri = {
.uri = "/api/bluetooth/scan",
.method = HTTP_POST,
.handler = bluetoothScanPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &bluetoothScanUri);
const httpd_uri_t bluetoothConnectUri = {
.uri = "/api/bluetooth/connect",
.method = HTTP_POST,
.handler = bluetoothConnectPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &bluetoothConnectUri);
const httpd_uri_t bluetoothSpeakerGetUri = {
.uri = "/api/bluetooth/speaker",
.method = HTTP_GET,
.handler = bluetoothSpeakerGetHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &bluetoothSpeakerGetUri);
const httpd_uri_t bluetoothSpeakerPostUri = {
.uri = "/api/bluetooth/speaker",
.method = HTTP_POST,
.handler = bluetoothSpeakerPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &bluetoothSpeakerPostUri);
const httpd_uri_t bluetoothSpeakerDeleteUri = {
.uri = "/api/bluetooth/speaker",
.method = HTTP_DELETE,
.handler = bluetoothSpeakerDeleteHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &bluetoothSpeakerDeleteUri);
const httpd_uri_t bluetoothReconnectUri = {
.uri = "/api/bluetooth/reconnect",
.method = HTTP_POST,
.handler = bluetoothReconnectPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &bluetoothReconnectUri);
const httpd_uri_t bluetoothAutoReconnectUri = {
.uri = "/api/bluetooth/auto-reconnect",
.method = HTTP_POST,
@@ -0,0 +1,570 @@
/**
* @file SigmaStudioTcpServer.cpp
* @brief SigmaStudioTcpServer implementation.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* @author Michele Bigi
* @date 2026-08-07
*/
#include "net/SigmaStudioTcpServer.hpp"
#include "adau1701/FlashDspProgramSource.hpp"
#include "core/DspProgram.hpp"
#include "core/DspProgramBlob.hpp"
#include "core/RegisterWrite.hpp"
#include "SigmaStudioFW.h"
#include "esp_log.h"
#include "lwip/sockets.h"
#include <algorithm>
#include <atomic>
#include <cerrno>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <span>
#include <vector>
namespace net {
namespace {
constexpr char kTag[] = "SigmaTcp";
constexpr std::uint16_t kPort = 8086U;
constexpr std::size_t kRecvBufSize = 16U * 1024U;
constexpr std::uint32_t kTaskStackBytes = 8192U;
constexpr UBaseType_t kTaskPriority = 5U;
constexpr std::uint8_t kCtrlWrite = 0x09U;
constexpr std::uint8_t kCtrlReadReq = 0x0AU;
constexpr std::uint8_t kCtrlReadResp = 0x0BU;
constexpr std::uint8_t kChipAddrDsp = 0x01U;
// board::pins::Adau1701Addr (main/board_pins.hpp), duplicated as a literal
// to avoid a net -> main include dependency; keep in sync if the board's
// I2C address ever changes. The reference project accepts both the IC
// index (0x01) and the raw address as chipAddr since it's unclear which
// convention real SigmaStudio uses for a single-IC project — mirrored here.
constexpr std::uint8_t kDspI2cAddr7 = 0x34U;
[[nodiscard]] bool isDspChipAddr(std::uint8_t chipAddr) noexcept
{
return chipAddr == kChipAddrDsp || chipAddr == kDspI2cAddr7;
}
/**
* @brief activeListenFd — process-lifetime storage for the listen fd.
*
* @dname activeListenFd
* @return Reference to the singleton listen-fd slot.
* @pubstate Written by SigmaStudioTcpServer::start()/stop(); read by
* acceptLoopTask(). SigmaStudioTcpServer is constructed as a
* local and move-relocated into NetBootstrap (see
* NetBootstrap.cpp), so a `this` pointer captured at start()
* time would go stale once that local's stack frame returns —
* same problem SetupWebServer's routeContextStorage() solves.
*
* @author Michele Bigi
* @date 2026-08-07
*/
[[nodiscard]] std::atomic<int>& activeListenFd() noexcept
{
static std::atomic<int> fd{-1};
return fd;
}
constexpr std::uint16_t kProgRamStart = 0x0400U;
constexpr std::uint16_t kProgRamEnd = 0x07FFU;
constexpr std::uint16_t kCtrlRegStart = 0x0800U;
constexpr std::uint16_t kCoreControlReg = 0x081CU;
constexpr std::uint8_t kDspRunBit = 0x04U;
constexpr unsigned kWordBytesParam = 4U;
constexpr unsigned kWordsPerSafeload = 5U;
constexpr std::size_t kWriteHeaderSize = 10U;
constexpr std::size_t kReadReqHeaderSize = 8U;
constexpr std::size_t kReadRespHeaderSize = 9U;
constexpr std::uint16_t kMaxReadBytes = 256U;
constexpr std::size_t kMaxCaptureRegions = 32U; // core::DspProgramBlob kMaxWriteCount
constexpr std::size_t kMaxRegionPayload = 16U * 1024U; // kMaxWritePayload
[[nodiscard]] unsigned wordSizeForAddress(std::uint16_t address) noexcept
{
if (address >= kProgRamStart && address <= kProgRamEnd) {
return 5U;
}
if (address >= kCtrlRegStart) {
return 2U;
}
return 4U;
}
[[nodiscard]] std::uint16_t readBe16(const std::uint8_t* p) noexcept
{
return static_cast<std::uint16_t>((static_cast<std::uint16_t>(p[0]) << 8)
| p[1]);
}
void writeBe16(std::uint8_t* p, std::uint16_t value) noexcept
{
p[0] = static_cast<std::uint8_t>((value >> 8) & 0xFFU);
p[1] = static_cast<std::uint8_t>(value & 0xFFU);
}
/**
* @brief PendingRegion — one coalesced contiguous write during a Download.
*/
struct PendingRegion {
std::uint16_t address;
std::vector<std::uint8_t> data;
};
/**
* @brief DownloadCapture — coalesces a Link Compile Download for persistence.
*
* Merges contiguous direct (non-safeload) DSP writes into a handful of
* regions (mirroring the ~5-block shape of EmbeddedDspProgramSource), then
* on finish() serialises and stores them via FlashDspProgramSource so the
* downloaded program becomes what DigiRadio boots with next time. Bails
* out (does not persist) if the session doesn't fit the DRAD blob's own
* caps — the DSP still runs fine from what was already written live.
*/
class DownloadCapture {
public:
void addWrite(std::uint16_t address, std::span<const std::uint8_t> data)
{
if (overflowed_ || data.empty()) {
return;
}
if (tryExtendLast(address, data)) {
return;
}
if (regions_.size() >= kMaxCaptureRegions) {
abandon();
return;
}
regions_.push_back(PendingRegion{
address, std::vector<std::uint8_t>(data.begin(), data.end())});
}
void finish()
{
if (!overflowed_ && !regions_.empty()) {
persist();
}
regions_.clear();
overflowed_ = false;
}
private:
[[nodiscard]] bool tryExtendLast(std::uint16_t address,
std::span<const std::uint8_t> data)
{
if (regions_.empty()) {
return false;
}
PendingRegion& last = regions_.back();
const unsigned wordSize = wordSizeForAddress(last.address);
const auto lastWords =
static_cast<std::uint32_t>(last.data.size() / wordSize);
const std::uint32_t lastEnd =
static_cast<std::uint32_t>(last.address) + lastWords;
if (lastEnd != address
|| last.data.size() + data.size() > kMaxRegionPayload) {
return false;
}
last.data.insert(last.data.end(), data.begin(), data.end());
return true;
}
void abandon()
{
overflowed_ = true;
regions_.clear();
ESP_LOGW(kTag,
"Download too fragmented to persist as boot program — "
"DSP still runs live, only reboot-persistence is skipped");
}
void persist()
{
std::vector<core::RegisterWrite> writes;
writes.reserve(regions_.size());
for (auto& region : regions_) {
writes.emplace_back(region.address, std::move(region.data));
}
const core::DspProgram program(std::move(writes));
const std::vector<std::uint8_t> blob =
core::serializeDspProgramBlob(program);
if (auto stored = adau1701::FlashDspProgramSource::storeBlob(blob);
!stored) {
ESP_LOGW(kTag, "SigmaStudio download not persisted (flash store failed)");
return;
}
ESP_LOGI(kTag,
"SigmaStudio download persisted as boot program (%u bytes)",
static_cast<unsigned>(blob.size()));
}
std::vector<PendingRegion> regions_;
bool overflowed_ = false;
};
/**
* @brief ConnectionState — per-TCP-connection Download tracking.
*/
struct ConnectionState {
DownloadCapture capture;
bool dspRunning = false;
};
void directWrite(std::uint16_t address, std::span<const std::uint8_t> data)
{
if (data.empty()) {
return;
}
sigma_studio_lock();
SIGMA_WRITE_REGISTER_BLOCK(
0U, address, static_cast<unsigned int>(data.size()),
const_cast<ADI_REG_TYPE*>(
reinterpret_cast<const ADI_REG_TYPE*>(data.data())));
sigma_studio_unlock();
}
void safeloadWrite(std::uint16_t address, std::span<const std::uint8_t> data)
{
const auto totalWords =
static_cast<unsigned>(data.size() / kWordBytesParam);
unsigned offset = 0U;
sigma_studio_lock();
while (offset < totalWords) {
const unsigned words =
std::min(totalWords - offset, kWordsPerSafeload);
unsigned addrs[kWordsPerSafeload];
for (unsigned i = 0U; i < words; ++i) {
addrs[i] = static_cast<unsigned>(address) + offset + i;
}
sigma_safeload_raw_block(
static_cast<unsigned char>(words), addrs,
data.data() + static_cast<std::size_t>(offset) * kWordBytesParam);
offset += words;
}
sigma_studio_unlock();
}
void trackDownloadCompletion(std::uint16_t address,
std::span<const std::uint8_t> payload,
ConnectionState& state)
{
if (address != kCoreControlReg || payload.size() < 2U) {
return;
}
const bool wasRunning = state.dspRunning;
state.dspRunning = (payload.back() & kDspRunBit) != 0U;
if (!wasRunning && state.dspRunning) {
state.capture.finish();
}
}
void dispatchWrite(std::uint16_t address, std::span<const std::uint8_t> payload,
std::uint8_t safeload, ConnectionState& state)
{
if (safeload != 0U) {
safeloadWrite(address, payload);
return;
}
if (!state.dspRunning) {
state.capture.addWrite(address, payload);
}
directWrite(address, payload);
trackDownloadCompletion(address, payload, state);
}
[[nodiscard]] std::size_t tryConsumeWrite(const std::uint8_t* p,
std::size_t avail,
ConnectionState& state)
{
if (avail < kWriteHeaderSize) {
return 0U;
}
const std::uint16_t totalLen = readBe16(p + 3U);
if (totalLen < kWriteHeaderSize) {
return 1U; // malformed frame — resync by one byte
}
if (avail < totalLen) {
return 0U;
}
const std::uint8_t safeload = p[1];
const std::uint8_t chipAddr = p[5];
const std::uint16_t dataLen = readBe16(p + 6U);
const std::uint16_t address = readBe16(p + 8U);
const std::uint16_t maxPayload =
static_cast<std::uint16_t>(totalLen - kWriteHeaderSize);
const std::uint16_t safeLen = std::min(dataLen, maxPayload);
if (isDspChipAddr(chipAddr)) {
const std::span<const std::uint8_t> payload(p + kWriteHeaderSize,
safeLen);
dispatchWrite(address, payload, safeload, state);
}
return totalLen;
}
void sendReadResponse(int clientFd, std::uint8_t chipAddr,
std::uint16_t address, std::uint16_t requested)
{
const std::uint16_t length = std::min(requested, kMaxReadBytes);
std::vector<std::uint8_t> data(length);
sigma_studio_lock();
const int result =
length == 0U ? 0 : sigma_i2c_read(address, data.data(), length);
sigma_studio_unlock();
if (result != 0) {
ESP_LOGW(kTag,
"read 0x%04x len=%u chipAddr=0x%02x: sigma_i2c_read failed",
static_cast<unsigned>(address),
static_cast<unsigned>(length),
static_cast<unsigned>(chipAddr));
return;
}
std::vector<std::uint8_t> resp(kReadRespHeaderSize + length);
resp[0] = kCtrlReadResp;
writeBe16(resp.data() + 1U,
static_cast<std::uint16_t>(kReadRespHeaderSize + length));
resp[3] = chipAddr;
writeBe16(resp.data() + 4U, length);
writeBe16(resp.data() + 6U, address);
resp[8] = 0x01U;
std::memcpy(resp.data() + kReadRespHeaderSize, data.data(), length);
const ssize_t sent = send(clientFd, resp.data(), resp.size(), 0);
ESP_LOGI(kTag,
"read 0x%04x len=%u chipAddr=0x%02x: sent %d/%u resp bytes",
static_cast<unsigned>(address), static_cast<unsigned>(length),
static_cast<unsigned>(chipAddr), static_cast<int>(sent),
static_cast<unsigned>(resp.size()));
}
[[nodiscard]] std::size_t tryConsumeRead(const std::uint8_t* p,
std::size_t avail, int clientFd)
{
if (avail < kReadReqHeaderSize) {
return 0U;
}
const std::uint16_t totalLen = readBe16(p + 1U);
if (totalLen < kReadReqHeaderSize) {
return 1U; // malformed frame — resync by one byte
}
if (avail < totalLen) {
return 0U;
}
const std::uint8_t chipAddr = p[3];
const std::uint16_t dataLen = readBe16(p + 4U);
const std::uint16_t address = readBe16(p + 6U);
if (isDspChipAddr(chipAddr)) {
sendReadResponse(clientFd, chipAddr, address, dataLen);
} else {
ESP_LOGW(kTag,
"read req dropped: chipAddr=0x%02x not DSP (want 0x%02x or "
"0x%02x), addr=0x%04x len=%u",
static_cast<unsigned>(chipAddr),
static_cast<unsigned>(kChipAddrDsp),
static_cast<unsigned>(kDspI2cAddr7),
static_cast<unsigned>(address),
static_cast<unsigned>(dataLen));
}
return totalLen;
}
[[nodiscard]] std::size_t processBuffer(std::uint8_t* buf, std::size_t len,
int clientFd, ConnectionState& state)
{
std::size_t pos = 0U;
while (pos < len) {
const std::uint8_t ctrl = buf[pos];
std::size_t consumed = 0U;
if (ctrl == kCtrlWrite) {
consumed = tryConsumeWrite(buf + pos, len - pos, state);
} else if (ctrl == kCtrlReadReq) {
consumed = tryConsumeRead(buf + pos, len - pos, clientFd);
} else {
ESP_LOGW(kTag, "unrecognized ctrl byte 0x%02x — resyncing",
static_cast<unsigned>(ctrl));
consumed = 1U;
}
if (consumed == 0U) {
break;
}
pos += consumed;
}
return pos;
}
/** @brief Diagnostic: hex-dump up to the first 48 bytes of a receive. */
void logRxHexDump(const std::uint8_t* p, std::size_t len)
{
constexpr std::size_t kMaxDump = 48U;
char hex[3U * kMaxDump + 1U];
const std::size_t n = std::min(len, kMaxDump);
for (std::size_t i = 0U; i < n; ++i) {
std::snprintf(hex + i * 3U, 4U, "%02x ", static_cast<unsigned>(p[i]));
}
ESP_LOGI(kTag, "rx %u bytes: %s%s", static_cast<unsigned>(len), hex,
len > kMaxDump ? "..." : "");
}
void serveClient(int clientFd)
{
ConnectionState state;
std::vector<std::uint8_t> buf(kRecvBufSize);
std::size_t len = 0U;
while (true) {
const ssize_t received =
recv(clientFd, buf.data() + len, buf.size() - len, 0);
if (received <= 0) {
break;
}
logRxHexDump(buf.data() + len, static_cast<std::size_t>(received));
len += static_cast<std::size_t>(received);
const std::size_t consumed = processBuffer(buf.data(), len, clientFd, state);
if (consumed > 0U && consumed < len) {
std::memmove(buf.data(), buf.data() + consumed, len - consumed);
}
len = consumed <= len ? len - consumed : 0U;
}
}
} // namespace
SigmaStudioTcpServer::SigmaStudioTcpServer()
: listenFd_(-1)
, task_(nullptr)
{
}
SigmaStudioTcpServer::~SigmaStudioTcpServer()
{
stop();
}
SigmaStudioTcpServer::SigmaStudioTcpServer(SigmaStudioTcpServer&& other) noexcept
: listenFd_(other.listenFd_)
, task_(other.task_)
{
other.listenFd_ = -1;
other.task_ = nullptr;
}
SigmaStudioTcpServer&
SigmaStudioTcpServer::operator=(SigmaStudioTcpServer&& other) noexcept
{
if (this != &other) {
stop();
listenFd_ = other.listenFd_;
task_ = other.task_;
other.listenFd_ = -1;
other.task_ = nullptr;
}
return *this;
}
void SigmaStudioTcpServer::stop() noexcept
{
if (task_ != nullptr) {
vTaskDelete(task_);
task_ = nullptr;
}
activeListenFd().store(-1, std::memory_order_release);
if (listenFd_ >= 0) {
close(listenFd_);
listenFd_ = -1;
}
}
std::expected<void, NetError> SigmaStudioTcpServer::start()
{
if (task_ != nullptr) {
return {};
}
const int fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (fd < 0) {
ESP_LOGE(kTag, "socket() failed");
return std::unexpected(NetError::TcpServerStartFailed);
}
const int reuse = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(kPort);
if (bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0
|| listen(fd, 1) != 0) {
ESP_LOGE(kTag, "bind/listen failed");
close(fd);
return std::unexpected(NetError::TcpServerStartFailed);
}
listenFd_ = fd;
activeListenFd().store(fd, std::memory_order_release);
const BaseType_t created =
xTaskCreate(&SigmaStudioTcpServer::acceptLoopTask, "sigma_tcp",
kTaskStackBytes, nullptr, kTaskPriority, &task_);
if (created != pdPASS) {
ESP_LOGE(kTag, "xTaskCreate failed");
activeListenFd().store(-1, std::memory_order_release);
close(listenFd_);
listenFd_ = -1;
task_ = nullptr;
return std::unexpected(NetError::TcpServerStartFailed);
}
ESP_LOGI(kTag, "SigmaStudio TCP bridge listening on port %u",
static_cast<unsigned>(kPort));
return {};
}
void SigmaStudioTcpServer::acceptLoopTask(void* /*arg*/)
{
while (true) {
const int listenFd = activeListenFd().load(std::memory_order_acquire);
sockaddr_in clientAddr{};
socklen_t clientLen = sizeof(clientAddr);
const int clientFd = accept(
listenFd, reinterpret_cast<sockaddr*>(&clientAddr), &clientLen);
if (clientFd < 0) {
ESP_LOGW(kTag, "accept() failed: errno=%d", errno);
vTaskDelay(pdMS_TO_TICKS(100));
continue;
}
ESP_LOGI(kTag, "SigmaStudio client connected");
serveClient(clientFd);
ESP_LOGI(kTag, "SigmaStudio client disconnected");
close(clientFd);
}
}
} // namespace net
+8 -1
View File
@@ -18,6 +18,7 @@
#include "net/SoftApHost.hpp"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "esp_log.h"
@@ -69,11 +70,17 @@ std::expected<void, NetError> SoftApHost::start()
return {};
}
if (esp_wifi_set_mode(WIFI_MODE_AP) != ESP_OK) {
if (esp_wifi_set_mode(WIFI_MODE_APSTA) != ESP_OK) {
ESP_LOGE(kTag, "esp_wifi_set_mode failed");
return std::unexpected(NetError::WifiConfigFailed);
}
wifi_config_t staCfg = {};
if (esp_wifi_set_config(WIFI_IF_STA, &staCfg) != ESP_OK) {
ESP_LOGE(kTag, "esp_wifi_set_config STA failed");
return std::unexpected(NetError::WifiConfigFailed);
}
wifi_config_t wifiCfg = {};
const std::string_view ssid = config_.ssid();
std::memcpy(wifiCfg.ap.ssid, ssid.data(), ssid.size());
+137 -12
View File
@@ -36,9 +36,62 @@ namespace {
constexpr char kTag[] = "StaClient";
constexpr int kConnectedBit = BIT0;
constexpr int kFailedBit = BIT1;
constexpr TickType_t kConnectTimeout = pdMS_TO_TICKS(30000);
constexpr int kMaxConnectRetries = 10;
constexpr TickType_t kConnectTimeout = pdMS_TO_TICKS(45000);
constexpr TickType_t kRetryDelay = pdMS_TO_TICKS(800);
EventGroupHandle_t s_wifiEventGroup = nullptr;
int s_connectRetries = 0;
/**
* @brief disconnectReasonString — map ESP-IDF Wi-Fi disconnect reason codes.
*
* @dname disconnectReasonString
* @param reason wifi_event_sta_disconnected_t::reason value.
* @return Short English label for logs.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] const char* disconnectReasonString(uint8_t reason) noexcept
{
switch (reason) {
case WIFI_REASON_AUTH_EXPIRE:
return "auth expired (wrong password?)";
case WIFI_REASON_NO_AP_FOUND:
return "no AP found (check SSID / 2.4 GHz)";
case WIFI_REASON_AUTH_FAIL:
return "auth failed (check password)";
case WIFI_REASON_ASSOC_FAIL:
return "association failed";
case WIFI_REASON_HANDSHAKE_TIMEOUT:
return "handshake timeout";
case WIFI_REASON_BEACON_TIMEOUT:
return "beacon timeout (weak signal / PS)";
case WIFI_REASON_CONNECTION_FAIL:
return "connection failed";
default:
return "unknown";
}
}
/**
* @brief applyStaLinkTuning — stabilise STA link after connect.
*
* @dname applyStaLinkTuning
* @pubstate Disables PS, forces 20 MHz, disables inactive disconnect.
*
* @author Michele Bigi
* @date 2026-08-05
*/
void applyStaLinkTuning() noexcept
{
esp_wifi_set_ps(WIFI_PS_NONE);
esp_wifi_set_bandwidth(WIFI_IF_STA, WIFI_BW_HT20);
esp_wifi_set_inactive_time(WIFI_IF_STA, 0);
ESP_LOGI(kTag, "STA link tuning applied (PS off, HT20, inactive off)");
}
/**
* @brief wifiEventHandler — signal connect success or failure.
@@ -59,29 +112,72 @@ void wifiEventHandler(void* arg,
void* eventData)
{
(void)arg;
(void)eventData;
if (eventBase == WIFI_EVENT && eventId == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (eventBase == WIFI_EVENT
&& eventId == WIFI_EVENT_STA_DISCONNECTED) {
const auto* disc =
static_cast<const wifi_event_sta_disconnected_t*>(eventData);
const uint8_t reason = disc != nullptr ? disc->reason : 0U;
ESP_LOGW(kTag, "STA disconnected (reason %u: %s)",
static_cast<unsigned>(reason),
disconnectReasonString(reason));
if (s_wifiEventGroup != nullptr) {
if (s_connectRetries < kMaxConnectRetries) {
++s_connectRetries;
ESP_LOGI(kTag, "retrying STA connect (%d/%d)",
s_connectRetries, kMaxConnectRetries);
vTaskDelay(kRetryDelay);
esp_wifi_connect();
return;
}
xEventGroupSetBits(s_wifiEventGroup, kFailedBit);
return;
}
ESP_LOGW(kTag, "STA link lost — reconnecting");
esp_wifi_connect();
} else if (eventBase == IP_EVENT && eventId == IP_EVENT_STA_GOT_IP) {
const auto* event =
static_cast<const ip_event_got_ip_t*>(eventData);
if (event != nullptr) {
ESP_LOGI(kTag, "STA IP " IPSTR, IP2STR(&event->ip_info.ip));
}
applyStaLinkTuning();
if (s_wifiEventGroup != nullptr) {
xEventGroupSetBits(s_wifiEventGroup, kConnectedBit);
}
}
}
void unregisterStaHandlers(esp_event_handler_instance_t wifiHandler,
esp_event_handler_instance_t ipHandler) noexcept
{
if (wifiHandler != nullptr) {
esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID,
wifiHandler);
}
if (ipHandler != nullptr) {
esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP,
ipHandler);
}
}
} // namespace
StaClient::StaClient()
: connected_(false)
, wifiHandler_(nullptr)
, ipHandler_(nullptr)
{
}
StaClient::~StaClient()
{
unregisterStaHandlers(wifiHandler_, ipHandler_);
wifiHandler_ = nullptr;
ipHandler_ = nullptr;
if (connected_) {
esp_wifi_stop();
connected_ = false;
@@ -90,18 +186,27 @@ StaClient::~StaClient()
StaClient::StaClient(StaClient&& other) noexcept
: connected_(other.connected_)
, wifiHandler_(other.wifiHandler_)
, ipHandler_(other.ipHandler_)
{
other.connected_ = false;
other.wifiHandler_ = nullptr;
other.ipHandler_ = nullptr;
}
StaClient& StaClient::operator=(StaClient&& other) noexcept
{
if (this != &other) {
unregisterStaHandlers(wifiHandler_, ipHandler_);
if (connected_) {
esp_wifi_stop();
}
connected_ = other.connected_;
wifiHandler_ = other.wifiHandler_;
ipHandler_ = other.ipHandler_;
other.connected_ = false;
other.wifiHandler_ = nullptr;
other.ipHandler_ = nullptr;
}
return *this;
}
@@ -122,19 +227,18 @@ StaClient::connect(const core::WifiCredentials& creds, std::string_view hostname
if (s_wifiEventGroup == nullptr) {
return std::unexpected(NetError::StaConnectFailed);
}
s_connectRetries = 0;
esp_event_handler_instance_t instanceAnyId = nullptr;
esp_event_handler_instance_t instanceGotIp = nullptr;
esp_event_handler_instance_register(WIFI_EVENT,
ESP_EVENT_ANY_ID,
&wifiEventHandler,
nullptr,
&instanceAnyId);
&wifiHandler_);
esp_event_handler_instance_register(IP_EVENT,
IP_EVENT_STA_GOT_IP,
&wifiEventHandler,
nullptr,
&instanceGotIp);
&ipHandler_);
if (esp_wifi_set_mode(WIFI_MODE_STA) != ESP_OK) {
return std::unexpected(NetError::WifiConfigFailed);
@@ -150,15 +254,31 @@ StaClient::connect(const core::WifiCredentials& creds, std::string_view hostname
wifi_config_t wifiCfg = {};
const std::string_view ssid = creds.ssid().value();
const std::size_t ssidCopy =
std::min(ssid.size(), sizeof(wifiCfg.sta.ssid) - 1);
std::min(ssid.size(), sizeof(wifiCfg.sta.ssid) - 1U);
std::memcpy(wifiCfg.sta.ssid, ssid.data(), ssidCopy);
wifiCfg.sta.ssid[ssidCopy] = '\0';
std::size_t pwdLen = 0U;
creds.password().usePlaintext([&](std::string_view pwd) {
pwdLen = pwd.size();
const std::size_t pwdCopy =
std::min(pwd.size(), sizeof(wifiCfg.sta.password) - 1);
std::min(pwd.size(), sizeof(wifiCfg.sta.password) - 1U);
std::memcpy(wifiCfg.sta.password, pwd.data(), pwdCopy);
wifiCfg.sta.password[pwdCopy] = '\0';
});
if (pwdLen == 0U) {
wifiCfg.sta.threshold.authmode = WIFI_AUTH_OPEN;
} else {
wifiCfg.sta.threshold.authmode = WIFI_AUTH_WPA2_WPA3_PSK;
}
wifiCfg.sta.pmf_cfg.capable = true;
wifiCfg.sta.pmf_cfg.required = false;
wifiCfg.sta.scan_method = WIFI_ALL_CHANNEL_SCAN;
wifiCfg.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL;
wifiCfg.sta.failure_retry_cnt = 3;
wifiCfg.sta.listen_interval = 1;
if (esp_wifi_set_config(WIFI_IF_STA, &wifiCfg) != ESP_OK) {
return std::unexpected(NetError::WifiConfigFailed);
}
@@ -167,20 +287,22 @@ StaClient::connect(const core::WifiCredentials& creds, std::string_view hostname
return std::unexpected(NetError::WifiStartFailed);
}
esp_wifi_set_protocol(WIFI_IF_STA,
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G
| WIFI_PROTOCOL_11N);
esp_wifi_set_ps(WIFI_PS_NONE);
const EventBits_t bits = xEventGroupWaitBits(s_wifiEventGroup,
kConnectedBit | kFailedBit,
pdTRUE,
pdFALSE,
kConnectTimeout);
esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP,
instanceGotIp);
esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID,
instanceAnyId);
vEventGroupDelete(s_wifiEventGroup);
s_wifiEventGroup = nullptr;
if ((bits & kConnectedBit) != 0) {
applyStaLinkTuning();
connected_ = true;
if (!hostLabel.empty()) {
if (mdns_init() == ESP_OK) {
@@ -192,6 +314,9 @@ StaClient::connect(const core::WifiCredentials& creds, std::string_view hostname
return {};
}
unregisterStaHandlers(wifiHandler_, ipHandler_);
wifiHandler_ = nullptr;
ipHandler_ = nullptr;
esp_wifi_stop();
ESP_LOGW(kTag, "STA connect timed out or failed");
if ((bits & kFailedBit) != 0) {
+220
View File
@@ -0,0 +1,220 @@
/**
* @file WifiScanner.cpp
* @brief WifiScanner implementation.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-08-05
*/
#include "net/WifiScanner.hpp"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <algorithm>
#include <cstring>
#include <string>
#include <unordered_map>
namespace net {
namespace {
constexpr char kTag[] = "WifiScanner";
/**
* @brief authToken — map ESP-IDF auth mode to a short API token.
*
* @dname authToken
* @param auth wifi_ap_record_t::authmode value.
* @return Stable lowercase token for JSON.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] const char* authToken(wifi_auth_mode_t auth) noexcept
{
switch (auth) {
case WIFI_AUTH_OPEN:
return "open";
case WIFI_AUTH_WEP:
return "wep";
case WIFI_AUTH_WPA_PSK:
return "wpa";
case WIFI_AUTH_WPA2_PSK:
return "wpa2";
case WIFI_AUTH_WPA_WPA2_PSK:
return "wpa_wpa2";
case WIFI_AUTH_WPA2_ENTERPRISE:
return "wpa2_enterprise";
case WIFI_AUTH_WPA3_PSK:
return "wpa3";
case WIFI_AUTH_WPA2_WPA3_PSK:
return "wpa2_wpa3";
case WIFI_AUTH_WAPI_PSK:
return "wapi";
default:
return "unknown";
}
}
/**
* @brief ssidFromRecord — read a possibly unterminated SSID field.
*
* @dname ssidFromRecord
* @param record Raw ESP-IDF scan entry.
* @return SSID string (empty when hidden).
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string ssidFromRecord(const wifi_ap_record_t& record)
{
const std::size_t len =
strnlen(reinterpret_cast<const char*>(record.ssid), sizeof(record.ssid));
return std::string(reinterpret_cast<const char*>(record.ssid), len);
}
/**
* @brief ensureStaNetif — create default STA netif when missing.
*
* @dname ensureStaNetif
* @return true when STA netif exists or was created.
* @pubstate may call esp_netif_create_default_wifi_sta().
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] bool ensureStaNetif() noexcept
{
if (esp_netif_get_handle_from_ifkey("WIFI_STA_DEF") != nullptr) {
return true;
}
return esp_netif_create_default_wifi_sta() != nullptr;
}
/**
* @brief dedupeBySsid — keep strongest RSSI per SSID.
*
* @dname dedupeBySsid
* @param records Raw scan rows from esp_wifi.
* @return Sorted networks, strongest first.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::vector<core::WifiScannedNetwork>
dedupeBySsid(const std::vector<wifi_ap_record_t>& records)
{
std::unordered_map<std::string, core::WifiScannedNetwork> best;
best.reserve(records.size());
for (const wifi_ap_record_t& record : records) {
const std::string ssid = ssidFromRecord(record);
if (ssid.empty()) {
continue;
}
core::WifiScannedNetwork entry = {
.ssid = ssid,
.rssiDbm = record.rssi,
.auth = authToken(record.authmode),
.channel = record.primary,
};
const auto existing = best.find(ssid);
if (existing == best.end() || entry.rssiDbm > existing->second.rssiDbm) {
best.emplace(ssid, std::move(entry));
}
}
std::vector<core::WifiScannedNetwork> networks;
networks.reserve(best.size());
for (auto& item : best) {
networks.push_back(std::move(item.second));
}
std::sort(networks.begin(), networks.end(),
[](const core::WifiScannedNetwork& left,
const core::WifiScannedNetwork& right) {
return left.rssiDbm > right.rssiDbm;
});
return networks;
}
} // namespace
std::expected<std::vector<core::WifiScannedNetwork>, NetError>
WifiScanner::scanNearby()
{
wifi_mode_t mode = WIFI_MODE_NULL;
if (esp_wifi_get_mode(&mode) != ESP_OK) {
ESP_LOGE(kTag, "esp_wifi_get_mode failed");
return std::unexpected(NetError::WifiScanFailed);
}
if (mode == WIFI_MODE_AP) {
if (!ensureStaNetif()) {
ESP_LOGE(kTag, "STA netif creation failed");
return std::unexpected(NetError::WifiScanFailed);
}
wifi_config_t staCfg = {};
if (esp_wifi_set_config(WIFI_IF_STA, &staCfg) != ESP_OK) {
ESP_LOGE(kTag, "STA config for scan failed");
return std::unexpected(NetError::WifiScanFailed);
}
if (esp_wifi_set_mode(WIFI_MODE_APSTA) != ESP_OK) {
ESP_LOGE(kTag, "APSTA mode switch failed");
return std::unexpected(NetError::WifiScanFailed);
}
vTaskDelay(pdMS_TO_TICKS(300));
}
(void)esp_wifi_scan_stop();
(void)esp_wifi_clear_ap_list();
wifi_scan_config_t scanCfg = {};
scanCfg.channel = 0;
scanCfg.show_hidden = true;
scanCfg.scan_type = WIFI_SCAN_TYPE_ACTIVE;
scanCfg.scan_time.active.min = 300U;
scanCfg.scan_time.active.max = 1200U;
const esp_err_t scanErr = esp_wifi_scan_start(&scanCfg, true);
if (scanErr != ESP_OK) {
ESP_LOGE(kTag, "esp_wifi_scan_start failed (%d)", static_cast<int>(scanErr));
return std::unexpected(NetError::WifiScanFailed);
}
std::uint16_t count = 0U;
if (esp_wifi_scan_get_ap_num(&count) != ESP_OK) {
ESP_LOGE(kTag, "esp_wifi_scan_get_ap_num failed");
return std::unexpected(NetError::WifiScanFailed);
}
std::vector<wifi_ap_record_t> records(count);
if (count > 0U
&& esp_wifi_scan_get_ap_records(&count, records.data()) != ESP_OK) {
ESP_LOGE(kTag, "esp_wifi_scan_get_ap_records failed");
return std::unexpected(NetError::WifiScanFailed);
}
records.resize(count);
ESP_LOGI(kTag, "raw AP count %u", static_cast<unsigned>(count));
const auto networks = dedupeBySsid(records);
ESP_LOGI(kTag, "scan found %u unique network(s)",
static_cast<unsigned>(networks.size()));
return networks;
}
} // namespace net
File diff suppressed because it is too large Load Diff
Binary file not shown.