From 587797cc0e57ec1aceabbae64bc6cc99843da026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20SAILLANT?= <108685187+electron-rare@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:47:28 +0100 Subject: [PATCH] feat(firmware): integrate VSCode WIP --- .gitignore | 9 + Makefile | 10 + hardware/firmware/esp32/data/net/config.json | 17 + .../firmware/esp32/data/radio/stations.json | 29 ++ .../esp32/src/runtime/radio_runtime.cpp | 169 ++++++++++ .../esp32/src/runtime/radio_runtime.h | 75 +++++ .../src/services/network/wifi_service.cpp | 170 ++++++++++ .../esp32/src/services/network/wifi_service.h | 41 +++ .../src/services/radio/radio_service.cpp | 148 +++++++++ .../esp32/src/services/radio/radio_service.h | 52 +++ .../src/services/radio/station_repository.cpp | 222 +++++++++++++ .../src/services/radio/station_repository.h | 36 +++ .../src/services/radio/stream_pipeline.cpp | 137 ++++++++ .../src/services/radio/stream_pipeline.h | 46 +++ .../services/serial/serial_commands_radio.cpp | 301 ++++++++++++++++++ .../services/serial/serial_commands_radio.h | 22 ++ .../esp32/src/services/web/web_ui_service.cpp | 158 +++++++++ .../esp32/src/services/web/web_ui_service.h | 34 ++ printables/export/png/zacus_v1/.gitkeep | 0 tools/images/README.md | 16 + tools/images/generate_printables.py | 118 +++++++ 21 files changed, 1810 insertions(+) create mode 100644 hardware/firmware/esp32/data/net/config.json create mode 100644 hardware/firmware/esp32/data/radio/stations.json create mode 100644 hardware/firmware/esp32/src/runtime/radio_runtime.cpp create mode 100644 hardware/firmware/esp32/src/runtime/radio_runtime.h create mode 100644 hardware/firmware/esp32/src/services/network/wifi_service.cpp create mode 100644 hardware/firmware/esp32/src/services/network/wifi_service.h create mode 100644 hardware/firmware/esp32/src/services/radio/radio_service.cpp create mode 100644 hardware/firmware/esp32/src/services/radio/radio_service.h create mode 100644 hardware/firmware/esp32/src/services/radio/station_repository.cpp create mode 100644 hardware/firmware/esp32/src/services/radio/station_repository.h create mode 100644 hardware/firmware/esp32/src/services/radio/stream_pipeline.cpp create mode 100644 hardware/firmware/esp32/src/services/radio/stream_pipeline.h create mode 100644 hardware/firmware/esp32/src/services/serial/serial_commands_radio.cpp create mode 100644 hardware/firmware/esp32/src/services/serial/serial_commands_radio.h create mode 100644 hardware/firmware/esp32/src/services/web/web_ui_service.cpp create mode 100644 hardware/firmware/esp32/src/services/web/web_ui_service.h create mode 100644 printables/export/png/zacus_v1/.gitkeep create mode 100644 tools/images/README.md create mode 100755 tools/images/generate_printables.py diff --git a/.gitignore b/.gitignore index 12327c9..32bdba1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,14 @@ audio/generated/* !audio/generated/README.md # Locally generated printable PDFs (not versioned) +<<<<<<< Updated upstream printables/export/pdf/zacus_v1/* !printables/export/pdf/zacus_v1/README.md +======= +printables/export/pdf/** +!printables/export/pdf/zacus_v1/README.md + +# Locally generated printable PNGs (not versioned) +printables/export/png/** +!printables/export/png/zacus_v1/.gitkeep +>>>>>>> Stashed changes diff --git a/Makefile b/Makefile index 6218d67..bcb426f 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,10 @@ PYTHON ?= python3 +<<<<<<< Updated upstream .PHONY: scenario-validate audio-validate printables-validate export all-validate +======= +.PHONY: scenario-validate audio-validate printables-validate export all-validate images +>>>>>>> Stashed changes scenario-validate: $(PYTHON) tools/scenario/validate_scenario.py game/scenarios/zacus_v1.yaml @@ -16,3 +20,9 @@ export: all-validate: scenario-validate audio-validate printables-validate @echo "All validations passed." +<<<<<<< Updated upstream +======= + +images: + $(PYTHON) tools/images/generate_printables.py --manifest printables/manifests/zacus_v1_printables.yaml +>>>>>>> Stashed changes diff --git a/hardware/firmware/esp32/data/net/config.json b/hardware/firmware/esp32/data/net/config.json new file mode 100644 index 0000000..3def0a9 --- /dev/null +++ b/hardware/firmware/esp32/data/net/config.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "hostname": "u-son-radio", + "sta": { + "ssid": "", + "pass": "" + }, + "ap": { + "enabled": true, + "ssid": "U-SON-RADIO", + "pass": "usonradio" + }, + "web": { + "port": 80, + "auth": false + } +} diff --git a/hardware/firmware/esp32/data/radio/stations.json b/hardware/firmware/esp32/data/radio/stations.json new file mode 100644 index 0000000..fe05879 --- /dev/null +++ b/hardware/firmware/esp32/data/radio/stations.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "stations": [ + { + "id": 1, + "name": "NOVA Radio", + "url": "http://novazz.ice.infomaniak.ch/novazz-128.mp3", + "codec": "MP3", + "favorite": true, + "enabled": true + }, + { + "id": 2, + "name": "FG Chic", + "url": "http://radiofg.impek.com/fg", + "codec": "MP3", + "favorite": false, + "enabled": true + }, + { + "id": 3, + "name": "SomaFM Groove", + "url": "http://ice1.somafm.com/groovesalad-128-mp3", + "codec": "MP3", + "favorite": false, + "enabled": true + } + ] +} diff --git a/hardware/firmware/esp32/src/runtime/radio_runtime.cpp b/hardware/firmware/esp32/src/runtime/radio_runtime.cpp new file mode 100644 index 0000000..04b9854 --- /dev/null +++ b/hardware/firmware/esp32/src/runtime/radio_runtime.cpp @@ -0,0 +1,169 @@ +#include "radio_runtime.h" + +#include "../services/network/wifi_service.h" +#include "../services/radio/radio_service.h" +#include "../services/web/web_ui_service.h" + +namespace { + +constexpr uint16_t kCmdQueueLen = 24U; +constexpr uint16_t kEvtQueueLen = 24U; +constexpr uint32_t kTickAudioMs = 15U; +constexpr uint32_t kTickStreamMs = 20U; +constexpr uint32_t kTickStorageMs = 40U; +constexpr uint32_t kTickWebMs = 15U; +constexpr uint32_t kTickUiMs = 20U; + +} // namespace + +void RadioRuntime::begin(bool enableTasks, WifiService* wifi, RadioService* radio, WebUiService* web) { + wifi_ = wifi; + radio_ = radio; + web_ = web; + + metrics_ = Metrics(); + metrics_.enabled = enableTasks; + enabled_ = enableTasks; + + if (!enabled_) { + started_ = true; + metrics_.started = true; + return; + } + + cmdQueue_ = xQueueCreate(kCmdQueueLen, sizeof(Command)); + evtQueue_ = xQueueCreate(kEvtQueueLen, sizeof(Command)); + flags_ = xEventGroupCreate(); + + if (cmdQueue_ == nullptr || evtQueue_ == nullptr || flags_ == nullptr) { + enabled_ = false; + metrics_.enabled = false; + started_ = true; + metrics_.started = true; + return; + } + + createTasks(); + started_ = true; + metrics_.started = true; +} + +void RadioRuntime::updateCooperative(uint32_t nowMs) { + if (!started_ || enabled_) { + return; + } + if (wifi_ != nullptr) { + wifi_->update(nowMs); + } + if (radio_ != nullptr) { + radio_->update(nowMs); + } + if (web_ != nullptr) { + web_->update(nowMs); + } +} + +bool RadioRuntime::enqueueCommand(const Command& cmd) { + if (cmdQueue_ == nullptr) { + ++metrics_.cmdDropped; + return false; + } + const BaseType_t ok = xQueueSend(cmdQueue_, &cmd, 0U); + if (ok == pdTRUE) { + ++metrics_.cmdQueued; + return true; + } + ++metrics_.cmdDropped; + return false; +} + +RadioRuntime::Metrics RadioRuntime::metrics() const { + return metrics_; +} + +void RadioRuntime::createTasks() { + // Core ownership policy: + // - Core 1: audio + ui orchestration + // - Core 0: stream/net + storage + web control + xTaskCreatePinnedToCore(taskAudioThunk, "TaskAudioEngine", 3072, this, 4, &taskAudio_, 1); + xTaskCreatePinnedToCore(taskStreamThunk, "TaskStreamNet", 4096, this, 3, &taskStream_, 0); + xTaskCreatePinnedToCore(taskStorageThunk, "TaskStorageScan", 3072, this, 2, &taskStorage_, 0); + xTaskCreatePinnedToCore(taskWebThunk, "TaskWebControl", 4096, this, 2, &taskWeb_, 0); + xTaskCreatePinnedToCore(taskUiThunk, "TaskUiOrchestrator", 3072, this, 2, &taskUi_, 1); +} + +void RadioRuntime::taskAudioThunk(void* arg) { + reinterpret_cast(arg)->taskAudioLoop(); +} + +void RadioRuntime::taskStreamThunk(void* arg) { + reinterpret_cast(arg)->taskStreamLoop(); +} + +void RadioRuntime::taskStorageThunk(void* arg) { + reinterpret_cast(arg)->taskStorageLoop(); +} + +void RadioRuntime::taskWebThunk(void* arg) { + reinterpret_cast(arg)->taskWebLoop(); +} + +void RadioRuntime::taskUiThunk(void* arg) { + reinterpret_cast(arg)->taskUiLoop(); +} + +void RadioRuntime::taskAudioLoop() { + while (true) { + ++metrics_.audioTicks; + vTaskDelay(pdMS_TO_TICKS(kTickAudioMs)); + } +} + +void RadioRuntime::taskStreamLoop() { + while (true) { + const uint32_t nowMs = millis(); + if (wifi_ != nullptr) { + wifi_->update(nowMs); + } + if (radio_ != nullptr) { + radio_->update(nowMs); + } + ++metrics_.streamTicks; + vTaskDelay(pdMS_TO_TICKS(kTickStreamMs)); + } +} + +void RadioRuntime::taskStorageLoop() { + while (true) { + ++metrics_.storageTicks; + vTaskDelay(pdMS_TO_TICKS(kTickStorageMs)); + } +} + +void RadioRuntime::taskWebLoop() { + while (true) { + const uint32_t nowMs = millis(); + if (web_ != nullptr) { + web_->update(nowMs); + } + ++metrics_.webTicks; + vTaskDelay(pdMS_TO_TICKS(kTickWebMs)); + } +} + +void RadioRuntime::taskUiLoop() { + Command cmd; + while (true) { + while (cmdQueue_ != nullptr && xQueueReceive(cmdQueue_, &cmd, 0U) == pdTRUE) { + if (cmd.type == CommandType::kScanWifi && wifi_ != nullptr) { + wifi_->requestScan("runtime_queue"); + } + if (evtQueue_ != nullptr) { + xQueueSend(evtQueue_, &cmd, 0U); + ++metrics_.evtPushed; + } + } + ++metrics_.uiTicks; + vTaskDelay(pdMS_TO_TICKS(kTickUiMs)); + } +} diff --git a/hardware/firmware/esp32/src/runtime/radio_runtime.h b/hardware/firmware/esp32/src/runtime/radio_runtime.h new file mode 100644 index 0000000..1919c05 --- /dev/null +++ b/hardware/firmware/esp32/src/runtime/radio_runtime.h @@ -0,0 +1,75 @@ +#pragma once + +#include + +#include +#include +#include +#include + +class WifiService; +class RadioService; +class WebUiService; + +class RadioRuntime { + public: + enum class CommandType : uint8_t { + kNone = 0, + kRefreshStatus, + kScanWifi, + }; + + struct Command { + CommandType type = CommandType::kNone; + uint32_t arg = 0U; + }; + + struct Metrics { + bool enabled = false; + bool started = false; + uint32_t cmdQueued = 0U; + uint32_t cmdDropped = 0U; + uint32_t evtPushed = 0U; + uint32_t streamTicks = 0U; + uint32_t webTicks = 0U; + uint32_t uiTicks = 0U; + uint32_t storageTicks = 0U; + uint32_t audioTicks = 0U; + }; + + void begin(bool enableTasks, WifiService* wifi, RadioService* radio, WebUiService* web); + void updateCooperative(uint32_t nowMs); + bool enqueueCommand(const Command& cmd); + Metrics metrics() const; + + private: + static void taskAudioThunk(void* arg); + static void taskStreamThunk(void* arg); + static void taskStorageThunk(void* arg); + static void taskWebThunk(void* arg); + static void taskUiThunk(void* arg); + + void taskAudioLoop(); + void taskStreamLoop(); + void taskStorageLoop(); + void taskWebLoop(); + void taskUiLoop(); + + void createTasks(); + + WifiService* wifi_ = nullptr; + RadioService* radio_ = nullptr; + WebUiService* web_ = nullptr; + + bool enabled_ = false; + bool started_ = false; + QueueHandle_t cmdQueue_ = nullptr; + QueueHandle_t evtQueue_ = nullptr; + EventGroupHandle_t flags_ = nullptr; + TaskHandle_t taskAudio_ = nullptr; + TaskHandle_t taskStream_ = nullptr; + TaskHandle_t taskStorage_ = nullptr; + TaskHandle_t taskWeb_ = nullptr; + TaskHandle_t taskUi_ = nullptr; + Metrics metrics_; +}; diff --git a/hardware/firmware/esp32/src/services/network/wifi_service.cpp b/hardware/firmware/esp32/src/services/network/wifi_service.cpp new file mode 100644 index 0000000..304ad6b --- /dev/null +++ b/hardware/firmware/esp32/src/services/network/wifi_service.cpp @@ -0,0 +1,170 @@ +#include "wifi_service.h" + +#include + +namespace { + +constexpr uint32_t kScanPollMs = 300U; +constexpr uint32_t kApFallbackDelayMs = 12000U; + +void copyText(char* out, size_t outLen, const char* text) { + if (out == nullptr || outLen == 0U) { + return; + } + out[0] = '\0'; + if (text == nullptr || text[0] == '\0') { + return; + } + snprintf(out, outLen, "%s", text); +} + +const char* wifiModeLabel(wifi_mode_t mode) { + switch (mode) { + case WIFI_MODE_NULL: + return "OFF"; + case WIFI_MODE_STA: + return "STA"; + case WIFI_MODE_AP: + return "AP"; + case WIFI_MODE_APSTA: + return "AP_STA"; + default: + return "UNKNOWN"; + } +} + +} // namespace + +void WifiService::begin(const char* hostname) { + snap_ = Snapshot(); + WiFi.mode(WIFI_STA); + WiFi.setSleep(false); + WiFi.setAutoReconnect(true); + if (hostname != nullptr && hostname[0] != '\0') { + WiFi.setHostname(hostname); + } + WiFi.disconnect(true, true); + setEvent("BEGIN"); +} + +void WifiService::update(uint32_t nowMs) { + if (scanRequested_ && !scanInFlight_) { + const int started = WiFi.scanNetworks(true, true); + if (started >= 0) { + scanInFlight_ = true; + scanRequested_ = false; + lastScanStartMs_ = nowMs; + setEvent("SCAN_START"); + } else { + setError("SCAN_FAIL"); + scanRequested_ = false; + setEvent("SCAN_REJECT"); + } + } + + if (scanInFlight_ && static_cast(nowMs - lastScanStartMs_) >= static_cast(kScanPollMs)) { + const int n = WiFi.scanComplete(); + if (n >= 0) { + snap_.scanCount = static_cast(n); + WiFi.scanDelete(); + scanInFlight_ = false; + setEvent("SCAN_DONE"); + } else if (n == WIFI_SCAN_FAILED) { + scanInFlight_ = false; + setError("SCAN_FAILED"); + setEvent("SCAN_FAIL"); + } + } + + if (apAutoFallback_ && WiFi.status() != WL_CONNECTED && !snap_.apEnabled && + static_cast(nowMs - lastStaAttemptMs_) >= static_cast(kApFallbackDelayMs)) { + enableAp("U-SON-RADIO", "usonradio", "AP_FALLBACK"); + } + + updateSnapshot(nowMs); +} + +bool WifiService::requestScan(const char* reason) { + (void)reason; + if (scanInFlight_) { + return false; + } + scanRequested_ = true; + setEvent("SCAN_REQ"); + return true; +} + +bool WifiService::connectSta(const char* ssid, const char* pass, const char* reason) { + if (ssid == nullptr || ssid[0] == '\0') { + setError("SSID_EMPTY"); + return false; + } + + WiFi.mode(snap_.apEnabled ? WIFI_AP_STA : WIFI_STA); + WiFi.begin(ssid, (pass != nullptr) ? pass : ""); + lastStaAttemptMs_ = millis(); + copyText(snap_.ssid, sizeof(snap_.ssid), ssid); + setEvent((reason != nullptr) ? reason : "STA_CONNECT"); + return true; +} + +bool WifiService::enableAp(const char* ssid, const char* pass, const char* reason) { + const char* apSsid = (ssid != nullptr && ssid[0] != '\0') ? ssid : "U-SON-RADIO"; + const char* apPass = (pass != nullptr) ? pass : "usonradio"; + + WiFi.mode(WIFI_AP_STA); + const bool ok = WiFi.softAP(apSsid, apPass); + if (ok) { + snap_.apEnabled = true; + setEvent((reason != nullptr) ? reason : "AP_ON"); + } else { + setError("AP_FAIL"); + } + return ok; +} + +void WifiService::disableAp(const char* reason) { + WiFi.softAPdisconnect(true); + snap_.apEnabled = false; + WiFi.mode(WIFI_STA); + setEvent((reason != nullptr) ? reason : "AP_OFF"); +} + +WifiService::Snapshot WifiService::snapshot() const { + return snap_; +} + +bool WifiService::isConnected() const { + return snap_.staConnected; +} + +bool WifiService::isApEnabled() const { + return snap_.apEnabled; +} + +void WifiService::setEvent(const char* event) { + copyText(snap_.lastEvent, sizeof(snap_.lastEvent), event); +} + +void WifiService::setError(const char* error) { + copyText(snap_.lastError, sizeof(snap_.lastError), error); +} + +void WifiService::updateSnapshot(uint32_t nowMs) { + (void)nowMs; + snap_.scanning = scanInFlight_ || scanRequested_; + snap_.staConnected = (WiFi.status() == WL_CONNECTED); + snap_.rssi = snap_.staConnected ? WiFi.RSSI() : 0; + snap_.apEnabled = WiFi.getMode() == WIFI_MODE_AP || WiFi.getMode() == WIFI_MODE_APSTA; + copyText(snap_.mode, sizeof(snap_.mode), wifiModeLabel(WiFi.getMode())); + + if (snap_.staConnected) { + const IPAddress ip = WiFi.localIP(); + snprintf(snap_.ip, sizeof(snap_.ip), "%u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]); + } else if (snap_.apEnabled) { + const IPAddress ip = WiFi.softAPIP(); + snprintf(snap_.ip, sizeof(snap_.ip), "%u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]); + } else { + copyText(snap_.ip, sizeof(snap_.ip), "0.0.0.0"); + } +} diff --git a/hardware/firmware/esp32/src/services/network/wifi_service.h b/hardware/firmware/esp32/src/services/network/wifi_service.h new file mode 100644 index 0000000..7e23314 --- /dev/null +++ b/hardware/firmware/esp32/src/services/network/wifi_service.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +class WifiService { + public: + struct Snapshot { + bool staConnected = false; + bool apEnabled = false; + bool scanning = false; + char mode[16] = "OFF"; + char ssid[33] = ""; + char ip[20] = "0.0.0.0"; + int32_t rssi = 0; + uint16_t scanCount = 0; + char lastError[32] = "OK"; + char lastEvent[32] = "INIT"; + }; + + void begin(const char* hostname); + void update(uint32_t nowMs); + bool requestScan(const char* reason); + bool connectSta(const char* ssid, const char* pass, const char* reason); + bool enableAp(const char* ssid, const char* pass, const char* reason); + void disableAp(const char* reason); + Snapshot snapshot() const; + bool isConnected() const; + bool isApEnabled() const; + + private: + void setEvent(const char* event); + void setError(const char* error); + void updateSnapshot(uint32_t nowMs); + + Snapshot snap_; + bool scanRequested_ = false; + bool scanInFlight_ = false; + bool apAutoFallback_ = true; + uint32_t lastStaAttemptMs_ = 0U; + uint32_t lastScanStartMs_ = 0U; +}; diff --git a/hardware/firmware/esp32/src/services/radio/radio_service.cpp b/hardware/firmware/esp32/src/services/radio/radio_service.cpp new file mode 100644 index 0000000..8e067c6 --- /dev/null +++ b/hardware/firmware/esp32/src/services/radio/radio_service.cpp @@ -0,0 +1,148 @@ +#include "radio_service.h" + +namespace { + +void copyText(char* out, size_t outLen, const char* text) { + if (out == nullptr || outLen == 0U) { + return; + } + out[0] = '\0'; + if (text == nullptr || text[0] == '\0') { + return; + } + snprintf(out, outLen, "%s", text); +} + +} // namespace + +void RadioService::begin(fs::FS* fs, const char* stationsPath, WifiService* wifiService) { + wifi_ = wifiService; + pipeline_.begin(); + snap_ = Snapshot(); + initialized_ = true; + + bool loaded = false; + if (fs != nullptr && stationsPath != nullptr && stationsPath[0] != '\0') { + loaded = repo_.loadFromFs(*fs, stationsPath); + } + if (!loaded) { + repo_.loadDefaults(); + setEvent("STATIONS_DEFAULT"); + } else { + setEvent("STATIONS_FS"); + } +} + +void RadioService::update(uint32_t nowMs) { + if (!initialized_) { + return; + } + const bool networkReady = (wifi_ != nullptr) && (wifi_->isConnected() || wifi_->isApEnabled()); + pipeline_.update(nowMs, networkReady); + updateSnapshot(nowMs); +} + +bool RadioService::playById(uint16_t stationId, const char* source) { + const int16_t idx = repo_.indexById(stationId); + if (idx < 0) { + setError("STATION_NOT_FOUND"); + return false; + } + return playStationIndex(static_cast(idx), source); +} + +bool RadioService::playByUrl(const char* url, const char* source) { + if (!pipeline_.start(url, "AUTO", source)) { + setError("PLAY_URL_FAIL"); + return false; + } + currentIndex_ = 0U; + snap_.activeStationId = 0U; + copyText(snap_.activeStationName, sizeof(snap_.activeStationName), "Direct URL"); + setEvent(source != nullptr ? source : "PLAY_URL"); + return true; +} + +bool RadioService::stop(const char* source) { + pipeline_.stop(source != nullptr ? source : "STOP"); + setEvent(source != nullptr ? source : "STOP"); + return true; +} + +bool RadioService::next(const char* source) { + const uint16_t total = repo_.count(); + if (total == 0U) { + setError("NO_STATION"); + return false; + } + const uint16_t nextIndex = static_cast((currentIndex_ + 1U) % total); + return playStationIndex(nextIndex, source != nullptr ? source : "NEXT"); +} + +bool RadioService::prev(const char* source) { + const uint16_t total = repo_.count(); + if (total == 0U) { + setError("NO_STATION"); + return false; + } + const uint16_t prevIndex = (currentIndex_ == 0U) ? static_cast(total - 1U) + : static_cast(currentIndex_ - 1U); + return playStationIndex(prevIndex, source != nullptr ? source : "PREV"); +} + +RadioService::Snapshot RadioService::snapshot() const { + return snap_; +} + +uint16_t RadioService::stationCount() const { + return repo_.count(); +} + +const StationRepository::Station* RadioService::stationAt(uint16_t index) const { + return repo_.at(index); +} + +const StationRepository::Station* RadioService::currentStation() const { + return repo_.at(currentIndex_); +} + +bool RadioService::playStationIndex(uint16_t index, const char* source) { + const StationRepository::Station* station = repo_.at(index); + if (station == nullptr || !station->enabled) { + setError("STATION_DISABLED"); + return false; + } + + if (!pipeline_.start(station->url, station->codec, source != nullptr ? source : "PLAY_STATION")) { + setError("STREAM_START_FAIL"); + return false; + } + + currentIndex_ = index; + snap_.activeStationId = station->id; + copyText(snap_.activeStationName, sizeof(snap_.activeStationName), station->name); + setEvent(source != nullptr ? source : "PLAY_STATION"); + return true; +} + +void RadioService::updateSnapshot(uint32_t nowMs) { + (void)nowMs; + const StreamPipeline::Snapshot stream = pipeline_.snapshot(); + snap_.active = pipeline_.isActive(); + copyText(snap_.streamState, sizeof(snap_.streamState), StreamPipeline::stateLabel(stream.state)); + copyText(snap_.title, sizeof(snap_.title), stream.title); + copyText(snap_.codec, sizeof(snap_.codec), stream.codec); + snap_.bitrateKbps = stream.bitrateKbps; + snap_.bufferPercent = stream.bufferPercent; + if (strncmp(stream.lastError, "OK", sizeof(stream.lastError)) != 0) { + copyText(snap_.lastError, sizeof(snap_.lastError), stream.lastError); + } +} + +void RadioService::setEvent(const char* event) { + copyText(snap_.lastEvent, sizeof(snap_.lastEvent), event); +} + +void RadioService::setError(const char* error) { + copyText(snap_.lastError, sizeof(snap_.lastError), error); +} diff --git a/hardware/firmware/esp32/src/services/radio/radio_service.h b/hardware/firmware/esp32/src/services/radio/radio_service.h new file mode 100644 index 0000000..ac22240 --- /dev/null +++ b/hardware/firmware/esp32/src/services/radio/radio_service.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include "station_repository.h" +#include "stream_pipeline.h" +#include "../network/wifi_service.h" + +class RadioService { + public: + struct Snapshot { + bool enabled = true; + bool active = false; + uint16_t activeStationId = 0U; + char activeStationName[40] = ""; + char streamState[16] = "IDLE"; + char title[96] = ""; + char codec[12] = "AUTO"; + uint16_t bitrateKbps = 0U; + uint8_t bufferPercent = 0U; + char lastError[32] = "OK"; + char lastEvent[32] = "INIT"; + }; + + void begin(fs::FS* fs, const char* stationsPath, WifiService* wifiService); + void update(uint32_t nowMs); + + bool playById(uint16_t stationId, const char* source); + bool playByUrl(const char* url, const char* source); + bool stop(const char* source); + bool next(const char* source); + bool prev(const char* source); + + Snapshot snapshot() const; + uint16_t stationCount() const; + const StationRepository::Station* stationAt(uint16_t index) const; + const StationRepository::Station* currentStation() const; + + private: + bool playStationIndex(uint16_t index, const char* source); + void updateSnapshot(uint32_t nowMs); + void setEvent(const char* event); + void setError(const char* error); + + StationRepository repo_; + StreamPipeline pipeline_; + WifiService* wifi_ = nullptr; + Snapshot snap_; + bool initialized_ = false; + uint16_t currentIndex_ = 0U; +}; diff --git a/hardware/firmware/esp32/src/services/radio/station_repository.cpp b/hardware/firmware/esp32/src/services/radio/station_repository.cpp new file mode 100644 index 0000000..f7bcc67 --- /dev/null +++ b/hardware/firmware/esp32/src/services/radio/station_repository.cpp @@ -0,0 +1,222 @@ +#include "station_repository.h" + +namespace { + +void copyText(char* out, size_t outLen, const String& in) { + if (out == nullptr || outLen == 0U) { + return; + } + out[0] = '\0'; + if (in.length() == 0U) { + return; + } + snprintf(out, outLen, "%s", in.c_str()); +} + +int findKeyValueStart(const String& obj, const char* key) { + if (key == nullptr || key[0] == '\0') { + return -1; + } + String marker = String("\"") + key + "\""; + int pos = obj.indexOf(marker); + if (pos < 0) { + return -1; + } + pos = obj.indexOf(':', pos + static_cast(marker.length())); + if (pos < 0) { + return -1; + } + ++pos; + while (pos < static_cast(obj.length()) && isspace(static_cast(obj[pos])) != 0) { + ++pos; + } + return pos; +} + +} // namespace + +bool StationRepository::loadFromFs(fs::FS& fs, const char* path) { + if (path == nullptr || path[0] == '\0') { + return false; + } + fs::File f = fs.open(path, FILE_READ); + if (!f) { + return false; + } + String json; + json.reserve(static_cast(f.size()) + 8U); + while (f.available()) { + json += static_cast(f.read()); + } + f.close(); + return parseJson(json); +} + +void StationRepository::loadDefaults() { + count_ = 0U; + + Station s1 = {}; + s1.id = 1U; + snprintf(s1.name, sizeof(s1.name), "NOVA Radio"); + snprintf(s1.url, sizeof(s1.url), "http://novazz.ice.infomaniak.ch/novazz-128.mp3"); + snprintf(s1.codec, sizeof(s1.codec), "MP3"); + s1.favorite = true; + stations_[count_++] = s1; + + Station s2 = {}; + s2.id = 2U; + snprintf(s2.name, sizeof(s2.name), "FG Chic"); + snprintf(s2.url, sizeof(s2.url), "http://radiofg.impek.com/fg"); + snprintf(s2.codec, sizeof(s2.codec), "MP3"); + stations_[count_++] = s2; + + Station s3 = {}; + s3.id = 3U; + snprintf(s3.name, sizeof(s3.name), "SomaFM Groove"); + snprintf(s3.url, sizeof(s3.url), "http://ice1.somafm.com/groovesalad-128-mp3"); + snprintf(s3.codec, sizeof(s3.codec), "MP3"); + stations_[count_++] = s3; +} + +uint16_t StationRepository::count() const { + return count_; +} + +const StationRepository::Station* StationRepository::at(uint16_t index) const { + if (index >= count_) { + return nullptr; + } + return &stations_[index]; +} + +const StationRepository::Station* StationRepository::findById(uint16_t id) const { + const int16_t idx = indexById(id); + if (idx < 0) { + return nullptr; + } + return &stations_[static_cast(idx)]; +} + +int16_t StationRepository::indexById(uint16_t id) const { + for (uint16_t i = 0U; i < count_; ++i) { + if (stations_[i].id == id) { + return static_cast(i); + } + } + return -1; +} + +bool StationRepository::parseJson(const String& json) { + count_ = 0U; + if (json.length() == 0U) { + return false; + } + + int pos = 0; + while (pos >= 0 && count_ < kMaxStations) { + const int begin = json.indexOf('{', pos); + if (begin < 0) { + break; + } + const int end = json.indexOf('}', begin + 1); + if (end < 0) { + break; + } + String obj = json.substring(begin, end + 1); + Station station = {}; + if (parseObject(obj, &station)) { + stations_[count_++] = station; + } + pos = end + 1; + } + return count_ > 0U; +} + +bool StationRepository::parseObject(const String& obj, Station* out) const { + if (out == nullptr) { + return false; + } + + uint32_t id = 0U; + String name; + String url; + String codec; + bool enabled = true; + bool favorite = false; + + if (!extractUInt(obj, "id", &id)) { + return false; + } + if (!extractQuoted(obj, "name", &name)) { + return false; + } + if (!extractQuoted(obj, "url", &url)) { + return false; + } + (void)extractQuoted(obj, "codec", &codec); + (void)extractBool(obj, "enabled", &enabled); + (void)extractBool(obj, "favorite", &favorite); + + out->id = static_cast(id); + copyText(out->name, sizeof(out->name), name); + copyText(out->url, sizeof(out->url), url); + if (codec.length() == 0) { + codec = "AUTO"; + } + copyText(out->codec, sizeof(out->codec), codec); + out->enabled = enabled; + out->favorite = favorite; + return out->name[0] != '\0' && out->url[0] != '\0'; +} + +bool StationRepository::extractQuoted(const String& obj, const char* key, String* out) { + if (out == nullptr) { + return false; + } + const int pos = findKeyValueStart(obj, key); + if (pos < 0 || pos >= static_cast(obj.length()) || obj[pos] != '"') { + return false; + } + const int end = obj.indexOf('"', pos + 1); + if (end < 0) { + return false; + } + *out = obj.substring(pos + 1, end); + return true; +} + +bool StationRepository::extractUInt(const String& obj, const char* key, uint32_t* out) { + if (out == nullptr) { + return false; + } + const int pos = findKeyValueStart(obj, key); + if (pos < 0) { + return false; + } + char* endPtr = nullptr; + const unsigned long value = strtoul(obj.c_str() + pos, &endPtr, 10); + if (endPtr == obj.c_str() + pos) { + return false; + } + *out = static_cast(value); + return true; +} + +bool StationRepository::extractBool(const String& obj, const char* key, bool* out) { + if (out == nullptr) { + return false; + } + const int pos = findKeyValueStart(obj, key); + if (pos < 0) { + return false; + } + if (strncmp(obj.c_str() + pos, "true", 4) == 0) { + *out = true; + return true; + } + if (strncmp(obj.c_str() + pos, "false", 5) == 0) { + *out = false; + return true; + } + return false; +} diff --git a/hardware/firmware/esp32/src/services/radio/station_repository.h b/hardware/firmware/esp32/src/services/radio/station_repository.h new file mode 100644 index 0000000..c782ecd --- /dev/null +++ b/hardware/firmware/esp32/src/services/radio/station_repository.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +class StationRepository { + public: + struct Station { + uint16_t id = 0U; + char name[40] = {}; + char url[160] = {}; + char codec[12] = {}; + bool favorite = false; + bool enabled = true; + }; + + static constexpr uint16_t kMaxStations = 40U; + + bool loadFromFs(fs::FS& fs, const char* path); + void loadDefaults(); + + uint16_t count() const; + const Station* at(uint16_t index) const; + const Station* findById(uint16_t id) const; + int16_t indexById(uint16_t id) const; + + private: + bool parseJson(const String& json); + bool parseObject(const String& obj, Station* out) const; + static bool extractQuoted(const String& obj, const char* key, String* out); + static bool extractUInt(const String& obj, const char* key, uint32_t* out); + static bool extractBool(const String& obj, const char* key, bool* out); + + Station stations_[kMaxStations] = {}; + uint16_t count_ = 0U; +}; diff --git a/hardware/firmware/esp32/src/services/radio/stream_pipeline.cpp b/hardware/firmware/esp32/src/services/radio/stream_pipeline.cpp new file mode 100644 index 0000000..bba88ab --- /dev/null +++ b/hardware/firmware/esp32/src/services/radio/stream_pipeline.cpp @@ -0,0 +1,137 @@ +#include "stream_pipeline.h" + +namespace { + +constexpr uint32_t kConnectMs = 450U; +constexpr uint32_t kBufferMs = 900U; +constexpr uint32_t kRetryBackoffMs = 1800U; + +} // namespace + +void StreamPipeline::begin() { + snap_ = Snapshot(); + stateSinceMs_ = millis(); +} + +void StreamPipeline::update(uint32_t nowMs, bool networkReady) { + if (snap_.state == State::kIdle) { + return; + } + + if (!networkReady) { + if (snap_.state != State::kRetrying) { + copyText(snap_.lastError, sizeof(snap_.lastError), "NET_DOWN"); + setState(State::kRetrying, nowMs); + nextRetryMs_ = nowMs + kRetryBackoffMs; + ++snap_.retries; + } + return; + } + + switch (snap_.state) { + case State::kConnecting: + if (nowMs - stateSinceMs_ >= kConnectMs) { + snap_.bufferPercent = 20U; + setState(State::kBuffering, nowMs); + } + break; + case State::kBuffering: + if (nowMs - stateSinceMs_ >= kBufferMs) { + snap_.bufferPercent = 100U; + if (snap_.title[0] == '\0') { + copyText(snap_.title, sizeof(snap_.title), "Flux radio actif"); + } + if (snap_.bitrateKbps == 0U) { + snap_.bitrateKbps = 128U; + } + setState(State::kStreaming, nowMs); + } else { + const uint32_t elapsed = nowMs - stateSinceMs_; + const uint8_t pct = static_cast(20U + (elapsed * 80U) / kBufferMs); + snap_.bufferPercent = pct; + } + break; + case State::kStreaming: + break; + case State::kRetrying: + if (static_cast(nowMs - nextRetryMs_) >= 0) { + setState(State::kConnecting, nowMs); + snap_.bufferPercent = 0U; + } + break; + case State::kError: + case State::kIdle: + default: + break; + } +} + +bool StreamPipeline::start(const char* url, const char* codec, const char* reason) { + (void)reason; + if (url == nullptr || url[0] == '\0') { + copyText(snap_.lastError, sizeof(snap_.lastError), "URL_EMPTY"); + setState(State::kError, millis()); + return false; + } + copyText(snap_.url, sizeof(snap_.url), url); + copyText(snap_.codec, sizeof(snap_.codec), (codec != nullptr && codec[0] != '\0') ? codec : "AUTO"); + copyText(snap_.title, sizeof(snap_.title), ""); + snap_.bitrateKbps = 0U; + snap_.bufferPercent = 0U; + copyText(snap_.lastError, sizeof(snap_.lastError), "OK"); + setState(State::kConnecting, millis()); + return true; +} + +void StreamPipeline::stop(const char* reason) { + copyText(snap_.lastError, sizeof(snap_.lastError), (reason != nullptr) ? reason : "STOP"); + snap_.bufferPercent = 0U; + setState(State::kIdle, millis()); +} + +StreamPipeline::Snapshot StreamPipeline::snapshot() const { + return snap_; +} + +bool StreamPipeline::isActive() const { + return snap_.state == State::kConnecting || + snap_.state == State::kBuffering || + snap_.state == State::kStreaming || + snap_.state == State::kRetrying; +} + +const char* StreamPipeline::stateLabel(State state) { + switch (state) { + case State::kIdle: + return "IDLE"; + case State::kConnecting: + return "CONNECTING"; + case State::kBuffering: + return "BUFFERING"; + case State::kStreaming: + return "STREAMING"; + case State::kRetrying: + return "RETRYING"; + case State::kError: + return "ERROR"; + default: + return "UNKNOWN"; + } +} + +void StreamPipeline::setState(State state, uint32_t nowMs) { + snap_.state = state; + snap_.lastStateMs = nowMs; + stateSinceMs_ = nowMs; +} + +void StreamPipeline::copyText(char* out, size_t outLen, const char* text) { + if (out == nullptr || outLen == 0U) { + return; + } + out[0] = '\0'; + if (text == nullptr || text[0] == '\0') { + return; + } + snprintf(out, outLen, "%s", text); +} diff --git a/hardware/firmware/esp32/src/services/radio/stream_pipeline.h b/hardware/firmware/esp32/src/services/radio/stream_pipeline.h new file mode 100644 index 0000000..a7e007d --- /dev/null +++ b/hardware/firmware/esp32/src/services/radio/stream_pipeline.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +class StreamPipeline { + public: + enum class State : uint8_t { + kIdle = 0, + kConnecting, + kBuffering, + kStreaming, + kRetrying, + kError, + }; + + struct Snapshot { + State state = State::kIdle; + char url[180] = {}; + char codec[12] = "AUTO"; + char title[96] = ""; + uint16_t bitrateKbps = 0U; + uint8_t bufferPercent = 0U; + uint32_t lastStateMs = 0U; + uint32_t retries = 0U; + char lastError[32] = "OK"; + }; + + void begin(); + void update(uint32_t nowMs, bool networkReady); + + bool start(const char* url, const char* codec, const char* reason); + void stop(const char* reason); + + Snapshot snapshot() const; + bool isActive() const; + + static const char* stateLabel(State state); + + private: + void setState(State state, uint32_t nowMs); + void copyText(char* out, size_t outLen, const char* text); + + Snapshot snap_; + uint32_t stateSinceMs_ = 0U; + uint32_t nextRetryMs_ = 0U; +}; diff --git a/hardware/firmware/esp32/src/services/serial/serial_commands_radio.cpp b/hardware/firmware/esp32/src/services/serial/serial_commands_radio.cpp new file mode 100644 index 0000000..73c1ef6 --- /dev/null +++ b/hardware/firmware/esp32/src/services/serial/serial_commands_radio.cpp @@ -0,0 +1,301 @@ +#include "serial_commands_radio.h" + +#include +#include +#include + +#include "serial_dispatch.h" +#include "../network/wifi_service.h" +#include "../radio/radio_service.h" +#include "../web/web_ui_service.h" + +namespace { + +const char* skipSpaces(const char* text) { + if (text == nullptr) { + return ""; + } + while (*text != '\0' && isspace(static_cast(*text)) != 0) { + ++text; + } + return text; +} + +bool equalsIgnoreCase(const char* lhs, const char* rhs) { + if (lhs == nullptr || rhs == nullptr) { + return false; + } + while (*lhs != '\0' && *rhs != '\0') { + if (toupper(static_cast(*lhs)) != toupper(static_cast(*rhs))) { + return false; + } + ++lhs; + ++rhs; + } + return *lhs == '\0' && *rhs == '\0'; +} + +void printWifiStatus(Print& out, const WifiService::Snapshot& s, const char* source) { + out.printf("[WIFI_STATUS] %s connected=%u ap=%u scanning=%u mode=%s ssid=%s ip=%s rssi=%ld scan=%u err=%s evt=%s\n", + source, + s.staConnected ? 1U : 0U, + s.apEnabled ? 1U : 0U, + s.scanning ? 1U : 0U, + s.mode, + s.ssid, + s.ip, + static_cast(s.rssi), + static_cast(s.scanCount), + s.lastError, + s.lastEvent); +} + +void printRadioStatus(Print& out, const RadioService::Snapshot& s, const char* source) { + out.printf("[RADIO_STATUS] %s active=%u id=%u station=%s state=%s codec=%s bitrate=%u buffer=%u err=%s evt=%s\n", + source, + s.active ? 1U : 0U, + static_cast(s.activeStationId), + s.activeStationName, + s.streamState, + s.codec, + static_cast(s.bitrateKbps), + static_cast(s.bufferPercent), + s.lastError, + s.lastEvent); +} + +void printWebStatus(Print& out, const WebUiService::Snapshot& s, const char* source) { + out.printf("[WEB_STATUS] %s started=%u port=%u req=%lu route=%s err=%s\n", + source, + s.started ? 1U : 0U, + static_cast(s.port), + static_cast(s.requestCount), + s.lastRoute, + s.lastError); +} + +} // namespace + +bool serialIsRadioCommand(const char* token) { + if (token == nullptr) { + return false; + } + return strncmp(token, "RADIO_", 6U) == 0 || strncmp(token, "WIFI_", 5U) == 0 || + strncmp(token, "WEB_", 4U) == 0; +} + +bool serialProcessRadioCommand(const SerialCommand& cmd, + uint32_t nowMs, + const RadioSerialRuntimeContext& ctx, + Print& out) { + (void)nowMs; + if (cmd.token == nullptr || cmd.token[0] == '\0') { + return false; + } + const char* args = skipSpaces(cmd.args); + + if (serialTokenEquals(cmd, "RADIO_HELP")) { + if (ctx.printHelp != nullptr) { + ctx.printHelp(); + } + serialDispatchReply(out, "RADIO", SerialDispatchResult::kOk, "help"); + return true; + } + + if (serialTokenEquals(cmd, "RADIO_STATUS")) { + if (ctx.radio == nullptr) { + serialDispatchReply(out, "RADIO", SerialDispatchResult::kOutOfContext, "missing_radio"); + return true; + } + printRadioStatus(out, ctx.radio->snapshot(), "status"); + serialDispatchReply(out, "RADIO", SerialDispatchResult::kOk, "status"); + return true; + } + + if (serialTokenEquals(cmd, "RADIO_LIST")) { + if (ctx.radio == nullptr) { + serialDispatchReply(out, "RADIO", SerialDispatchResult::kOutOfContext, "missing_radio"); + return true; + } + int offset = 0; + int limit = 8; + if (args[0] != '\0' && sscanf(args, "%d %d", &offset, &limit) < 1) { + serialDispatchReply(out, "RADIO", SerialDispatchResult::kBadArgs, "[offset limit]"); + return true; + } + if (offset < 0) { + offset = 0; + } + if (limit <= 0 || limit > 40) { + limit = 8; + } + + const uint16_t total = ctx.radio->stationCount(); + out.printf("[RADIO_LIST] total=%u offset=%u limit=%u\n", + static_cast(total), + static_cast(offset), + static_cast(limit)); + for (uint16_t i = static_cast(offset); + i < total && i < static_cast(offset + limit); + ++i) { + const StationRepository::Station* station = ctx.radio->stationAt(i); + if (station == nullptr) { + continue; + } + out.printf(" [%u] id=%u %s codec=%s enabled=%u fav=%u\n", + static_cast(i), + static_cast(station->id), + station->name, + station->codec, + station->enabled ? 1U : 0U, + station->favorite ? 1U : 0U); + } + serialDispatchReply(out, "RADIO", SerialDispatchResult::kOk, "list"); + return true; + } + + if (serialTokenEquals(cmd, "RADIO_PLAY")) { + if (ctx.radio == nullptr) { + serialDispatchReply(out, "RADIO", SerialDispatchResult::kOutOfContext, "missing_radio"); + return true; + } + if (args[0] == '\0') { + serialDispatchReply(out, "RADIO", SerialDispatchResult::kBadArgs, "RADIO_PLAY "); + return true; + } + uint16_t id = 0U; + if (sscanf(args, "%hu", &id) == 1) { + const bool ok = ctx.radio->playById(id, "serial_radio_play_id"); + serialDispatchReply(out, + "RADIO", + ok ? SerialDispatchResult::kOk : SerialDispatchResult::kNotFound, + ok ? "play_id" : "id"); + return true; + } + const bool ok = ctx.radio->playByUrl(args, "serial_radio_play_url"); + serialDispatchReply(out, + "RADIO", + ok ? SerialDispatchResult::kOk : SerialDispatchResult::kNotFound, + ok ? "play_url" : "url"); + return true; + } + + if (serialTokenEquals(cmd, "RADIO_STOP")) { + if (ctx.radio == nullptr) { + serialDispatchReply(out, "RADIO", SerialDispatchResult::kOutOfContext, "missing_radio"); + return true; + } + ctx.radio->stop("serial_radio_stop"); + serialDispatchReply(out, "RADIO", SerialDispatchResult::kOk, "stop"); + return true; + } + + if (serialTokenEquals(cmd, "RADIO_NEXT")) { + if (ctx.radio == nullptr) { + serialDispatchReply(out, "RADIO", SerialDispatchResult::kOutOfContext, "missing_radio"); + return true; + } + const bool ok = ctx.radio->next("serial_radio_next"); + serialDispatchReply(out, "RADIO", ok ? SerialDispatchResult::kOk : SerialDispatchResult::kNotFound, "next"); + return true; + } + + if (serialTokenEquals(cmd, "RADIO_PREV")) { + if (ctx.radio == nullptr) { + serialDispatchReply(out, "RADIO", SerialDispatchResult::kOutOfContext, "missing_radio"); + return true; + } + const bool ok = ctx.radio->prev("serial_radio_prev"); + serialDispatchReply(out, "RADIO", ok ? SerialDispatchResult::kOk : SerialDispatchResult::kNotFound, "prev"); + return true; + } + + if (serialTokenEquals(cmd, "RADIO_META")) { + if (ctx.radio == nullptr) { + serialDispatchReply(out, "RADIO", SerialDispatchResult::kOutOfContext, "missing_radio"); + return true; + } + printRadioStatus(out, ctx.radio->snapshot(), "meta"); + serialDispatchReply(out, "RADIO", SerialDispatchResult::kOk, "meta"); + return true; + } + + if (serialTokenEquals(cmd, "WIFI_STATUS")) { + if (ctx.wifi == nullptr) { + serialDispatchReply(out, "WIFI", SerialDispatchResult::kOutOfContext, "missing_wifi"); + return true; + } + printWifiStatus(out, ctx.wifi->snapshot(), "status"); + serialDispatchReply(out, "WIFI", SerialDispatchResult::kOk, "status"); + return true; + } + + if (serialTokenEquals(cmd, "WIFI_SCAN")) { + if (ctx.wifi == nullptr) { + serialDispatchReply(out, "WIFI", SerialDispatchResult::kOutOfContext, "missing_wifi"); + return true; + } + const bool ok = ctx.wifi->requestScan("serial_wifi_scan"); + serialDispatchReply(out, "WIFI", ok ? SerialDispatchResult::kOk : SerialDispatchResult::kBusy, "scan"); + return true; + } + + if (serialTokenEquals(cmd, "WIFI_CONNECT")) { + if (ctx.wifi == nullptr) { + serialDispatchReply(out, "WIFI", SerialDispatchResult::kOutOfContext, "missing_wifi"); + return true; + } + char ssid[33] = {}; + char pass[65] = {}; + if (sscanf(args, "%32s %64s", ssid, pass) < 1) { + serialDispatchReply(out, "WIFI", SerialDispatchResult::kBadArgs, "WIFI_CONNECT "); + return true; + } + const bool ok = ctx.wifi->connectSta(ssid, pass, "serial_wifi_connect"); + serialDispatchReply(out, "WIFI", ok ? SerialDispatchResult::kOk : SerialDispatchResult::kBadArgs, "connect"); + return true; + } + + if (serialTokenEquals(cmd, "WIFI_AP_ON")) { + if (ctx.wifi == nullptr) { + serialDispatchReply(out, "WIFI", SerialDispatchResult::kOutOfContext, "missing_wifi"); + return true; + } + char ssid[33] = {}; + char pass[65] = {}; + if (args[0] == '\0') { + const bool ok = ctx.wifi->enableAp("U-SON-RADIO", "usonradio", "serial_ap_on"); + serialDispatchReply(out, "WIFI", ok ? SerialDispatchResult::kOk : SerialDispatchResult::kBusy, "ap_on"); + return true; + } + if (sscanf(args, "%32s %64s", ssid, pass) < 1) { + serialDispatchReply(out, "WIFI", SerialDispatchResult::kBadArgs, "WIFI_AP_ON [ssid pass]"); + return true; + } + const bool ok = ctx.wifi->enableAp(ssid, pass, "serial_ap_on"); + serialDispatchReply(out, "WIFI", ok ? SerialDispatchResult::kOk : SerialDispatchResult::kBusy, "ap_on"); + return true; + } + + if (serialTokenEquals(cmd, "WIFI_AP_OFF")) { + if (ctx.wifi == nullptr) { + serialDispatchReply(out, "WIFI", SerialDispatchResult::kOutOfContext, "missing_wifi"); + return true; + } + ctx.wifi->disableAp("serial_ap_off"); + serialDispatchReply(out, "WIFI", SerialDispatchResult::kOk, "ap_off"); + return true; + } + + if (serialTokenEquals(cmd, "WEB_STATUS")) { + if (ctx.web == nullptr) { + serialDispatchReply(out, "WEB", SerialDispatchResult::kOutOfContext, "missing_web"); + return true; + } + printWebStatus(out, ctx.web->snapshot(), "status"); + serialDispatchReply(out, "WEB", SerialDispatchResult::kOk, "status"); + return true; + } + + return false; +} diff --git a/hardware/firmware/esp32/src/services/serial/serial_commands_radio.h b/hardware/firmware/esp32/src/services/serial/serial_commands_radio.h new file mode 100644 index 0000000..5dbf3fe --- /dev/null +++ b/hardware/firmware/esp32/src/services/serial/serial_commands_radio.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "serial_router.h" + +class WifiService; +class RadioService; +class WebUiService; + +struct RadioSerialRuntimeContext { + WifiService* wifi = nullptr; + RadioService* radio = nullptr; + WebUiService* web = nullptr; + void (*printHelp)() = nullptr; +}; + +bool serialIsRadioCommand(const char* token); +bool serialProcessRadioCommand(const SerialCommand& cmd, + uint32_t nowMs, + const RadioSerialRuntimeContext& ctx, + Print& out); diff --git a/hardware/firmware/esp32/src/services/web/web_ui_service.cpp b/hardware/firmware/esp32/src/services/web/web_ui_service.cpp new file mode 100644 index 0000000..c129ac0 --- /dev/null +++ b/hardware/firmware/esp32/src/services/web/web_ui_service.cpp @@ -0,0 +1,158 @@ +#include "web_ui_service.h" + +#include + +#include "../network/wifi_service.h" +#include "../radio/radio_service.h" +#include "../../audio/mp3_player.h" + +namespace { + +void copyText(char* out, size_t outLen, const char* text) { + if (out == nullptr || outLen == 0U) { + return; + } + out[0] = '\0'; + if (text == nullptr || text[0] == '\0') { + return; + } + snprintf(out, outLen, "%s", text); +} + +} // namespace + +void WebUiService::begin(WifiService* wifi, RadioService* radio, Mp3Player* mp3, uint16_t port) { + wifi_ = wifi; + radio_ = radio; + mp3_ = mp3; + snap_ = Snapshot(); + snap_.port = port; + + if (server_ != nullptr) { + delete server_; + server_ = nullptr; + } + + server_ = new WebServer(port); + if (server_ == nullptr) { + setError("ALLOC_FAIL"); + return; + } + + setupRoutes(); + server_->begin(); + snap_.started = true; + setRoute("BEGIN"); +} + +void WebUiService::update(uint32_t nowMs) { + (void)nowMs; + if (server_ == nullptr || !snap_.started) { + return; + } + server_->handleClient(); +} + +WebUiService::Snapshot WebUiService::snapshot() const { + return snap_; +} + +void WebUiService::setupRoutes() { + if (server_ == nullptr) { + return; + } + + server_->on("/", HTTP_GET, [this]() { + setRoute("/"); + ++snap_.requestCount; + const char* html = + "U-SON Radio" + "

U-SON RC V3

Endpoints: /api/status /api/radio /api/wifi

"; + server_->send(200, "text/html", html); + }); + + server_->on("/api/status", HTTP_GET, [this]() { + setRoute("/api/status"); + ++snap_.requestCount; + + String json = "{"; + if (wifi_ != nullptr) { + const WifiService::Snapshot w = wifi_->snapshot(); + json += "\"wifi\":{"; + json += "\"connected\":" + String(w.staConnected ? "true" : "false") + ","; + json += "\"ap\":" + String(w.apEnabled ? "true" : "false") + ","; + json += "\"mode\":\"" + String(w.mode) + "\","; + json += "\"ip\":\"" + String(w.ip) + "\"},"; + } + if (radio_ != nullptr) { + const RadioService::Snapshot r = radio_->snapshot(); + json += "\"radio\":{"; + json += "\"active\":" + String(r.active ? "true" : "false") + ","; + json += "\"station\":\"" + String(r.activeStationName) + "\","; + json += "\"state\":\"" + String(r.streamState) + "\","; + json += "\"buffer\":" + String(r.bufferPercent) + "},"; + } + if (mp3_ != nullptr) { + json += "\"mp3\":{"; + json += "\"playing\":" + String(mp3_->isPlaying() ? "true" : "false") + ","; + json += "\"tracks\":" + String(mp3_->trackCount()) + "}"; + } else { + if (json.endsWith(",")) { + json.remove(json.length() - 1); + } + } + if (json.endsWith(",")) { + json.remove(json.length() - 1); + } + json += "}"; + server_->send(200, "application/json", json); + }); + + server_->on("/api/radio", HTTP_GET, [this]() { + setRoute("/api/radio"); + ++snap_.requestCount; + if (radio_ == nullptr) { + server_->send(503, "application/json", "{\"error\":\"radio_unavailable\"}"); + return; + } + const RadioService::Snapshot r = radio_->snapshot(); + String json = "{"; + json += "\"active\":" + String(r.active ? "true" : "false") + ","; + json += "\"station_id\":" + String(r.activeStationId) + ","; + json += "\"station\":\"" + String(r.activeStationName) + "\","; + json += "\"state\":\"" + String(r.streamState) + "\","; + json += "\"title\":\"" + String(r.title) + "\"}"; + server_->send(200, "application/json", json); + }); + + server_->on("/api/wifi", HTTP_GET, [this]() { + setRoute("/api/wifi"); + ++snap_.requestCount; + if (wifi_ == nullptr) { + server_->send(503, "application/json", "{\"error\":\"wifi_unavailable\"}"); + return; + } + const WifiService::Snapshot w = wifi_->snapshot(); + String json = "{"; + json += "\"connected\":" + String(w.staConnected ? "true" : "false") + ","; + json += "\"ap\":" + String(w.apEnabled ? "true" : "false") + ","; + json += "\"mode\":\"" + String(w.mode) + "\","; + json += "\"ip\":\"" + String(w.ip) + "\","; + json += "\"scan_count\":" + String(w.scanCount) + "}"; + server_->send(200, "application/json", json); + }); + + server_->onNotFound([this]() { + setRoute("404"); + ++snap_.requestCount; + server_->send(404, "application/json", "{\"error\":\"not_found\"}"); + }); +} + +void WebUiService::setRoute(const char* route) { + copyText(snap_.lastRoute, sizeof(snap_.lastRoute), route); +} + +void WebUiService::setError(const char* error) { + copyText(snap_.lastError, sizeof(snap_.lastError), error); +} diff --git a/hardware/firmware/esp32/src/services/web/web_ui_service.h b/hardware/firmware/esp32/src/services/web/web_ui_service.h new file mode 100644 index 0000000..e295bb4 --- /dev/null +++ b/hardware/firmware/esp32/src/services/web/web_ui_service.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +class Mp3Player; +class WifiService; +class RadioService; +class WebServer; + +class WebUiService { + public: + struct Snapshot { + bool started = false; + uint16_t port = 80U; + uint32_t requestCount = 0U; + char lastRoute[32] = "-"; + char lastError[32] = "OK"; + }; + + void begin(WifiService* wifi, RadioService* radio, Mp3Player* mp3, uint16_t port = 80U); + void update(uint32_t nowMs); + Snapshot snapshot() const; + + private: + void setupRoutes(); + void setRoute(const char* route); + void setError(const char* error); + + WifiService* wifi_ = nullptr; + RadioService* radio_ = nullptr; + Mp3Player* mp3_ = nullptr; + WebServer* server_ = nullptr; + Snapshot snap_; +}; diff --git a/printables/export/png/zacus_v1/.gitkeep b/printables/export/png/zacus_v1/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tools/images/README.md b/tools/images/README.md new file mode 100644 index 0000000..f3ca1ec --- /dev/null +++ b/tools/images/README.md @@ -0,0 +1,16 @@ +# Printable image generator + +Creates PNG drafts for each printable asset with the OpenAI Images API. + +## Requirements +- `OPENAI_API_KEY` in your environment. +- Dependencies installed in `.venv` (`PyYAML`, `openai`). + +## Run +```sh +PYTHON=.venv/bin/python tools/images/generate_printables.py \ + --manifest printables/manifests/zacus_v1_printables.yaml +``` +Add `--force` to regenerate outputs even if files already exist. + +Outputs land in `printables/export/png/zacus_v1/` and mirror the IDs from the manifest. diff --git a/tools/images/generate_printables.py b/tools/images/generate_printables.py new file mode 100755 index 0000000..f8c9fd8 --- /dev/null +++ b/tools/images/generate_printables.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Generate printable PNGs from the official printable manifest. +""" + +from __future__ import annotations + +import argparse +import base64 +import os +import sys +from pathlib import Path + +try: + import yaml +except ImportError as exc: + print("Missing dependency: install PyYAML:", exc) + sys.exit(1) + +try: + import openai +except ImportError as exc: + print("Missing dependency: install openai", exc) + sys.exit(1) + +from openai import OpenAI, OpenAIError + +PRINTABLES_DIR = Path("printables") +MANIFEST_PATH = PRINTABLES_DIR / "manifests" / "zacus_v1_printables.yaml" +EXPORT_DIR = PRINTABLES_DIR / "export" / "png" / "zacus_v1" +MODEL = "gpt-image-1-mini" +SIZE = "1024x1024" +QUALITY = "low" +COST_PER_IMAGE = 0.005 # USD per low square image + + +def load_manifest(path: Path) -> list[dict]: + if not path.exists(): + raise FileNotFoundError(f"Manifest not found: {path}") + manifest = yaml.safe_load(path.read_text()) + items = manifest.get("items") or [] + if not isinstance(items, list): + raise ValueError("Manifest items must be a list") + return items + + +def load_prompt(item: dict) -> tuple[str, Path]: + prompt_rel = item.get("prompt") + if not prompt_rel: + raise ValueError(f"Missing prompt reference for item {item.get('id')}") + prompt_path = PRINTABLES_DIR / prompt_rel + if not prompt_path.exists(): + raise FileNotFoundError(f"Prompt file missing: {prompt_path}") + return prompt_path.read_text().strip(), prompt_path + + +def ensure_api_key() -> None: + if not os.environ.get("OPENAI_API_KEY"): + print("OPENAI_API_KEY is required for image generation") + sys.exit(1) + + +def generate_image(client: OpenAI, prompt: str) -> bytes: + response = client.images.generate( + model=MODEL, + prompt=prompt, + size=SIZE, + quality=QUALITY, + n=1, + output_format="png", + ) + asset = response.data[0] + data = asset.b64_json + return base64.b64decode(data) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate PNG printables via OpenAI Images") + parser.add_argument("--manifest", type=Path, default=MANIFEST_PATH, help="Printable manifest path") + parser.add_argument("--force", action="store_true", help="Regenerate even when PNG exists") + args = parser.parse_args() + + ensure_api_key() + client = OpenAI() + items = load_manifest(args.manifest) + EXPORT_DIR.mkdir(parents=True, exist_ok=True) + + counters = {"generated": 0, "skipped": 0, "failed": 0} + + for item in items: + item_id = item.get("id") + if not item_id: + print("Skipping entry without id") + counters["failed"] += 1 + continue + destination = EXPORT_DIR / f"{item_id}.png" + if destination.exists() and not args.force: + counters["skipped"] += 1 + continue + try: + prompt_text, prompt_path = load_prompt(item) + image_bytes = generate_image(client, prompt_text) + destination.write_bytes(image_bytes) + counters["generated"] += 1 + print(f"Generated {destination}") + except (OpenAIError, FileNotFoundError, ValueError) as exc: + print(f"Failed {item_id}: {exc}") + counters["failed"] += 1 + + estimated_cost = counters["generated"] * COST_PER_IMAGE + print( + f"Summary: generated={counters['generated']}, skipped={counters['skipped']}, failed={counters['failed']}." + f" Estimated cost: ${estimated_cost:.3f}" + ) + return 0 if counters["failed"] == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main())