feat(firmware): integrate VSCode WIP
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
@@ -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.
|
||||
Executable
+118
@@ -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())
|
||||
Reference in New Issue
Block a user