Files
kidsbox/device/main/WebInterface.cpp
T
Claude bf9852c345 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
2026-06-12 19:44:54 +00:00

291 lines
9.8 KiB
C++

#include "WebInterface.hpp"
#include "AudioPlayer.hpp"
#include "CacheManager.hpp"
#include <esp_http_server.h>
#include <esp_log.h>
#include <esp_vfs.h>
#include <board.h>
#include <periph_sdcard.h>
#include <esp_system.h>
#include <esp_wifi.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <dirent.h>
#include <sys/stat.h>
#include <string>
#include <sstream>
static const char* TAG = "WebInterface";
#define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128)
#define SCRATCH_BUFSIZE (10240)
#define CHECK_FILE_EXTENSION(f, ext) (strcasecmp(&(f)[strlen(f) - strlen(ext)], (ext)) == 0)
typedef struct {
char base_path[ESP_VFS_PATH_MAX + 1];
char scratch[SCRATCH_BUFSIZE];
} rest_server_context_t;
/* ── 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() {
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();
config.lru_purge_enable = true;
config.uri_match_fn = httpd_uri_match_wildcard;
config.max_uri_handlers = 16;
httpd_handle_t srv = NULL;
ESP_LOGI(TAG, "Starting server on port %d", config.server_port);
if(httpd_start(&srv, &config) == ESP_OK) return srv;
ESP_LOGE(TAG, "Failed to start server");
return NULL;
}
void WebInterface::registerResource(httpd_uri_t* config) {
if(!server) return;
ESP_LOGI(TAG, "Register %s", config->uri);
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() {
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; }
strlcpy(ctx->base_path, "/sdcard/www", sizeof(ctx->base_path));
httpd_uri_t file_uri = {
.uri = "/*",
.method = HTTP_GET,
.handler = rest_common_get_handler,
.user_ctx = ctx
};
registerResource(&file_uri);
}