mirror of
https://github.com/sweidac/kidsbox.git
synced 2026-10-28 06:52:57 +01:00
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:
19 files changed
+1107
-253
No files matched your search
+1
-1
@@ -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
|
||||||
|
|||||||
+46
-11
@@ -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 {
|
||||||
if (isLastUri(uri)) {
|
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)) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,29 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <esp_player_wrapper.h>
|
||||||
|
|
||||||
class AudioPlayer {
|
class AudioPlayer {
|
||||||
void* playerHandle;
|
void* playerHandle;
|
||||||
std::string lastUri;
|
std::string lastUri;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
AudioPlayer();
|
AudioPlayer();
|
||||||
void play(std::string uri);
|
|
||||||
void pause();
|
|
||||||
|
|
||||||
private:
|
/* Play URI. http:// / https:// URIs stream via HTTP; anything else plays from SD card.
|
||||||
bool isLastUri(std::string uri);
|
* Re-placing the same URI resumes rather than restarting. */
|
||||||
|
void play(const std::string& uri);
|
||||||
|
|
||||||
|
void pause();
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
int getVolume();
|
||||||
|
void setVolume(int vol);
|
||||||
|
|
||||||
|
bool isPlaying() const;
|
||||||
|
const std::string& getCurrentUri() const { return lastUri; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool isHttpUri(const std::string& uri) const;
|
||||||
|
bool isLastUri(const std::string& uri) const;
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,15 +73,18 @@ void RFIDInterface::registerWebResources(WebInterface* interface) {
|
|||||||
httpd_uri_t config = {
|
httpd_uri_t config = {
|
||||||
.uri = "/rfid",
|
.uri = "/rfid",
|
||||||
.method = HTTP_GET,
|
.method = HTTP_GET,
|
||||||
.handler = [](httpd_req_t *req) {
|
.handler = [](httpd_req_t* req) {
|
||||||
httpd_resp_send(req, getLastTagId(), HTTPD_RESP_USE_STRLEN);
|
httpd_resp_send(req, getLastTagId(), HTTPD_RESP_USE_STRLEN);
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -8,11 +8,19 @@
|
|||||||
class RFIDInterface {
|
class RFIDInterface {
|
||||||
rc522_start_args_t startConfig;
|
rc522_start_args_t startConfig;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
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
|
||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
public:
|
std::string lastUid;
|
||||||
TagPlayerControl(std::shared_ptr<AudioPlayer> player);
|
|
||||||
|
|
||||||
void onTagChanged(char* tagId);
|
public:
|
||||||
|
TagPlayerControl(std::shared_ptr<AudioPlayer> player,
|
||||||
|
std::shared_ptr<CacheManager> cache);
|
||||||
|
|
||||||
private:
|
/* Called by RFIDInterface. uid == NULL means tag removed.
|
||||||
void playTag(char* tagId);
|
* 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:
|
||||||
|
void playUrl(const std::string& url);
|
||||||
|
void playLocalTag(const std::string& uid);
|
||||||
};
|
};
|
||||||
+234
-126
@@ -1,37 +1,225 @@
|
|||||||
#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) {
|
||||||
|
const char* type = "text/plain";
|
||||||
|
if(CHECK_FILE_EXTENSION(filepath, ".html")) type = "text/html";
|
||||||
|
else if(CHECK_FILE_EXTENSION(filepath, ".js")) type = "application/javascript";
|
||||||
|
else if(CHECK_FILE_EXTENSION(filepath, ".css")) type = "text/css";
|
||||||
|
else if(CHECK_FILE_EXTENSION(filepath, ".png")) type = "image/png";
|
||||||
|
else if(CHECK_FILE_EXTENSION(filepath, ".ico")) type = "image/x-icon";
|
||||||
|
else if(CHECK_FILE_EXTENSION(filepath, ".svg")) type = "image/svg+xml";
|
||||||
|
else if(CHECK_FILE_EXTENSION(filepath, ".json"))type = "application/json";
|
||||||
|
return httpd_resp_set_type(req, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void send_json(httpd_req_t* req, const std::string& json) {
|
||||||
|
httpd_resp_set_type(req, "application/json");
|
||||||
|
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||||
|
httpd_resp_sendstr(req, json.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void send_json_ok(httpd_req_t* req) {
|
||||||
|
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] == '/') {
|
||||||
|
strlcat(filepath, "/index.html", sizeof(filepath));
|
||||||
|
} else {
|
||||||
|
strlcat(filepath, req->uri, sizeof(filepath));
|
||||||
|
}
|
||||||
|
|
||||||
|
int fd = open(filepath, O_RDONLY);
|
||||||
|
if(fd == -1) {
|
||||||
|
ESP_LOGE(TAG, "Failed to open %s (errno %d)", filepath, errno);
|
||||||
|
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found");
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_content_type(req, filepath);
|
||||||
|
|
||||||
|
char* chunk = ctx->scratch;
|
||||||
|
ssize_t n;
|
||||||
|
while((n = read(fd, chunk, SCRATCH_BUFSIZE)) > 0) {
|
||||||
|
if(httpd_resp_send_chunk(req, chunk, n) != ESP_OK) {
|
||||||
|
close(fd);
|
||||||
|
httpd_resp_sendstr_chunk(req, NULL);
|
||||||
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Send error");
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(fd);
|
||||||
|
httpd_resp_send_chunk(req, NULL, 0);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── API handlers (use shared_ptrs captured from WebInterface) ──────── */
|
||||||
|
|
||||||
|
/* GET /api/info — device info JSON */
|
||||||
|
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() {
|
void WebInterface::start() {
|
||||||
initSD();
|
initSD();
|
||||||
server = start_webserver();
|
server = start_webserver();
|
||||||
}
|
}
|
||||||
|
|
||||||
void WebInterface::finishResourceRegistrations() {
|
void WebInterface::finishResourceRegistrations() {
|
||||||
|
registerApiHandlers();
|
||||||
registerFileServer();
|
registerFileServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,144 +227,64 @@ void WebInterface::initSD() {
|
|||||||
esp_periph_config_t periph_cfg = DEFAULT_ESP_PERIPH_SET_CONFIG();
|
esp_periph_config_t periph_cfg = DEFAULT_ESP_PERIPH_SET_CONFIG();
|
||||||
esp_periph_set_handle_t set = esp_periph_set_init(&periph_cfg);
|
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) {
|
||||||
if (audio_board_sdcard_init(set, SD_MODE_1_LINE) == ESP_FAIL) {
|
ESP_LOGE(TAG, "SDCard not mounted");
|
||||||
ESP_LOGE(TAG, "Sdcard not mounted");
|
|
||||||
} else {
|
} else {
|
||||||
ESP_LOGI(TAG, "SDCard mounted");
|
ESP_LOGI(TAG, "SDCard mounted");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Set HTTP response content type according to file extension */
|
httpd_handle_t WebInterface::start_webserver() {
|
||||||
esp_err_t set_content_type_from_file(httpd_req_t *req, const char *filepath)
|
|
||||||
{
|
|
||||||
const char *type = "text/plain";
|
|
||||||
if (CHECK_FILE_EXTENSION(filepath, ".html")) {
|
|
||||||
type = "text/html";
|
|
||||||
} else if (CHECK_FILE_EXTENSION(filepath, ".js")) {
|
|
||||||
type = "application/javascript";
|
|
||||||
} else if (CHECK_FILE_EXTENSION(filepath, ".css")) {
|
|
||||||
type = "text/css";
|
|
||||||
} else if (CHECK_FILE_EXTENSION(filepath, ".png")) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Send HTTP response with the contents of the requested file */
|
|
||||||
esp_err_t rest_common_get_handler(httpd_req_t *req)
|
|
||||||
{
|
|
||||||
char filepath[FILE_PATH_MAX];
|
|
||||||
|
|
||||||
rest_server_context_t *rest_context = (rest_server_context_t *)req->user_ctx;
|
|
||||||
strlcpy(filepath, rest_context->base_path, sizeof(filepath));
|
|
||||||
|
|
||||||
if (req->uri[strlen(req->uri) - 1] == '/') {
|
|
||||||
strlcat(filepath, "/index.html", sizeof(filepath));
|
|
||||||
} else {
|
|
||||||
strlcat(filepath, req->uri, sizeof(filepath));
|
|
||||||
}
|
|
||||||
|
|
||||||
int fd = open(filepath, O_RDONLY);
|
|
||||||
if (fd == -1) {
|
|
||||||
ESP_LOGE(TAG, "Failed to open file : %s. Errno: %d", filepath, errno);
|
|
||||||
/* Respond with 500 Internal Server Error */
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
set_content_type_from_file(req, filepath);
|
|
||||||
|
|
||||||
char *chunk = rest_context->scratch;
|
|
||||||
ssize_t read_bytes;
|
|
||||||
do {
|
|
||||||
/* Read file in chunks into the scratch buffer */
|
|
||||||
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);
|
|
||||||
ESP_LOGE(TAG, "File sending failed!");
|
|
||||||
/* Abort sending file */
|
|
||||||
httpd_resp_sendstr_chunk(req, NULL);
|
|
||||||
/* Respond with 500 Internal Server Error */
|
|
||||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to send file");
|
|
||||||
return ESP_FAIL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} while (read_bytes > 0);
|
|
||||||
/* Close file after sending complete */
|
|
||||||
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);
|
|
||||||
return ESP_OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
httpd_handle_t WebInterface::start_webserver(void) {
|
|
||||||
httpd_handle_t server = NULL;
|
|
||||||
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::registerApiHandlers() {
|
||||||
|
/* Expose singletons to the static C handlers */
|
||||||
|
g_audio = audioPlayer.get();
|
||||||
|
g_cache = cacheManager.get();
|
||||||
|
|
||||||
|
httpd_uri_t info = { "/api/info", HTTP_GET, api_info_handler, nullptr };
|
||||||
|
httpd_uri_t stg_g = { "/api/storage", HTTP_GET, api_storage_get_handler, nullptr };
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
void WebInterface::registerFileServer() {
|
void WebInterface::registerFileServer() {
|
||||||
rest_server_context_t *fileContext = (rest_server_context_t*)calloc(1, sizeof(rest_server_context_t));
|
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; }
|
||||||
|
|
||||||
if (! fileContext) {
|
strlcpy(ctx->base_path, "/sdcard/www", sizeof(ctx->base_path));
|
||||||
ESP_LOGE(TAG, "No memory for rest context");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
strlcpy(fileContext->base_path, "/sdcard/www", sizeof(fileContext->base_path));
|
httpd_uri_t file_uri = {
|
||||||
|
|
||||||
/* URI handler for getting web server files */
|
|
||||||
httpd_uri_t common_get_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);
|
|
||||||
}
|
}
|
||||||
@@ -2,21 +2,32 @@
|
|||||||
#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);
|
||||||
void initSD();
|
void initSD();
|
||||||
|
|
||||||
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
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+53
-11
@@ -1,17 +1,59 @@
|
|||||||
<html>
|
<!DOCTYPE html>
|
||||||
<head>
|
<html lang="en">
|
||||||
|
<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>🎧 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
@@ -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
@@ -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; }
|
||||||
Reference in new issue
Block a user