#include "WebInterface.hpp" #include "AudioPlayer.hpp" #include "CacheManager.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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= — 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); }