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:
@@ -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
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user