Implement full NFC-URL streaming architecture

- rc522: add NDEF URL reading via WUPA+cascade anticoll+SELECT+TLV parse
- AudioPlayer: route http:// URIs to esp_player_http_music_play()
- CacheManager: download URLs async to /sdcard/cache/{hash}.{ext}
- TagPlayerControl: NDEF URL → cache-or-stream; fallback to UID local mapping
- RFIDInterface: wire ndef_callback, expose combined uid+url callback
- WebInterface: add /api/info, /api/storage, /api/playback, /api/bluetooth
- BluetoothInterface: stub for A2DP speaker mode
- Networking: WiFi connect with 30s timeout instead of blocking forever
- main.cpp: re-enable WiFi + web server, fix init order, add CacheManager
- www/: full SPA — playback controls, device info, storage, BT pairing
- .gitmodules: switch rc522 submodule URL to HTTPS

https://claude.ai/code/session_01MhAnh68DPNnTjcKqjNYDmk
This commit is contained in:
Claude committed 2026-06-12 19:44:54 +00:00
1 parent 98872d75df
commit bf9852c345
19 files changed
+1082 -228

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
[submodule "device/components/esp-idf-rc522"] [submodule "device/components/esp-idf-rc522"]
path = device/components/esp-idf-rc522 path = device/components/esp-idf-rc522
url = git@github.com:sweidac/esp-idf-rc522.git url = https://github.com/sweidac/esp-idf-rc522.git
[submodule "device/components/arduino-esp32"] [submodule "device/components/arduino-esp32"]
path = device/components/arduino-esp32 path = device/components/arduino-esp32
+45 -10
View File
@@ -1,24 +1,40 @@
#include "AudioPlayer.hpp" #include "AudioPlayer.hpp"
#include <iostream>
#include <esp_player_wrapper.h> #include <esp_player_wrapper.h>
#include "audio_setup.h" #include "audio_setup.h"
#include <string> #include <esp_log.h>
static const char* TAG = "AudioPlayer";
AudioPlayer::AudioPlayer() { AudioPlayer::AudioPlayer() {
playerHandle = setup_player(NULL, NULL); playerHandle = setup_player(NULL, NULL);
esp_player_init(playerHandle); esp_player_init((esp_audio_handle_t)playerHandle);
esp_audio_vol_set((esp_audio_handle_t)playerHandle, 50);
esp_audio_vol_set(playerHandle, 50);
} }
void AudioPlayer::play(std::string uri) { bool AudioPlayer::isHttpUri(const std::string& uri) const {
return uri.rfind("http://", 0) == 0 || uri.rfind("https://", 0) == 0;
}
bool AudioPlayer::isLastUri(const std::string& uri) const {
return lastUri == uri;
}
void AudioPlayer::play(const std::string& uri) {
if(isLastUri(uri)) { if(isLastUri(uri)) {
esp_player_music_resume(); esp_player_music_resume();
} else { return;
}
esp_player_music_stop(); esp_player_music_stop();
lastUri = uri;
if(isHttpUri(uri)) {
ESP_LOGI(TAG, "Streaming: %s", uri.c_str());
esp_player_http_music_play(uri.c_str(), 0);
} else {
ESP_LOGI(TAG, "Playing local: %s", uri.c_str());
esp_player_sdcard_music_play(uri.c_str(), 0); esp_player_sdcard_music_play(uri.c_str(), 0);
lastUri = std::string(uri);
} }
} }
@@ -26,6 +42,25 @@ void AudioPlayer::pause() {
esp_player_music_pause(); esp_player_music_pause();
} }
bool AudioPlayer::isLastUri(std::string uri) { void AudioPlayer::stop() {
return lastUri == uri; esp_player_music_stop();
lastUri.clear();
}
int AudioPlayer::getVolume() {
int vol = 0;
esp_player_vol_get(&vol);
return vol;
}
void AudioPlayer::setVolume(int vol) {
if(vol < 0) vol = 0;
if(vol > 100) vol = 100;
esp_audio_vol_set((esp_audio_handle_t)playerHandle, vol);
}
bool AudioPlayer::isPlaying() const {
esp_audio_state_t state;
if(esp_player_state_get(&state) != ESP_OK) return false;
return state.status == AUDIO_STATUS_RUNNING;
} }
+15 -2
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <string> #include <string>
#include <esp_player_wrapper.h>
class AudioPlayer { class AudioPlayer {
void* playerHandle; void* playerHandle;
@@ -8,9 +9,21 @@ class AudioPlayer {
public: public:
AudioPlayer(); AudioPlayer();
void play(std::string uri);
/* Play URI. http:// / https:// URIs stream via HTTP; anything else plays from SD card.
* Re-placing the same URI resumes rather than restarting. */
void play(const std::string& uri);
void pause(); void pause();
void stop();
int getVolume();
void setVolume(int vol);
bool isPlaying() const;
const std::string& getCurrentUri() const { return lastUri; }
private: private:
bool isLastUri(std::string uri); bool isHttpUri(const std::string& uri) const;
bool isLastUri(const std::string& uri) const;
}; };
+25
View File
@@ -0,0 +1,25 @@
#include "BluetoothInterface.hpp"
#include <esp_log.h>
static const char* TAG = "Bluetooth";
BluetoothInterface::BluetoothInterface() {
ESP_LOGI(TAG, "BT interface created (stub — not yet enabled)");
}
bool BluetoothInterface::setEnabled(bool on) {
if(on == enabled) return enabled;
if(on) {
/* TODO: initialize Bluedroid stack, register A2DP sink callbacks,
* switch audio pipeline output from I2S codec to BT stream.
* Requires CONFIG_BT_ENABLED=y and esp_bt / esp_a2dp components. */
ESP_LOGW(TAG, "BT enable requested — not yet implemented");
enabled = false; /* keep false until implemented */
} else {
ESP_LOGI(TAG, "BT disable requested");
enabled = false;
}
return enabled;
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
/* Stub for Bluetooth A2DP speaker mode.
* When enabled the ESP32 advertises itself as a BT audio sink so a phone
* can pair and route music through the Kidsbox speaker.
* Full implementation requires the esp_bt + esp_a2dp components to be
* added to the build and CONFIG_BT_ENABLED=y in sdkconfig. */
class BluetoothInterface {
public:
BluetoothInterface();
bool isEnabled() const { return enabled; }
/* Toggle BT speaker mode on/off. Returns whether BT is now enabled. */
bool setEnabled(bool on);
private:
bool enabled = false;
};
+12 -2
View File
@@ -1,2 +1,12 @@
idf_component_register(SRCS "Networking.cpp" "main.cpp" "WebInterface.cpp" "RFIDInterface.cpp" "audio_setup.c" "AudioPlayer.cpp" "TagPlayerControl.cpp" idf_component_register(SRCS
INCLUDE_DIRS ".") "main.cpp"
"Networking.cpp"
"WebInterface.cpp"
"RFIDInterface.cpp"
"AudioPlayer.cpp"
"TagPlayerControl.cpp"
"CacheManager.cpp"
"BluetoothInterface.cpp"
"audio_setup.c"
INCLUDE_DIRS "."
REQUIRES esp_http_client esp_netif)
+205
View File
@@ -0,0 +1,205 @@
#include "CacheManager.hpp"
#include <esp_log.h>
#include <esp_http_client.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <sys/stat.h>
#include <dirent.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <string>
#include <functional>
static const char* TAG = "CacheManager";
struct DownloadArgs {
std::string url;
std::function<void(bool)> onComplete;
};
CacheManager::CacheManager() {
mkdir(CACHE_DIR, 0777);
}
/* djb2 hash of the URL, returned as 8-char hex string */
std::string CacheManager::urlToKey(const std::string& url) const {
uint32_t hash = 5381;
for(char c : url) {
hash = ((hash << 5) + hash) ^ (uint8_t)c;
}
char buf[9];
snprintf(buf, sizeof(buf), "%08lx", (unsigned long)hash);
return std::string(buf);
}
std::string CacheManager::urlExtension(const std::string& url) const {
/* Strip query/fragment before looking for extension */
size_t qs = url.find('?');
std::string path = (qs != std::string::npos) ? url.substr(0, qs) : url;
size_t dot = path.rfind('.');
size_t slash = path.rfind('/');
if(dot != std::string::npos && (slash == std::string::npos || dot > slash)) {
std::string ext = path.substr(dot); /* includes the dot */
if(ext.size() <= 5) return ext; /* ignore suspiciously long "extensions" */
}
return ".mp3"; /* sensible default */
}
std::string CacheManager::keyToPath(const std::string& key, const std::string& ext) const {
return std::string(CACHE_DIR) + "/" + key + ext;
}
/* Find any file in CACHE_DIR whose name starts with key */
std::string CacheManager::findFileByKey(const std::string& key) const {
DIR* dir = opendir(CACHE_DIR);
if(!dir) return "";
struct dirent* entry;
while((entry = readdir(dir)) != NULL) {
std::string name(entry->d_name);
if(name.rfind(key, 0) == 0) {
closedir(dir);
return std::string(CACHE_DIR) + "/" + name;
}
}
closedir(dir);
return "";
}
std::string CacheManager::getCachedPath(const std::string& url) const {
if(url.empty()) return "";
std::string key = urlToKey(url);
std::string path = findFileByKey(key);
if(!path.empty()) {
ESP_LOGI(TAG, "Cache hit: %s -> %s", url.c_str(), path.c_str());
}
return path;
}
void CacheManager::downloadTask(void* arg) {
auto* dl = static_cast<DownloadArgs*>(arg);
ESP_LOGI(TAG, "Downloading: %s", dl->url.c_str());
CacheManager cm;
std::string key = cm.urlToKey(dl->url);
std::string ext = cm.urlExtension(dl->url);
std::string path = cm.keyToPath(key, ext);
esp_http_client_config_t cfg = {};
cfg.url = dl->url.c_str();
cfg.timeout_ms = 30000;
cfg.buffer_size = 4096;
esp_http_client_handle_t client = esp_http_client_init(&cfg);
esp_err_t err = esp_http_client_open(client, 0);
bool success = false;
if(err == ESP_OK) {
int content_len = esp_http_client_fetch_headers(client);
(void)content_len;
FILE* f = fopen(path.c_str(), "wb");
if(f) {
char buf[4096];
int read_len;
while((read_len = esp_http_client_read(client, buf, sizeof(buf))) > 0) {
fwrite(buf, 1, read_len, f);
}
fclose(f);
success = (read_len == 0); /* 0 = clean EOF */
if(!success) {
remove(path.c_str());
ESP_LOGE(TAG, "Download error, partial file deleted");
} else {
ESP_LOGI(TAG, "Cached to %s", path.c_str());
}
} else {
ESP_LOGE(TAG, "Cannot open cache file: %s (errno %d)", path.c_str(), errno);
}
} else {
ESP_LOGE(TAG, "HTTP open failed: %s", esp_err_to_name(err));
}
esp_http_client_cleanup(client);
if(dl->onComplete) dl->onComplete(success);
delete dl;
vTaskDelete(NULL);
}
void CacheManager::downloadAsync(const std::string& url, std::function<void(bool)> onComplete) {
if(url.empty()) return;
auto* args = new DownloadArgs{ url, onComplete };
xTaskCreate(downloadTask, "cache_dl", 8192, args, 3, NULL);
}
std::vector<CacheEntry> CacheManager::listEntries() const {
std::vector<CacheEntry> entries;
DIR* dir = opendir(CACHE_DIR);
if(!dir) return entries;
struct dirent* entry;
while((entry = readdir(dir)) != NULL) {
if(entry->d_name[0] == '.') continue;
std::string name(entry->d_name);
std::string full = std::string(CACHE_DIR) + "/" + name;
struct stat st;
size_t size = 0;
if(stat(full.c_str(), &st) == 0) size = st.st_size;
/* Key is filename without extension */
size_t dot = name.rfind('.');
std::string key = (dot != std::string::npos) ? name.substr(0, dot) : name;
entries.push_back({ key, /*url unknown*/"", full, size });
}
closedir(dir);
return entries;
}
bool CacheManager::deleteEntry(const std::string& key) {
std::string path = findFileByKey(key);
if(path.empty()) return false;
return remove(path.c_str()) == 0;
}
void CacheManager::deleteAll() {
DIR* dir = opendir(CACHE_DIR);
if(!dir) return;
struct dirent* entry;
while((entry = readdir(dir)) != NULL) {
if(entry->d_name[0] == '.') continue;
std::string full = std::string(CACHE_DIR) + "/" + entry->d_name;
remove(full.c_str());
}
closedir(dir);
ESP_LOGI(TAG, "Cache cleared");
}
size_t CacheManager::totalBytes() const {
size_t total = 0;
DIR* dir = opendir(CACHE_DIR);
if(!dir) return 0;
struct dirent* entry;
while((entry = readdir(dir)) != NULL) {
if(entry->d_name[0] == '.') continue;
std::string full = std::string(CACHE_DIR) + "/" + entry->d_name;
struct stat st;
if(stat(full.c_str(), &st) == 0) total += st.st_size;
}
closedir(dir);
return total;
}
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <string>
#include <vector>
#include <functional>
struct CacheEntry {
std::string key; /* hex hash of the URL */
std::string url;
std::string path; /* full SD card path */
size_t size; /* file size in bytes, 0 if unknown */
};
class CacheManager {
public:
CacheManager();
/* Returns the SD-card path for url if cached, or empty string if not. */
std::string getCachedPath(const std::string& url) const;
/* Enqueue an async FreeRTOS task that downloads url and caches it.
* onComplete is called (from the download task) when done or on error. */
void downloadAsync(const std::string& url, std::function<void(bool success)> onComplete = nullptr);
/* List all cached entries. */
std::vector<CacheEntry> listEntries() const;
/* Delete a single cache entry by key (hex hash). Returns true if deleted. */
bool deleteEntry(const std::string& key);
/* Delete the entire cache directory. */
void deleteAll();
/* Total bytes used by cache. */
size_t totalBytes() const;
private:
static constexpr const char* CACHE_DIR = "/sdcard/cache";
std::string urlToKey(const std::string& url) const;
std::string urlExtension(const std::string& url) const;
std::string keyToPath(const std::string& key, const std::string& ext) const;
std::string findFileByKey(const std::string& key) const;
static void downloadTask(void* arg);
};
+16 -5
View File
@@ -1,15 +1,26 @@
#include "Networking.hpp" #include "Networking.hpp"
#include "WiFi.h" #include "WiFi.h"
#include <esp_log.h>
static const char* TAG = "Networking";
static constexpr int WIFI_TIMEOUT_MS = 30000;
static constexpr int WIFI_POLL_MS = 500;
void Networking::connectWifi() { void Networking::connectWifi() {
WiFi.begin(CONFIG_ESP_WIFI_SSID, CONFIG_ESP_WIFI_PASSWORD); WiFi.begin(CONFIG_ESP_WIFI_SSID, CONFIG_ESP_WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) { int elapsed = 0;
delay(1000); while(WiFi.status() != WL_CONNECTED && elapsed < WIFI_TIMEOUT_MS) {
Serial.println("Waiting for connection..."); vTaskDelay(WIFI_POLL_MS / portTICK_PERIOD_MS);
elapsed += WIFI_POLL_MS;
ESP_LOGI(TAG, "Waiting for WiFi... (%d ms)", elapsed);
} }
Serial.println("Connected."); if(WiFi.status() == WL_CONNECTED) {
Serial.println(WiFi.localIP()); ESP_LOGI(TAG, "Connected. IP: %s", WiFi.localIP().toString().c_str());
} else {
ESP_LOGW(TAG, "WiFi connection timed out — running without network");
}
} }
+46 -20
View File
@@ -10,35 +10,58 @@
static const char* TAG = "rfid"; static const char* TAG = "rfid";
static char* lastTagId = (char*)malloc(26 * sizeof(char)); static char lastTagId[26];
static std::function<void(char*)> callback; static std::function<void(char*)> legacyCallback;
static std::function<void(const char*, const char*)> ndefCallback;
static char* getLastTagId() { static char* getLastTagId() {
return lastTagId; return lastTagId;
} }
static void onUidCallback(uint8_t* sn) {
if(sn == NULL) {
ESP_LOGI(TAG, "Tag removed");
lastTagId[0] = '\0';
if(legacyCallback) legacyCallback(NULL);
} else {
snprintf(lastTagId, sizeof(lastTagId),
"%#x_%#x_%#x_%#x_%#x", sn[0], sn[1], sn[2], sn[3], sn[4]);
ESP_LOGI(TAG, "Tag UID: %s", lastTagId);
if(legacyCallback) legacyCallback(lastTagId);
}
}
static void onNdefCallback(uint8_t* sn, const char* url) {
if(!ndefCallback) return;
if(sn == NULL) {
ndefCallback(NULL, NULL);
return;
}
/* Format UID same way as above */
char uid[26];
snprintf(uid, sizeof(uid),
"%#x_%#x_%#x_%#x_%#x", sn[0], sn[1], sn[2], sn[3], sn[4]);
if(url) {
ESP_LOGI(TAG, "NDEF URL on %s: %s", uid, url);
} else {
ESP_LOGI(TAG, "No NDEF on %s", uid);
}
ndefCallback(uid, url);
}
RFIDInterface::RFIDInterface() { RFIDInterface::RFIDInterface() {
startConfig = (rc522_start_args_t){ startConfig = (rc522_start_args_t){
.miso_io = 12, .miso_io = 12,
.mosi_io = 13, .mosi_io = 13,
.sck_io = 23, .sck_io = 23,
.sda_io = 18, .sda_io = 18,
.callback = [](uint8_t* sn) { .spi_host_id = (spi_host_device_t)0,
if (sn == NULL) { .callback = onUidCallback,
ESP_LOGI(TAG, "Tag removed"); .ndef_callback = onNdefCallback,
if (callback) {
callback(NULL);
}
} else { // serial number is always 5 bytes long
sprintf(lastTagId, "%#x_%#x_%#x_%#x_%#x", sn[0], sn[1], sn[2], sn[3], sn[4]);
ESP_LOGI(TAG, "tag id: %s", lastTagId);
if (callback) {
callback(getLastTagId());
}
}
}
}; };
} }
@@ -55,10 +78,13 @@ void RFIDInterface::registerWebResources(WebInterface* interface) {
return ESP_OK; return ESP_OK;
} }
}; };
interface->registerResource(&config); interface->registerResource(&config);
} }
void RFIDInterface::registerTagCallback(std::function<void(const char*, const char*)> cb) {
ndefCallback = cb;
}
void RFIDInterface::registerTagChangeCallback(std::function<void(char*)> cb) { void RFIDInterface::registerTagChangeCallback(std::function<void(char*)> cb) {
callback = cb; legacyCallback = cb;
} }
+9 -1
View File
@@ -12,7 +12,15 @@ class RFIDInterface {
RFIDInterface(); RFIDInterface();
void start(); void start();
void registerWebResources(WebInterface* interface); void registerWebResources(WebInterface* interface);
void registerTagChangeCallback(std::function<void(char*)>);
/* Callback receives (uid_string, url_or_null):
* uid == NULL → tag removed
* url == NULL → tag placed but no NDEF URI found (use uid for local lookup)
* url != NULL → tag placed with NDEF URI */
void registerTagCallback(std::function<void(const char* uid, const char* url)> cb);
/* Legacy: only fires with UID string (NULL on removal). Kept for compatibility. */
void registerTagChangeCallback(std::function<void(char*)> cb);
}; };
#endif #endif
+52 -43
View File
@@ -1,62 +1,71 @@
#include "TagPlayerControl.hpp" #include "TagPlayerControl.hpp"
#include "AudioPlayer.hpp" #include <esp_log.h>
#include <iostream>
#include <fstream> #include <fstream>
#include <string> #include <string>
#include <memory>
#include <esp_log.h>
static const char* TAG = "TagPlayerControl"; static const char* TAG = "TagPlayerControl";
TagPlayerControl::TagPlayerControl(std::shared_ptr<AudioPlayer> player) TagPlayerControl::TagPlayerControl(std::shared_ptr<AudioPlayer> player,
: audioPlayer(player) { std::shared_ptr<CacheManager> cache)
} : audioPlayer(player), cacheManager(cache) {}
void TagPlayerControl::onTagChanged(char* tagId) { void TagPlayerControl::onTagChanged(const char* uid, const char* url) {
if (tagId == NULL) { if(uid == NULL) {
// tag has been removed. stop playback ESP_LOGI(TAG, "Tag removed — pausing");
audioPlayer->pause(); audioPlayer->pause();
} else { lastUid.clear();
playTag(tagId);
}
}
void TagPlayerControl::playTag(char* tagId) {
std::string filePath = "/sdcard/internal/";
filePath.append(tagId);
std::fstream file(filePath);
if (! file) {
ESP_LOGE(TAG, "Error opening file for tag %s", tagId);
char* error_name;
switch (errno)
{
case EACCES:
ESP_LOGE(TAG, "EACCESS");
break;
case ENOENT:
ESP_LOGE(TAG, "ENOENT");
break;
default:
ESP_LOGE(TAG, "not parsed");
}
return; return;
} }
std::string line; lastUid = std::string(uid);
std::string content;
if(url != NULL && url[0] != '\0') {
ESP_LOGI(TAG, "Tag %s -> URL: %s", uid, url);
playUrl(std::string(url));
} else {
ESP_LOGI(TAG, "Tag %s -> no NDEF, trying local mapping", uid);
playLocalTag(std::string(uid));
}
}
void TagPlayerControl::playUrl(const std::string& url) {
/* Check cache first; if found play local file to avoid re-downloading */
std::string cached = cacheManager->getCachedPath(url);
if(!cached.empty()) {
audioPlayer->play(cached);
return;
}
/* Stream via HTTP and cache in background for future plays */
audioPlayer->play(url);
cacheManager->downloadAsync(url, [url](bool success) {
if(success) {
ESP_LOGI("TagPlayerControl", "Cached for next play: %s", url.c_str());
}
});
}
void TagPlayerControl::playLocalTag(const std::string& uid) {
std::string mappingPath = std::string("/sdcard/internal/") + uid;
std::fstream file(mappingPath);
if(!file) {
ESP_LOGW(TAG, "No mapping file for tag %s at %s", uid.c_str(), mappingPath.c_str());
return;
}
std::string line, content;
while(getline(file, line)) { while(getline(file, line)) {
content.append(line); content.append(line);
} }
file.close();
if(content.empty()) {
ESP_LOGW(TAG, "Empty mapping file for tag %s", uid.c_str());
return;
}
audioPlayer->play(content); audioPlayer->play(content);
file.close();
} }
+12 -3
View File
@@ -1,16 +1,25 @@
#pragma once #pragma once
#include <memory> #include <memory>
#include <string>
#include "AudioPlayer.hpp" #include "AudioPlayer.hpp"
#include "CacheManager.hpp"
class TagPlayerControl { class TagPlayerControl {
std::shared_ptr<AudioPlayer> audioPlayer; std::shared_ptr<AudioPlayer> audioPlayer;
std::shared_ptr<CacheManager> cacheManager;
std::string lastUid;
public: public:
TagPlayerControl(std::shared_ptr<AudioPlayer> player); TagPlayerControl(std::shared_ptr<AudioPlayer> player,
std::shared_ptr<CacheManager> cache);
void onTagChanged(char* tagId); /* Called by RFIDInterface. uid == NULL means tag removed.
* url is the NDEF URI from the tag, or NULL if no NDEF record was found. */
void onTagChanged(const char* uid, const char* url);
private: private:
void playTag(char* tagId); void playUrl(const std::string& url);
void playLocalTag(const std::string& uid);
}; };
+223 -115
View File
@@ -1,79 +1,69 @@
#include "WebInterface.hpp" #include "WebInterface.hpp"
#include "AudioPlayer.hpp"
#include "CacheManager.hpp"
#include <esp_http_server.h> #include <esp_http_server.h>
#include <esp_log.h> #include <esp_log.h>
#include "esp_vfs.h" #include <esp_vfs.h>
#include <board.h> #include <board.h>
#include "periph_sdcard.h" #include <periph_sdcard.h>
#include <errno.h> #include <esp_system.h>
#include <esp_wifi.h>
#include <fcntl.h> #include <fcntl.h>
#include "esp_system.h" #include <errno.h>
#include <string.h>
#include <dirent.h>
#include <sys/stat.h>
#include <string>
#include <sstream>
static const char* TAG = "WebInterface"; static const char* TAG = "WebInterface";
#define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128) #define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128)
#define SCRATCH_BUFSIZE (10240) #define SCRATCH_BUFSIZE (10240)
#define CHECK_FILE_EXTENSION(f, ext) (strcasecmp(&(f)[strlen(f) - strlen(ext)], (ext)) == 0)
#define CHECK_FILE_EXTENSION(filename, ext) (strcasecmp(&filename[strlen(filename) - strlen(ext)], ext) == 0) typedef struct {
typedef struct rest_server_context {
char base_path[ESP_VFS_PATH_MAX + 1]; char base_path[ESP_VFS_PATH_MAX + 1];
char scratch[SCRATCH_BUFSIZE]; char scratch[SCRATCH_BUFSIZE];
} rest_server_context_t; } rest_server_context_t;
WebInterface::WebInterface() { /* ── helpers ──────────────────────────────────────────────────────────── */
} static esp_err_t set_content_type(httpd_req_t* req, const char* filepath) {
void WebInterface::start() {
initSD();
server = start_webserver();
}
void WebInterface::finishResourceRegistrations() {
registerFileServer();
}
void WebInterface::initSD() {
esp_periph_config_t periph_cfg = DEFAULT_ESP_PERIPH_SET_CONFIG();
esp_periph_set_handle_t set = esp_periph_set_init(&periph_cfg);
// Initialize SD Card peripheral
if (audio_board_sdcard_init(set, SD_MODE_1_LINE) == ESP_FAIL) {
ESP_LOGE(TAG, "Sdcard not mounted");
} else {
ESP_LOGI(TAG, "SDCard mounted");
}
}
/* Set HTTP response content type according to file extension */
esp_err_t set_content_type_from_file(httpd_req_t *req, const char *filepath)
{
const char* type = "text/plain"; const char* type = "text/plain";
if (CHECK_FILE_EXTENSION(filepath, ".html")) { if(CHECK_FILE_EXTENSION(filepath, ".html")) type = "text/html";
type = "text/html"; else if(CHECK_FILE_EXTENSION(filepath, ".js")) type = "application/javascript";
} else if (CHECK_FILE_EXTENSION(filepath, ".js")) { else if(CHECK_FILE_EXTENSION(filepath, ".css")) type = "text/css";
type = "application/javascript"; else if(CHECK_FILE_EXTENSION(filepath, ".png")) type = "image/png";
} else if (CHECK_FILE_EXTENSION(filepath, ".css")) { else if(CHECK_FILE_EXTENSION(filepath, ".ico")) type = "image/x-icon";
type = "text/css"; else if(CHECK_FILE_EXTENSION(filepath, ".svg")) type = "image/svg+xml";
} else if (CHECK_FILE_EXTENSION(filepath, ".png")) { else if(CHECK_FILE_EXTENSION(filepath, ".json"))type = "application/json";
type = "image/png";
} else if (CHECK_FILE_EXTENSION(filepath, ".ico")) {
type = "image/x-icon";
} else if (CHECK_FILE_EXTENSION(filepath, ".svg")) {
type = "text/xml";
}
return httpd_resp_set_type(req, type); return httpd_resp_set_type(req, type);
} }
/* Send HTTP response with the contents of the requested file */ static void send_json(httpd_req_t* req, const std::string& json) {
esp_err_t rest_common_get_handler(httpd_req_t *req) httpd_resp_set_type(req, "application/json");
{ httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
char filepath[FILE_PATH_MAX]; httpd_resp_sendstr(req, json.c_str());
}
rest_server_context_t *rest_context = (rest_server_context_t *)req->user_ctx; static void send_json_ok(httpd_req_t* req) {
strlcpy(filepath, rest_context->base_path, sizeof(filepath)); send_json(req, "{\"ok\":true}");
}
static void send_json_error(httpd_req_t* req, const char* msg) {
std::string body = std::string("{\"error\":\"") + msg + "\"}";
httpd_resp_set_status(req, "400 Bad Request");
send_json(req, body);
}
/* ── static file handler ──────────────────────────────────────────────── */
static esp_err_t rest_common_get_handler(httpd_req_t* req) {
char filepath[FILE_PATH_MAX];
rest_server_context_t* ctx = (rest_server_context_t*)req->user_ctx;
strlcpy(filepath, ctx->base_path, sizeof(filepath));
if(req->uri[strlen(req->uri) - 1] == '/') { if(req->uri[strlen(req->uri) - 1] == '/') {
strlcat(filepath, "/index.html", sizeof(filepath)); strlcat(filepath, "/index.html", sizeof(filepath));
@@ -83,100 +73,218 @@ esp_err_t rest_common_get_handler(httpd_req_t *req)
int fd = open(filepath, O_RDONLY); int fd = open(filepath, O_RDONLY);
if(fd == -1) { if(fd == -1) {
ESP_LOGE(TAG, "Failed to open file : %s. Errno: %d", filepath, errno); ESP_LOGE(TAG, "Failed to open %s (errno %d)", filepath, errno);
/* Respond with 500 Internal Server Error */ httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found");
char responseError[255];
char* error_name;
switch (errno)
{
case EACCES:
error_name = "EACCESS";
break;
case ENOENT:
error_name = "ENOENT";
break;
default:
error_name = "not parsed";
}
sprintf(responseError, "Failed to read existing file. %d => %s", errno, error_name);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, responseError);
return ESP_FAIL; return ESP_FAIL;
} }
set_content_type_from_file(req, filepath); set_content_type(req, filepath);
char *chunk = rest_context->scratch; char* chunk = ctx->scratch;
ssize_t read_bytes; ssize_t n;
do { while((n = read(fd, chunk, SCRATCH_BUFSIZE)) > 0) {
/* Read file in chunks into the scratch buffer */ if(httpd_resp_send_chunk(req, chunk, n) != ESP_OK) {
read_bytes = read(fd, chunk, SCRATCH_BUFSIZE);
if (read_bytes == -1) {
ESP_LOGE(TAG, "Failed to read file : %s", filepath);
} else if (read_bytes > 0) {
/* Send the buffer contents as HTTP response chunk */
if (httpd_resp_send_chunk(req, chunk, read_bytes) != ESP_OK) {
close(fd); close(fd);
ESP_LOGE(TAG, "File sending failed!");
/* Abort sending file */
httpd_resp_sendstr_chunk(req, NULL); httpd_resp_sendstr_chunk(req, NULL);
/* Respond with 500 Internal Server Error */ httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Send error");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to send file");
return ESP_FAIL; return ESP_FAIL;
} }
} }
} while (read_bytes > 0);
/* Close file after sending complete */
close(fd); close(fd);
ESP_LOGI(TAG, "File sending complete");
/* Respond with an empty chunk to signal HTTP response completion */
httpd_resp_send_chunk(req, NULL, 0); httpd_resp_send_chunk(req, NULL, 0);
return ESP_OK; return ESP_OK;
} }
/* ── API handlers (use shared_ptrs captured from WebInterface) ──────── */
httpd_handle_t WebInterface::start_webserver(void) { /* GET /api/info — device info JSON */
httpd_handle_t server = NULL; static AudioPlayer* g_audio = nullptr;
static CacheManager* g_cache = nullptr;
static esp_err_t api_info_handler(httpd_req_t* req) {
/* Get device IP */
esp_netif_ip_info_t ip_info = {};
esp_netif_t* netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if(netif) esp_netif_get_ip_info(netif, &ip_info);
char ip_str[16] = "0.0.0.0";
snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip));
int vol = g_audio ? g_audio->getVolume() : 0;
bool playing = g_audio ? g_audio->isPlaying() : false;
size_t cache_bytes = g_cache ? g_cache->totalBytes() : 0;
/* Free heap */
uint32_t free_heap = esp_get_free_heap_size();
std::ostringstream json;
json << "{"
<< "\"ip\":\"" << ip_str << "\","
<< "\"volume\":" << vol << ","
<< "\"playing\":" << (playing ? "true" : "false") << ","
<< "\"cacheBytes\":" << cache_bytes << ","
<< "\"freeHeap\":" << free_heap
<< "}";
send_json(req, json.str());
return ESP_OK;
}
/* GET /api/storage — list cached files as JSON array */
static esp_err_t api_storage_get_handler(httpd_req_t* req) {
if(!g_cache) { send_json(req, "[]"); return ESP_OK; }
auto entries = g_cache->listEntries();
std::ostringstream json;
json << "[";
for(size_t i = 0; i < entries.size(); i++) {
if(i > 0) json << ",";
json << "{\"key\":\"" << entries[i].key << "\","
<< "\"size\":" << entries[i].size << "}";
}
json << "]";
send_json(req, json.str());
return ESP_OK;
}
/* DELETE /api/storage?key=<hex> — delete one entry, or all if no key */
static esp_err_t api_storage_delete_handler(httpd_req_t* req) {
if(!g_cache) { send_json_ok(req); return ESP_OK; }
char key_buf[32] = {};
if(httpd_req_get_url_query_str(req, key_buf, sizeof(key_buf)) == ESP_OK) {
char key[32] = {};
if(httpd_query_key_value(key_buf, "key", key, sizeof(key)) == ESP_OK && key[0]) {
bool ok = g_cache->deleteEntry(std::string(key));
if(!ok) { send_json_error(req, "key not found"); return ESP_OK; }
} else {
g_cache->deleteAll();
}
} else {
g_cache->deleteAll();
}
send_json_ok(req);
return ESP_OK;
}
/* POST /api/playback body: action=play|pause|stop|volume&value=<0-100> */
static esp_err_t api_playback_handler(httpd_req_t* req) {
char buf[128] = {};
int len = httpd_req_recv(req, buf, sizeof(buf) - 1);
if(len <= 0) { send_json_error(req, "empty body"); return ESP_OK; }
buf[len] = '\0';
char action[16] = {};
httpd_query_key_value(buf, "action", action, sizeof(action));
if(!g_audio) { send_json_error(req, "audio not ready"); return ESP_OK; }
if(strcmp(action, "pause") == 0) {
g_audio->pause();
} else if(strcmp(action, "stop") == 0) {
g_audio->stop();
} else if(strcmp(action, "volume") == 0) {
char val_str[8] = {};
httpd_query_key_value(buf, "value", val_str, sizeof(val_str));
int vol = atoi(val_str);
g_audio->setVolume(vol);
} else {
send_json_error(req, "unknown action");
return ESP_OK;
}
send_json_ok(req);
return ESP_OK;
}
/* GET /api/bluetooth — returns BT status (stub) */
static esp_err_t api_bluetooth_get_handler(httpd_req_t* req) {
send_json(req, "{\"enabled\":false,\"note\":\"bluetooth not yet implemented\"}");
return ESP_OK;
}
/* POST /api/bluetooth body: enabled=true|false (stub) */
static esp_err_t api_bluetooth_post_handler(httpd_req_t* req) {
send_json(req, "{\"ok\":false,\"note\":\"bluetooth not yet implemented\"}");
return ESP_OK;
}
/* ── WebInterface ────────────────────────────────────────────────────── */
WebInterface::WebInterface() {}
void WebInterface::start() {
initSD();
server = start_webserver();
}
void WebInterface::finishResourceRegistrations() {
registerApiHandlers();
registerFileServer();
}
void WebInterface::initSD() {
esp_periph_config_t periph_cfg = DEFAULT_ESP_PERIPH_SET_CONFIG();
esp_periph_set_handle_t set = esp_periph_set_init(&periph_cfg);
if(audio_board_sdcard_init(set, SD_MODE_1_LINE) == ESP_FAIL) {
ESP_LOGE(TAG, "SDCard not mounted");
} else {
ESP_LOGI(TAG, "SDCard mounted");
}
}
httpd_handle_t WebInterface::start_webserver() {
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.lru_purge_enable = true; config.lru_purge_enable = true;
config.uri_match_fn = httpd_uri_match_wildcard; config.uri_match_fn = httpd_uri_match_wildcard;
config.max_uri_handlers = 16;
// Start the httpd server httpd_handle_t srv = NULL;
ESP_LOGI(TAG, "Starting server on port: '%d'", config.server_port); ESP_LOGI(TAG, "Starting server on port %d", config.server_port);
if (httpd_start(&server, &config) == ESP_OK) { if(httpd_start(&srv, &config) == ESP_OK) return srv;
return server;
}
ESP_LOGE(TAG, "Failed to start server");
ESP_LOGI(TAG, "Error starting server!");
return NULL; return NULL;
} }
void WebInterface::registerResource(httpd_uri_t* config) { void WebInterface::registerResource(httpd_uri_t* config) {
ESP_LOGI(TAG, "Register resource on path %s", config->uri); if(!server) return;
ESP_LOGI(TAG, "Register %s", config->uri);
httpd_register_uri_handler(server, config); httpd_register_uri_handler(server, config);
} }
void WebInterface::registerFileServer() { void WebInterface::registerApiHandlers() {
rest_server_context_t *fileContext = (rest_server_context_t*)calloc(1, sizeof(rest_server_context_t)); /* Expose singletons to the static C handlers */
g_audio = audioPlayer.get();
g_cache = cacheManager.get();
if (! fileContext) { httpd_uri_t info = { "/api/info", HTTP_GET, api_info_handler, nullptr };
ESP_LOGE(TAG, "No memory for rest context"); httpd_uri_t stg_g = { "/api/storage", HTTP_GET, api_storage_get_handler, nullptr };
return; httpd_uri_t stg_d = { "/api/storage", HTTP_DELETE, api_storage_delete_handler, nullptr };
httpd_uri_t pb = { "/api/playback", HTTP_POST, api_playback_handler, nullptr };
httpd_uri_t bt_g = { "/api/bluetooth", HTTP_GET, api_bluetooth_get_handler, nullptr };
httpd_uri_t bt_p = { "/api/bluetooth", HTTP_POST, api_bluetooth_post_handler, nullptr };
registerResource(&info);
registerResource(&stg_g);
registerResource(&stg_d);
registerResource(&pb);
registerResource(&bt_g);
registerResource(&bt_p);
} }
strlcpy(fileContext->base_path, "/sdcard/www", sizeof(fileContext->base_path)); void WebInterface::registerFileServer() {
rest_server_context_t* ctx = (rest_server_context_t*)calloc(1, sizeof(rest_server_context_t));
if(!ctx) { ESP_LOGE(TAG, "No memory for file server context"); return; }
/* URI handler for getting web server files */ strlcpy(ctx->base_path, "/sdcard/www", sizeof(ctx->base_path));
httpd_uri_t common_get_uri = {
httpd_uri_t file_uri = {
.uri = "/*", .uri = "/*",
.method = HTTP_GET, .method = HTTP_GET,
.handler = rest_common_get_handler, .handler = rest_common_get_handler,
.user_ctx = fileContext .user_ctx = ctx
}; };
registerResource(&file_uri);
registerResource(&common_get_uri);
} }
+12 -1
View File
@@ -2,12 +2,23 @@
#define _WEB_INTERFACE_HPP #define _WEB_INTERFACE_HPP
#include <esp_http_server.h> #include <esp_http_server.h>
#include <memory>
#include <functional>
class AudioPlayer;
class CacheManager;
class WebInterface { class WebInterface {
httpd_handle_t server = NULL; httpd_handle_t server = NULL;
std::shared_ptr<AudioPlayer> audioPlayer;
std::shared_ptr<CacheManager> cacheManager;
public: public:
WebInterface(); WebInterface();
void setAudioPlayer(std::shared_ptr<AudioPlayer> p) { audioPlayer = p; }
void setCacheManager(std::shared_ptr<CacheManager> c) { cacheManager = c; }
void start(); void start();
void finishResourceRegistrations(); void finishResourceRegistrations();
void registerResource(httpd_uri_t* config); void registerResource(httpd_uri_t* config);
@@ -16,7 +27,7 @@ class WebInterface {
private: private:
httpd_handle_t start_webserver(); httpd_handle_t start_webserver();
void registerFileServer(); void registerFileServer();
//esp_err_t hello_get_handler(httpd_req_t *req); void registerApiHandlers();
}; };
#endif #endif
+23 -10
View File
@@ -5,8 +5,9 @@
#include "RFIDInterface.hpp" #include "RFIDInterface.hpp"
#include "AudioPlayer.hpp" #include "AudioPlayer.hpp"
#include "TagPlayerControl.hpp" #include "TagPlayerControl.hpp"
#include "CacheManager.hpp"
#include "BluetoothInterface.hpp"
#include <iostream>
#include <memory> #include <memory>
extern "C" { extern "C" {
@@ -16,29 +17,41 @@ extern "C" {
Networking networking; Networking networking;
WebInterface webInterface; WebInterface webInterface;
RFIDInterface rfid; RFIDInterface rfid;
BluetoothInterface bluetooth;
std::shared_ptr<AudioPlayer> audioPlayer; // initialize here, because the ctor does init stuff that fails when executed before `app_main()` /* Shared objects — must be heap-allocated after app_main() starts the scheduler */
std::shared_ptr<AudioPlayer> audioPlayer;
std::shared_ptr<CacheManager> cacheManager;
std::shared_ptr<TagPlayerControl> tagPlayerControl; std::shared_ptr<TagPlayerControl> tagPlayerControl;
void app_main(void) { void app_main(void) {
initArduino(); initArduino();
/* SD card must be mounted first — other components depend on it */
webInterface.initSD(); webInterface.initSD();
/* networking.connectWifi(); /* Network — non-blocking with 30 s timeout */
webInterface.start(); networking.connectWifi();
/* Audio pipeline — must run after scheduler is up */
audioPlayer = std::make_shared<AudioPlayer>();
cacheManager = std::make_shared<CacheManager>();
/* Wire web interface to audio/cache for API endpoints */
webInterface.setAudioPlayer(audioPlayer);
webInterface.setCacheManager(cacheManager);
/* Start HTTP server and register API + static-file handlers */
webInterface.start();
rfid.registerWebResources(&webInterface); rfid.registerWebResources(&webInterface);
webInterface.finishResourceRegistrations(); webInterface.finishResourceRegistrations();
*/
audioPlayer = std::make_shared<AudioPlayer>(); tagPlayerControl = std::make_shared<TagPlayerControl>(audioPlayer, cacheManager);
tagPlayerControl = std::make_shared<TagPlayerControl>(audioPlayer);
/* RFID must start after the audio player (hardware init order constraint) */
rfid.start(); rfid.start();
rfid.registerTagChangeCallback([tagPlayerControl](char* tagId) { rfid.registerTagCallback([](const char* uid, const char* url) {
tagPlayerControl->onTagChanged(tagId); tagPlayerControl->onTagChanged(uid, url);
}); });
} }
+49 -7
View File
@@ -1,17 +1,59 @@
<html> <!DOCTYPE html>
<html lang="en">
<head> <head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kidsbox</title> <title>Kidsbox</title>
<link rel="stylesheet" src="styles.css"> <link rel="stylesheet" href="styles.css">
</head> </head>
<body> <body>
<h1>Hi, I'm a File!</h1> <header>
<h1>&#127911; Kidsbox</h1>
</header>
<section class="rfid"> <main>
<h3>RFID-ID</h3> <section class="card" id="section-playback">
<div class="rfid-id"></div> <h2>Now Playing</h2>
<button class="rfid-refresh">refresh</button> <div class="track-uri" id="track-uri">—</div>
<div class="playback-controls">
<button class="btn btn-primary" id="btn-pause" disabled>Pause</button>
<button class="btn btn-secondary" id="btn-stop" disabled>Stop</button>
</div>
<div class="volume-row">
<label for="volume-slider">Volume</label>
<input type="range" id="volume-slider" min="0" max="100" value="50">
<span id="volume-label">50</span>
</div>
</section> </section>
<section class="card" id="section-info">
<h2>Device Info</h2>
<table class="info-table">
<tr><th>IP Address</th><td id="info-ip">—</td></tr>
<tr><th>Status</th><td id="info-playing">—</td></tr>
<tr><th>Cache Used</th><td id="info-cache">—</td></tr>
<tr><th>Free Heap</th><td id="info-heap">—</td></tr>
</table>
<button class="btn btn-secondary" id="btn-refresh-info">Refresh</button>
</section>
<section class="card" id="section-storage">
<h2>Storage</h2>
<div class="storage-header">
<span id="storage-summary">Loading…</span>
<button class="btn btn-danger" id="btn-clear-cache">Clear All</button>
</div>
<ul class="cache-list" id="cache-list"></ul>
</section>
<section class="card" id="section-bluetooth">
<h2>Bluetooth Speaker</h2>
<p class="bt-status" id="bt-status">Disabled</p>
<button class="btn btn-secondary" id="btn-bt-toggle">Enable</button>
<p class="note">Pair your phone to play music through the Kidsbox speaker.</p>
</section>
</main>
<script src="main.js"></script> <script src="main.js"></script>
</body> </body>
</html> </html>
+147 -8
View File
@@ -1,13 +1,152 @@
console.log('hi'); 'use strict';
const elements = {}; /* ── helpers ──────────────────────────────────────────────────────────── */
elements.rfidId = document.querySelector('.rfid-id'); function fmt_bytes(n) {
elements.rfidRefresh = document.querySelector('.rfid-refresh'); if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / (1024 * 1024)).toFixed(1) + ' MB';
}
elements.rfidRefresh.addEventListener('click', async () => { async function api(method, path, body) {
const response = await fetch('/rfid'), const opts = { method };
rfidId = await response.text(); if (body) {
opts.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
opts.body = body;
}
const res = await fetch(path, opts);
return res.json();
}
elements.rfidId.innerText = rfidId; /* ── device info ──────────────────────────────────────────────────────── */
async function refreshInfo() {
try {
const d = await api('GET', '/api/info');
document.getElementById('info-ip').textContent = d.ip ?? '—';
document.getElementById('info-playing').textContent = d.playing ? 'Playing' : 'Stopped';
document.getElementById('info-cache').textContent = fmt_bytes(d.cacheBytes ?? 0);
document.getElementById('info-heap').textContent = fmt_bytes(d.freeHeap ?? 0);
document.getElementById('track-uri').textContent = d.currentUri || '—';
document.getElementById('btn-pause').disabled = !d.playing;
document.getElementById('btn-stop').disabled = !d.playing;
const vol = d.volume ?? 50;
document.getElementById('volume-slider').value = vol;
document.getElementById('volume-label').textContent = vol;
} catch (e) {
console.error('Failed to fetch /api/info', e);
}
}
/* ── storage ──────────────────────────────────────────────────────────── */
async function refreshStorage() {
try {
const entries = await api('GET', '/api/storage');
const list = document.getElementById('cache-list');
const summary = document.getElementById('storage-summary');
list.innerHTML = '';
if (!Array.isArray(entries) || entries.length === 0) {
summary.textContent = 'No cached files';
return;
}
let total = 0;
entries.forEach(e => {
total += e.size ?? 0;
const li = document.createElement('li');
const key = document.createElement('span');
key.className = 'cache-key';
key.textContent = e.key;
const sz = document.createElement('span');
sz.className = 'cache-size';
sz.textContent = fmt_bytes(e.size ?? 0);
const del = document.createElement('button');
del.className = 'btn btn-danger';
del.style.cssText = 'padding:.2rem .5rem;font-size:.75rem';
del.textContent = '✕';
del.addEventListener('click', () => deleteEntry(e.key));
li.append(key, sz, del);
list.appendChild(li);
});
summary.textContent =
`${entries.length} file${entries.length !== 1 ? 's' : ''} — ${fmt_bytes(total)}`;
} catch (e) {
console.error('Failed to fetch /api/storage', e);
}
}
async function deleteEntry(key) {
await api('DELETE', `/api/storage?key=${encodeURIComponent(key)}`);
refreshStorage();
}
async function clearCache() {
if (!confirm('Delete all cached files?')) return;
await api('DELETE', '/api/storage');
refreshStorage();
}
/* ── playback ─────────────────────────────────────────────────────────── */
async function sendPlayback(action, value) {
const body = value !== undefined
? `action=${action}&value=${encodeURIComponent(value)}`
: `action=${action}`;
await api('POST', '/api/playback', body);
refreshInfo();
}
/* ── bluetooth ────────────────────────────────────────────────────────── */
async function refreshBt() {
try {
const d = await api('GET', '/api/bluetooth');
const status = document.getElementById('bt-status');
const btn = document.getElementById('btn-bt-toggle');
if (d.enabled) {
status.textContent = 'Enabled';
status.style.color = '#48bb78';
btn.textContent = 'Disable';
} else {
status.textContent = 'Disabled';
status.style.color = '#a0aec0';
btn.textContent = 'Enable';
}
} catch (e) {
console.error('Failed to fetch /api/bluetooth', e);
}
}
async function toggleBt() {
const enabled = document.getElementById('bt-status').textContent === 'Enabled';
await api('POST', '/api/bluetooth', `enabled=${!enabled}`);
refreshBt();
}
/* ── init ─────────────────────────────────────────────────────────────── */
document.addEventListener('DOMContentLoaded', () => {
refreshInfo();
refreshStorage();
refreshBt();
document.getElementById('btn-refresh-info').addEventListener('click', refreshInfo);
document.getElementById('btn-clear-cache').addEventListener('click', clearCache);
document.getElementById('btn-bt-toggle').addEventListener('click', toggleBt);
document.getElementById('btn-pause').addEventListener('click', () => sendPlayback('pause'));
document.getElementById('btn-stop').addEventListener('click', () => sendPlayback('stop'));
const slider = document.getElementById('volume-slider');
const label = document.getElementById('volume-label');
slider.addEventListener('input', () => { label.textContent = slider.value; });
slider.addEventListener('change', () => sendPlayback('volume', slider.value));
/* Auto-refresh info every 5 s while page is visible */
setInterval(() => { if (!document.hidden) refreshInfo(); }, 5000);
}); });
+125
View File
@@ -0,0 +1,125 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0f4f8;
color: #1a202c;
min-height: 100vh;
}
header {
background: #2d3748;
color: #fff;
padding: 1rem 1.5rem;
}
header h1 {
font-size: 1.5rem;
font-weight: 700;
}
main {
max-width: 700px;
margin: 1.5rem auto;
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.card {
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,.08);
padding: 1.25rem 1.5rem;
}
.card h2 {
font-size: 1rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .05em;
color: #718096;
margin-bottom: 1rem;
}
/* Playback */
.track-uri {
font-size: .9rem;
color: #4a5568;
word-break: break-all;
min-height: 1.2em;
margin-bottom: .75rem;
}
.playback-controls {
display: flex;
gap: .5rem;
margin-bottom: 1rem;
}
.volume-row {
display: flex;
align-items: center;
gap: .75rem;
}
.volume-row label { font-size: .875rem; color: #718096; white-space: nowrap; }
.volume-row input { flex: 1; }
#volume-label { font-size: .875rem; width: 2rem; text-align: right; }
/* Buttons */
.btn {
padding: .45rem .9rem;
border: none;
border-radius: 6px;
font-size: .875rem;
font-weight: 600;
cursor: pointer;
transition: opacity .15s;
}
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn:not(:disabled):hover { opacity: .85; }
.btn-primary { background: #4299e1; color: #fff; }
.btn-secondary { background: #e2e8f0; color: #2d3748; }
.btn-danger { background: #fc8181; color: #fff; }
/* Device info */
.info-table { width: 100%; border-collapse: collapse; margin-bottom: .75rem; }
.info-table th, .info-table td { padding: .35rem .5rem; text-align: left; font-size: .875rem; }
.info-table th { color: #718096; width: 40%; }
.info-table tr:not(:last-child) th,
.info-table tr:not(:last-child) td { border-bottom: 1px solid #edf2f7; }
/* Storage */
.storage-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: .75rem;
font-size: .875rem;
color: #4a5568;
}
.cache-list { list-style: none; }
.cache-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: .4rem 0;
border-bottom: 1px solid #edf2f7;
font-size: .875rem;
}
.cache-list li:last-child { border-bottom: none; }
.cache-list .cache-key { font-family: monospace; color: #4a5568; }
.cache-list .cache-size { color: #a0aec0; }
/* Bluetooth */
.bt-status { font-weight: 600; margin-bottom: .5rem; }
#btn-bt-toggle { margin-bottom: .5rem; }
.note { font-size: .8rem; color: #a0aec0; }