feat(firmware): integrate VSCode WIP

This commit is contained in:
Clément SAILLANT
2026-02-14 14:47:28 +01:00
parent d63165969d
commit 587797cc0e
21 changed files with 1810 additions and 0 deletions
+9
View File
@@ -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
+10
View File
@@ -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
@@ -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
}
}
@@ -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
}
]
}
@@ -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<RadioRuntime*>(arg)->taskAudioLoop();
}
void RadioRuntime::taskStreamThunk(void* arg) {
reinterpret_cast<RadioRuntime*>(arg)->taskStreamLoop();
}
void RadioRuntime::taskStorageThunk(void* arg) {
reinterpret_cast<RadioRuntime*>(arg)->taskStorageLoop();
}
void RadioRuntime::taskWebThunk(void* arg) {
reinterpret_cast<RadioRuntime*>(arg)->taskWebLoop();
}
void RadioRuntime::taskUiThunk(void* arg) {
reinterpret_cast<RadioRuntime*>(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));
}
}
@@ -0,0 +1,75 @@
#pragma once
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/event_groups.h>
#include <freertos/queue.h>
#include <freertos/task.h>
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_;
};
@@ -0,0 +1,170 @@
#include "wifi_service.h"
#include <WiFi.h>
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<int32_t>(nowMs - lastScanStartMs_) >= static_cast<int32_t>(kScanPollMs)) {
const int n = WiFi.scanComplete();
if (n >= 0) {
snap_.scanCount = static_cast<uint16_t>(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<int32_t>(nowMs - lastStaAttemptMs_) >= static_cast<int32_t>(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");
}
}
@@ -0,0 +1,41 @@
#pragma once
#include <Arduino.h>
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;
};
@@ -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<uint16_t>(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<uint16_t>((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<uint16_t>(total - 1U)
: static_cast<uint16_t>(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);
}
@@ -0,0 +1,52 @@
#pragma once
#include <Arduino.h>
#include <FS.h>
#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;
};
@@ -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<int>(marker.length()));
if (pos < 0) {
return -1;
}
++pos;
while (pos < static_cast<int>(obj.length()) && isspace(static_cast<unsigned char>(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<size_t>(f.size()) + 8U);
while (f.available()) {
json += static_cast<char>(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<uint16_t>(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<int16_t>(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<uint16_t>(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<int>(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<uint32_t>(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;
}
@@ -0,0 +1,36 @@
#pragma once
#include <Arduino.h>
#include <FS.h>
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;
};
@@ -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<uint8_t>(20U + (elapsed * 80U) / kBufferMs);
snap_.bufferPercent = pct;
}
break;
case State::kStreaming:
break;
case State::kRetrying:
if (static_cast<int32_t>(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);
}
@@ -0,0 +1,46 @@
#pragma once
#include <Arduino.h>
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;
};
@@ -0,0 +1,301 @@
#include "serial_commands_radio.h"
#include <cctype>
#include <cstdio>
#include <cstring>
#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<unsigned char>(*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<unsigned char>(*lhs)) != toupper(static_cast<unsigned char>(*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<long>(s.rssi),
static_cast<unsigned int>(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<unsigned int>(s.activeStationId),
s.activeStationName,
s.streamState,
s.codec,
static_cast<unsigned int>(s.bitrateKbps),
static_cast<unsigned int>(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<unsigned int>(s.port),
static_cast<unsigned long>(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<unsigned int>(total),
static_cast<unsigned int>(offset),
static_cast<unsigned int>(limit));
for (uint16_t i = static_cast<uint16_t>(offset);
i < total && i < static_cast<uint16_t>(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<unsigned int>(i),
static_cast<unsigned int>(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 <id|url>");
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 <ssid> <pass>");
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;
}
@@ -0,0 +1,22 @@
#pragma once
#include <Arduino.h>
#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);
@@ -0,0 +1,158 @@
#include "web_ui_service.h"
#include <WebServer.h>
#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 =
"<html><head><meta charset='utf-8'><title>U-SON Radio</title></head>"
"<body><h2>U-SON RC V3</h2><p>Endpoints: /api/status /api/radio /api/wifi</p></body></html>";
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);
}
@@ -0,0 +1,34 @@
#pragma once
#include <Arduino.h>
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_;
};
+16
View File
@@ -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.
+118
View File
@@ -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())