#include "web_server.h" #include "esp_log.h" #include "esp_http_server.h" #include "esp_system.h" #include "cJSON.h" #include #include #include #include #include #include "esp_wifi.h" #include "settings.h" #include "led.h" #include "wifi.h" #include "ethernet.h" #include "ota.h" #include "log_stream.h" #include "rtsp_server.h" #include "esp_app_desc.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "dac_njw1195.h" #include "panel_uart.h" #include "playback_control.h" #include "rtsp_events.h" #ifdef CONFIG_BT_A2DP_ENABLE #include "a2dp_sink.h" #endif #ifdef CONFIG_DAC_TAS58XX #include "eq_events.h" #include "dac_tas58xx_eq.h" #endif static const char *TAG = "web_server"; static httpd_handle_t s_server = NULL; #define SPIFFS_CHUNK_SIZE 1024 static esp_err_t serve_spiffs_file(httpd_req_t *req, const char *path, const char *content_type) { FILE *f = fopen(path, "r"); if (!f) { ESP_LOGE(TAG, "Failed to open %s", path); httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found"); return ESP_FAIL; } httpd_resp_set_type(req, content_type); char buf[SPIFFS_CHUNK_SIZE]; size_t n; while ((n = fread(buf, 1, sizeof(buf), f)) > 0) { if (httpd_resp_send_chunk(req, buf, (ssize_t)n) != ESP_OK) { fclose(f); httpd_resp_send_chunk(req, NULL, 0); return ESP_FAIL; } } fclose(f); httpd_resp_send_chunk(req, NULL, 0); return ESP_OK; } // API handlers static esp_err_t root_handler(httpd_req_t *req) { return serve_spiffs_file(req, "/spiffs/www/index.html", "text/html"); } static esp_err_t favicon_handler(httpd_req_t *req) { httpd_resp_set_status(req, "204 No Content"); httpd_resp_send(req, NULL, 0); return ESP_OK; } static esp_err_t logs_page_handler(httpd_req_t *req) { return serve_spiffs_file(req, "/spiffs/www/logs.html", "text/html"); } static esp_err_t speedtest_page_handler(httpd_req_t *req) { return serve_spiffs_file(req, "/spiffs/www/speedtest.html", "text/html"); } // Tiny endpoint used by JS for RTT timing. Returns minimal body. static esp_err_t speedtest_ping_handler(httpd_req_t *req) { httpd_resp_set_type(req, "text/plain"); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); httpd_resp_send(req, "ok", 2); return ESP_OK; } // Streams `bytes` octets of filler data so the browser can measure DL speed. // Capped to avoid pathological requests starving audio. #define SPEEDTEST_MAX_BYTES ((size_t)16 * 1024 * 1024) #define SPEEDTEST_CHUNK 2048 static esp_err_t speedtest_download_handler(httpd_req_t *req) { size_t bytes = (size_t)1024 * 1024; char qbuf[64]; if (httpd_req_get_url_query_str(req, qbuf, sizeof(qbuf)) == ESP_OK) { char val[16]; if (httpd_query_key_value(qbuf, "bytes", val, sizeof(val)) == ESP_OK) { long v = strtol(val, NULL, 10); if (v > 0) { bytes = (size_t)v; } } } if (bytes > SPEEDTEST_MAX_BYTES) { bytes = SPEEDTEST_MAX_BYTES; } // Reuse a single buffer of filler bytes. Static so we don't repeatedly // hammer the heap; content is irrelevant but non-zero to thwart any // compression along the way. static uint8_t filler[SPEEDTEST_CHUNK]; static bool filler_init = false; if (!filler_init) { for (size_t i = 0; i < sizeof(filler); i++) { filler[i] = (uint8_t)(i * 37); } filler_init = true; } httpd_resp_set_type(req, "application/octet-stream"); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); size_t remaining = bytes; while (remaining > 0) { ssize_t n = remaining < SPEEDTEST_CHUNK ? (ssize_t)remaining : SPEEDTEST_CHUNK; if (httpd_resp_send_chunk(req, (const char *)filler, n) != ESP_OK) { return ESP_FAIL; } remaining -= (size_t)n; } httpd_resp_send_chunk(req, NULL, 0); return ESP_OK; } // Consumes a POST body and reports how many bytes were received. static esp_err_t speedtest_upload_handler(httpd_req_t *req) { size_t total = req->content_len; size_t got = 0; uint8_t buf[SPEEDTEST_CHUNK]; while (got < total) { size_t want = total - got; if (want > sizeof(buf)) { want = sizeof(buf); } int r = httpd_req_recv(req, (char *)buf, want); if (r <= 0) { if (r == HTTPD_SOCK_ERR_TIMEOUT) { continue; } return ESP_FAIL; } got += (size_t)r; } char reply[64]; int n = snprintf(reply, sizeof(reply), "received=%u", (unsigned)got); httpd_resp_set_type(req, "text/plain"); httpd_resp_send(req, reply, n); return ESP_OK; } // Captive portal detection handlers // These endpoints are requested by various OS to detect captive portals static esp_err_t captive_portal_redirect(httpd_req_t *req) { // Redirect to the configuration page httpd_resp_set_status(req, "302 Found"); httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/"); httpd_resp_send(req, NULL, 0); return ESP_OK; } // Apple devices (iOS/macOS) check these static esp_err_t captive_apple_handler(httpd_req_t *req) { // Apple expects specific response, redirect instead return captive_portal_redirect(req); } // Android checks this static esp_err_t captive_android_handler(httpd_req_t *req) { // Android expects 204 for no captive portal, anything else triggers portal return captive_portal_redirect(req); } // Windows checks this static esp_err_t captive_windows_handler(httpd_req_t *req) { return captive_portal_redirect(req); } // ============================================================================ // Metadata Listener // ============================================================================ static rtsp_metadata_t s_current_metadata = {0}; static bool s_client_connected = false; static void web_server_rtsp_event_cb(rtsp_event_t event, const rtsp_event_data_t *data, void *user_data) { if (event == RTSP_EVENT_METADATA && data) { memcpy(&s_current_metadata, &data->metadata, sizeof(rtsp_metadata_t)); } else if (event == RTSP_EVENT_CLIENT_CONNECTED) { s_client_connected = true; } else if (event == RTSP_EVENT_DISCONNECTED) { s_client_connected = false; memset(&s_current_metadata, 0, sizeof(rtsp_metadata_t)); } } // ============================================================================ // Dashboard API Handlers // ============================================================================ static esp_err_t api_status_handler(httpd_req_t *req) { cJSON *json = cJSON_CreateObject(); cJSON_AddNumberToObject(json, "battery", panel_uart_get_battery_percent()); cJSON_AddBoolToObject(json, "charging", panel_uart_get_charging_status()); cJSON_AddNumberToObject(json, "volume", dac_njw1195_get_vol_main()); cJSON_AddNumberToObject(json, "prompt_volume", dac_njw1195_get_prompt_volume()); cJSON_AddNumberToObject(json, "bass_volume", dac_njw1195_get_vol_bass()); cJSON_AddNumberToObject(json, "source", dac_njw1195_get_source()); if (dac_njw1195_get_source() == NJW_SOURCE_AUX) { cJSON_AddStringToObject(json, "conn_type", "AUX"); } else { playback_source_t src = playback_control_get_source(); if (src == PLAYBACK_SOURCE_AIRPLAY) { cJSON_AddStringToObject(json, "conn_type", "AirPlay"); } else if (src == PLAYBACK_SOURCE_BLUETOOTH) { cJSON_AddStringToObject(json, "conn_type", "蓝牙"); } else { cJSON_AddStringToObject(json, "conn_type", "未连接"); } } // Basic sys_status logic if (panel_uart_get_battery_percent() <= 10 && !panel_uart_get_charging_status()) { cJSON_AddStringToObject(json, "sys_status", "低电量"); } else if (panel_uart_get_charging_status()) { cJSON_AddStringToObject(json, "sys_status", "充电中"); } else { cJSON_AddStringToObject(json, "sys_status", "正常"); } if (strlen(s_current_metadata.title) > 0) { cJSON_AddStringToObject(json, "title", s_current_metadata.title); cJSON_AddStringToObject(json, "artist", s_current_metadata.artist); cJSON_AddStringToObject(json, "album", s_current_metadata.album); cJSON_AddNumberToObject(json, "duration", s_current_metadata.duration_secs); cJSON_AddNumberToObject(json, "position", s_current_metadata.position_secs); } else { cJSON_AddStringToObject(json, "title", ""); } const char *resp = cJSON_PrintUnformatted(json); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, strlen(resp)); free((void *)resp); cJSON_Delete(json); return ESP_OK; } static esp_err_t api_control_handler(httpd_req_t *req) { char buf[256]; int ret, remaining = req->content_len; if (remaining >= sizeof(buf)) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Payload too large"); return ESP_FAIL; } if ((ret = httpd_req_recv(req, buf, remaining)) <= 0) { return ESP_FAIL; } buf[ret] = '\0'; cJSON *json = cJSON_Parse(buf); if (!json) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON"); return ESP_FAIL; } cJSON *action = cJSON_GetObjectItem(json, "action"); if (action && cJSON_IsString(action)) { if (strcmp(action->valuestring, "set_volume") == 0) { cJSON *vol = cJSON_GetObjectItem(json, "volume"); if (vol && cJSON_IsNumber(vol)) { dac_njw1195_set_vol_main(vol->valueint); playback_control_set_volume_percent(vol->valueint); panel_uart_report(0x0C, vol->valueint); // ITEM_VOL_LR } } else if (strcmp(action->valuestring, "set_prompt_volume") == 0) { cJSON *vol = cJSON_GetObjectItem(json, "volume"); if (vol && cJSON_IsNumber(vol)) { dac_njw1195_set_prompt_volume(vol->valueint); } } else if (strcmp(action->valuestring, "set_bass_volume") == 0) { cJSON *vol = cJSON_GetObjectItem(json, "volume"); if (vol && cJSON_IsNumber(vol)) { dac_njw1195_set_vol_bass(vol->valueint); } } else if (strcmp(action->valuestring, "set_source") == 0) { cJSON *src = cJSON_GetObjectItem(json, "source"); if (src && cJSON_IsNumber(src)) { dac_njw1195_set_source((njw_source_t)src->valueint); } } else if (strcmp(action->valuestring, "clear_bt") == 0) { #ifdef CONFIG_BT_A2DP_ENABLE settings_clear_last_bt_bda(); bt_a2dp_sink_disconnect_current(); #endif } } cJSON_Delete(json); const char *resp = "{\"success\":true}"; httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, strlen(resp)); return ESP_OK; } static esp_err_t wifi_scan_handler(httpd_req_t *req) { wifi_ap_record_t *ap_list = NULL; uint16_t ap_count = 0; cJSON *json = cJSON_CreateObject(); esp_err_t err = wifi_scan(&ap_list, &ap_count); if (err == ESP_OK && ap_list) { cJSON *networks = cJSON_CreateArray(); for (uint16_t i = 0; i < ap_count; i++) { cJSON *net = cJSON_CreateObject(); cJSON_AddStringToObject(net, "ssid", (char *)ap_list[i].ssid); cJSON_AddNumberToObject(net, "rssi", ap_list[i].rssi); cJSON_AddNumberToObject(net, "channel", ap_list[i].primary); cJSON_AddItemToArray(networks, net); } cJSON_AddItemToObject(json, "networks", networks); cJSON_AddBoolToObject(json, "success", true); free(ap_list); } else { cJSON_AddBoolToObject(json, "success", false); cJSON_AddStringToObject(json, "error", esp_err_to_name(err)); } char *json_str = cJSON_Print(json); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json_str, HTTPD_RESP_USE_STRLEN); free(json_str); cJSON_Delete(json); return ESP_OK; } static esp_err_t wifi_config_handler(httpd_req_t *req) { char content[512]; int ret = httpd_req_recv(req, content, sizeof(content) - 1); if (ret <= 0) { httpd_resp_send_500(req); return ESP_FAIL; } content[ret] = '\0'; cJSON *json = cJSON_Parse(content); if (!json) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON"); return ESP_FAIL; } cJSON *ssid_json = cJSON_GetObjectItem(json, "ssid"); cJSON *password_json = cJSON_GetObjectItem(json, "password"); cJSON *response = cJSON_CreateObject(); if (ssid_json && cJSON_IsString(ssid_json)) { const char *ssid = cJSON_GetStringValue(ssid_json); const char *password = password_json && cJSON_IsString(password_json) ? cJSON_GetStringValue(password_json) : ""; esp_err_t err = settings_set_wifi_credentials(ssid, password); if (err == ESP_OK) { cJSON_AddBoolToObject(response, "success", true); ESP_LOGI(TAG, "WiFi credentials saved. We are restarting..."); // Schedule restart vTaskDelay(pdMS_TO_TICKS(1000)); esp_restart(); } else { cJSON_AddBoolToObject(response, "success", false); cJSON_AddStringToObject(response, "error", esp_err_to_name(err)); } } else { cJSON_AddBoolToObject(response, "success", false); cJSON_AddStringToObject(response, "error", "Invalid SSID"); } char *json_str = cJSON_Print(response); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json_str, HTTPD_RESP_USE_STRLEN); free(json_str); cJSON_Delete(json); cJSON_Delete(response); return ESP_OK; } static esp_err_t device_name_handler(httpd_req_t *req) { char content[256]; int ret = httpd_req_recv(req, content, sizeof(content) - 1); if (ret <= 0) { httpd_resp_send_500(req); return ESP_FAIL; } content[ret] = '\0'; cJSON *json = cJSON_Parse(content); if (!json) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON"); return ESP_FAIL; } cJSON *name_json = cJSON_GetObjectItem(json, "name"); cJSON *response = cJSON_CreateObject(); if (name_json && cJSON_IsString(name_json)) { const char *name = cJSON_GetStringValue(name_json); esp_err_t err = settings_set_device_name(name); if (err == ESP_OK) { wifi_set_hostname(name); ethernet_set_hostname(name); cJSON_AddBoolToObject(response, "success", true); } else { cJSON_AddBoolToObject(response, "success", false); cJSON_AddStringToObject(response, "error", esp_err_to_name(err)); } } else { cJSON_AddBoolToObject(response, "success", false); cJSON_AddStringToObject(response, "error", "Invalid name"); } char *json_str = cJSON_Print(response); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json_str, HTTPD_RESP_USE_STRLEN); free(json_str); cJSON_Delete(json); cJSON_Delete(response); return ESP_OK; } static esp_err_t led_brightness_get_handler(httpd_req_t *req) { cJSON *json = cJSON_CreateObject(); cJSON_AddNumberToObject(json, "brightness", led_get_brightness()); cJSON_AddBoolToObject(json, "success", true); char *json_str = cJSON_Print(json); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json_str, HTTPD_RESP_USE_STRLEN); free(json_str); cJSON_Delete(json); return ESP_OK; } static esp_err_t led_brightness_post_handler(httpd_req_t *req) { char content[64]; int ret = httpd_req_recv(req, content, sizeof(content) - 1); if (ret <= 0) { httpd_resp_send_500(req); return ESP_FAIL; } content[ret] = '\0'; cJSON *json = cJSON_Parse(content); if (!json) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON"); return ESP_FAIL; } cJSON *response = cJSON_CreateObject(); cJSON *val = cJSON_GetObjectItem(json, "brightness"); if (val && cJSON_IsNumber(val)) { int b = (int)val->valuedouble; if (b < 0) { b = 0; } if (b > 255) { b = 255; } esp_err_t err = led_set_brightness((uint8_t)b); if (err == ESP_OK) { cJSON_AddBoolToObject(response, "success", true); } else { cJSON_AddBoolToObject(response, "success", false); cJSON_AddStringToObject(response, "error", esp_err_to_name(err)); } } else { cJSON_AddBoolToObject(response, "success", false); cJSON_AddStringToObject(response, "error", "Expected {\"brightness\": 0-255}"); } char *json_str = cJSON_Print(response); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json_str, HTTPD_RESP_USE_STRLEN); free(json_str); cJSON_Delete(json); cJSON_Delete(response); return ESP_OK; } static esp_err_t ota_update_handler(httpd_req_t *req) { if (req->content_len == 0) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "No firmware uploaded"); return ESP_FAIL; } // Stop AirPlay to free resources during OTA ESP_LOGI(TAG, "Stopping AirPlay for OTA update"); rtsp_server_stop(); esp_err_t err = ota_start_from_http(req); if (err != ESP_OK) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(err)); return ESP_FAIL; } // Send response before restarting httpd_resp_sendstr(req, "Firmware update complete, rebooting now!\n"); vTaskDelay(pdMS_TO_TICKS(500)); esp_restart(); return ESP_OK; } static esp_err_t system_info_handler(httpd_req_t *req) { cJSON *json = cJSON_CreateObject(); cJSON *info = cJSON_CreateObject(); char ip_str[16] = {0}; char mac_str[18] = {0}; char device_name[65] = {0}; bool wifi_connected = wifi_is_connected(); bool eth_connected = ethernet_is_connected(); // Show IP and MAC for the active interface if (eth_connected) { ethernet_get_ip_str(ip_str, sizeof(ip_str)); ethernet_get_mac_str(mac_str, sizeof(mac_str)); } else { wifi_get_ip_str(ip_str, sizeof(ip_str)); wifi_get_mac_str(mac_str, sizeof(mac_str)); } settings_get_device_name(device_name, sizeof(device_name)); cJSON_AddStringToObject(info, "ip", ip_str); cJSON_AddStringToObject(info, "mac", mac_str); cJSON_AddStringToObject(info, "device_name", device_name); cJSON_AddBoolToObject(info, "wifi_connected", wifi_connected); cJSON_AddBoolToObject(info, "eth_connected", eth_connected); cJSON_AddNumberToObject(info, "free_heap", esp_get_free_heap_size()); // WiFi link diagnostics (only meaningful when associated as STA) if (wifi_connected) { wifi_ap_record_t ap; if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) { char ssid_buf[33]; size_t slen = strnlen((const char *)ap.ssid, sizeof(ap.ssid)); if (slen > sizeof(ssid_buf) - 1) { slen = sizeof(ssid_buf) - 1; } memcpy(ssid_buf, ap.ssid, slen); ssid_buf[slen] = '\0'; char bssid_buf[18]; snprintf(bssid_buf, sizeof(bssid_buf), "%02x:%02x:%02x:%02x:%02x:%02x", ap.bssid[0], ap.bssid[1], ap.bssid[2], ap.bssid[3], ap.bssid[4], ap.bssid[5]); const char *phy = "?"; if (ap.phy_11n) { phy = "11n"; } else if (ap.phy_11g) { phy = "11g"; } else if (ap.phy_11b) { phy = "11b"; } else if (ap.phy_lr) { phy = "LR"; } cJSON_AddStringToObject(info, "wifi_ssid", ssid_buf); cJSON_AddStringToObject(info, "wifi_bssid", bssid_buf); cJSON_AddNumberToObject(info, "wifi_rssi", ap.rssi); cJSON_AddNumberToObject(info, "wifi_channel", ap.primary); cJSON_AddStringToObject(info, "wifi_phy", phy); } } const esp_app_desc_t *app_desc = esp_app_get_description(); cJSON_AddStringToObject(info, "firmware_version", app_desc->version); #ifdef CONFIG_DAC_TAS58XX cJSON_AddBoolToObject(info, "eq_supported", true); #else cJSON_AddBoolToObject(info, "eq_supported", false); #endif cJSON_AddItemToObject(json, "info", info); cJSON_AddBoolToObject(json, "success", true); char *json_str = cJSON_Print(json); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json_str, HTTPD_RESP_USE_STRLEN); free(json_str); cJSON_Delete(json); return ESP_OK; } static esp_err_t system_restart_handler(httpd_req_t *req) { cJSON *json = cJSON_CreateObject(); cJSON_AddBoolToObject(json, "success", true); char *json_str = cJSON_Print(json); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json_str, HTTPD_RESP_USE_STRLEN); free(json_str); cJSON_Delete(json); ESP_LOGI(TAG, "Restart requested via web interface"); vTaskDelay(pdMS_TO_TICKS(500)); esp_restart(); return ESP_OK; } /* ================================================================== */ /* SPIFFS File Management API */ /* ================================================================== */ // Allowed path prefixes for file upload (prevent writes outside SPIFFS) static const char *ALLOWED_PREFIXES[] = {"/spiffs/"}; static bool is_path_allowed(const char *path) { for (int i = 0; i < sizeof(ALLOWED_PREFIXES) / sizeof(ALLOWED_PREFIXES[0]); i++) { if (strncmp(path, ALLOWED_PREFIXES[i], strlen(ALLOWED_PREFIXES[i])) == 0) { // Reject path traversal if (strstr(path, "..") != NULL) { return false; } return true; } } return false; } static esp_err_t fs_upload_handler(httpd_req_t *req) { // Get target path from query string char query[128] = {0}; char path[64] = {0}; if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK || httpd_query_key_value(query, "path", path, sizeof(path)) != ESP_OK) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing 'path' query parameter"); return ESP_FAIL; } if (!is_path_allowed(path)) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Path not allowed"); return ESP_FAIL; } if (req->content_len == 0 || req->content_len > (size_t)(64 * 1024)) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Body required (max 64KB)"); return ESP_FAIL; } FILE *f = fopen(path, "wb"); if (!f) { ESP_LOGE(TAG, "Failed to create %s", path); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to create file"); return ESP_FAIL; } char buf[SPIFFS_CHUNK_SIZE]; size_t remaining = req->content_len; while (remaining > 0) { size_t to_read = remaining < sizeof(buf) ? remaining : sizeof(buf); int received = httpd_req_recv(req, buf, to_read); if (received <= 0) { fclose(f); remove(path); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Receive failed"); return ESP_FAIL; } fwrite(buf, 1, (size_t)received, f); remaining -= (size_t)received; } fclose(f); ESP_LOGI(TAG, "Uploaded %u bytes to %s", (unsigned)req->content_len, path); cJSON *json = cJSON_CreateObject(); cJSON_AddBoolToObject(json, "success", true); cJSON_AddNumberToObject(json, "size", (double)req->content_len); cJSON_AddStringToObject(json, "path", path); char *json_str = cJSON_Print(json); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json_str, HTTPD_RESP_USE_STRLEN); free(json_str); cJSON_Delete(json); return ESP_OK; } static esp_err_t fs_delete_handler(httpd_req_t *req) { char query[128] = {0}; char path[64] = {0}; if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK || httpd_query_key_value(query, "path", path, sizeof(path)) != ESP_OK) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing 'path' query parameter"); return ESP_FAIL; } if (!is_path_allowed(path)) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Path not allowed"); return ESP_FAIL; } cJSON *json = cJSON_CreateObject(); if (remove(path) == 0) { ESP_LOGI(TAG, "Deleted %s", path); cJSON_AddBoolToObject(json, "success", true); } else { cJSON_AddBoolToObject(json, "success", false); cJSON_AddStringToObject(json, "error", "File not found"); } char *json_str = cJSON_Print(json); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json_str, HTTPD_RESP_USE_STRLEN); free(json_str); cJSON_Delete(json); return ESP_OK; } static esp_err_t fs_list_handler(httpd_req_t *req) { char query[128] = {0}; char dir_path[64] = "/spiffs"; if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) { httpd_query_key_value(query, "dir", dir_path, sizeof(dir_path)); } if (!is_path_allowed(dir_path) && strcmp(dir_path, "/spiffs") != 0) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Path not allowed"); return ESP_FAIL; } DIR *d = opendir(dir_path); cJSON *json = cJSON_CreateObject(); cJSON *files = cJSON_CreateArray(); if (d) { struct dirent *entry; while ((entry = readdir(d)) != NULL) { cJSON *item = cJSON_CreateObject(); cJSON_AddStringToObject(item, "name", entry->d_name); char full_path[320]; snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, entry->d_name); struct stat st; if (stat(full_path, &st) == 0) { cJSON_AddNumberToObject(item, "size", (double)st.st_size); } cJSON_AddItemToArray(files, item); } closedir(d); cJSON_AddBoolToObject(json, "success", true); } else { cJSON_AddBoolToObject(json, "success", false); cJSON_AddStringToObject(json, "error", "Cannot open directory"); } cJSON_AddItemToObject(json, "files", files); char *json_str = cJSON_Print(json); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json_str, HTTPD_RESP_USE_STRLEN); free(json_str); cJSON_Delete(json); return ESP_OK; } /* ================================================================== */ /* EQ Page + API (only when TAS58xx DAC is configured) */ /* ================================================================== */ #ifdef CONFIG_DAC_TAS58XX static esp_err_t eq_page_handler(httpd_req_t *req) { return serve_spiffs_file(req, "/spiffs/www/eq.html", "text/html"); } static esp_err_t eq_get_handler(httpd_req_t *req) { cJSON *json = cJSON_CreateObject(); cJSON *arr = cJSON_CreateArray(); float gains[SETTINGS_EQ_BANDS]; if (settings_get_eq_gains(gains) == ESP_OK) { for (int i = 0; i < SETTINGS_EQ_BANDS; i++) { cJSON_AddItemToArray(arr, cJSON_CreateNumber((double)gains[i])); } } else { /* No saved EQ — return all zeros (flat) */ for (int i = 0; i < SETTINGS_EQ_BANDS; i++) { cJSON_AddItemToArray(arr, cJSON_CreateNumber(0.0)); } } cJSON_AddItemToObject(json, "gains", arr); cJSON_AddNumberToObject(json, "bands", SETTINGS_EQ_BANDS); cJSON_AddBoolToObject(json, "success", true); char *json_str = cJSON_Print(json); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json_str, HTTPD_RESP_USE_STRLEN); free(json_str); cJSON_Delete(json); return ESP_OK; } static esp_err_t eq_post_handler(httpd_req_t *req) { char content[512]; int ret = httpd_req_recv(req, content, sizeof(content) - 1); if (ret <= 0) { httpd_resp_send_500(req); return ESP_FAIL; } content[ret] = '\0'; cJSON *json = cJSON_Parse(content); if (!json) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON"); return ESP_FAIL; } cJSON *response = cJSON_CreateObject(); cJSON *gains_arr = cJSON_GetObjectItem(json, "gains"); if (gains_arr && cJSON_IsArray(gains_arr) && cJSON_GetArraySize(gains_arr) == SETTINGS_EQ_BANDS) { float gains[SETTINGS_EQ_BANDS]; for (int i = 0; i < SETTINGS_EQ_BANDS; i++) { cJSON *item = cJSON_GetArrayItem(gains_arr, i); gains[i] = cJSON_IsNumber(item) ? (float)item->valuedouble : 0.0f; /* Clamp */ if (gains[i] > 15.0f) { gains[i] = 15.0f; } if (gains[i] < -15.0f) { gains[i] = -15.0f; } } /* Emit event — listeners (settings + DAC) will handle it */ eq_event_data_t ev_data; memcpy(ev_data.all_bands.gains_db, gains, sizeof(gains)); eq_events_emit(EQ_EVENT_ALL_BANDS_SET, &ev_data); cJSON_AddBoolToObject(response, "success", true); } else { cJSON_AddBoolToObject(response, "success", false); cJSON_AddStringToObject(response, "error", "Expected 'gains' array with 15 values"); } char *json_str = cJSON_Print(response); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json_str, HTTPD_RESP_USE_STRLEN); free(json_str); cJSON_Delete(json); cJSON_Delete(response); return ESP_OK; } #endif /* CONFIG_DAC_TAS58XX */ esp_err_t web_server_start(uint16_t port) { if (s_server) { ESP_LOGW(TAG, "Web server already running"); return ESP_OK; } rtsp_events_register(web_server_rtsp_event_cb, NULL); httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = port; #ifdef CONFIG_BT_ENABLED config.max_open_sockets = 2; // BT: tighter socket budget (LWIP 12) config.send_wait_timeout = 10; // BT/WiFi coexistence slows TCP drain #else config.max_open_sockets = 3; // Limit to save lwIP socket slots for AirPlay #endif config.lru_purge_enable = true; // Reclaim stale sockets when all are in use config.max_uri_handlers = 28; // Room for captive portal + EQ + speedtest + brightness config.max_resp_headers = 8; config.stack_size = 8192; esp_err_t err = httpd_start(&s_server, &config); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to start web server: %s", esp_err_to_name(err)); return err; } // Register handlers httpd_uri_t root_uri = { .uri = "/", .method = HTTP_GET, .handler = root_handler}; httpd_register_uri_handler(s_server, &root_uri); httpd_uri_t favicon_uri = { .uri = "/favicon.ico", .method = HTTP_GET, .handler = favicon_handler}; httpd_register_uri_handler(s_server, &favicon_uri); httpd_uri_t logs_uri = { .uri = "/logs", .method = HTTP_GET, .handler = logs_page_handler}; httpd_register_uri_handler(s_server, &logs_uri); httpd_uri_t speedtest_page_uri = {.uri = "/speedtest", .method = HTTP_GET, .handler = speedtest_page_handler}; httpd_register_uri_handler(s_server, &speedtest_page_uri); httpd_uri_t speedtest_ping_uri = {.uri = "/api/speedtest/ping", .method = HTTP_GET, .handler = speedtest_ping_handler}; httpd_register_uri_handler(s_server, &speedtest_ping_uri); httpd_uri_t speedtest_dl_uri = {.uri = "/api/speedtest/download", .method = HTTP_GET, .handler = speedtest_download_handler}; httpd_register_uri_handler(s_server, &speedtest_dl_uri); httpd_uri_t speedtest_ul_uri = {.uri = "/api/speedtest/upload", .method = HTTP_POST, .handler = speedtest_upload_handler}; httpd_register_uri_handler(s_server, &speedtest_ul_uri); httpd_uri_t wifi_scan_uri = {.uri = "/api/wifi/scan", .method = HTTP_GET, .handler = wifi_scan_handler}; httpd_register_uri_handler(s_server, &wifi_scan_uri); httpd_uri_t wifi_config_uri = {.uri = "/api/wifi/config", .method = HTTP_POST, .handler = wifi_config_handler}; httpd_register_uri_handler(s_server, &wifi_config_uri); httpd_uri_t device_name_uri = {.uri = "/api/device/name", .method = HTTP_POST, .handler = device_name_handler}; httpd_register_uri_handler(s_server, &device_name_uri); httpd_uri_t led_brightness_get_uri = {.uri = "/api/led/brightness", .method = HTTP_GET, .handler = led_brightness_get_handler}; httpd_register_uri_handler(s_server, &led_brightness_get_uri); httpd_uri_t led_brightness_post_uri = {.uri = "/api/led/brightness", .method = HTTP_POST, .handler = led_brightness_post_handler}; httpd_register_uri_handler(s_server, &led_brightness_post_uri); httpd_uri_t ota_uri = {.uri = "/api/ota/update", .method = HTTP_POST, .handler = ota_update_handler}; httpd_register_uri_handler(s_server, &ota_uri); httpd_uri_t system_info_uri = {.uri = "/api/system/info", .method = HTTP_GET, .handler = system_info_handler}; httpd_register_uri_handler(s_server, &system_info_uri); httpd_uri_t system_restart_uri = {.uri = "/api/system/restart", .method = HTTP_POST, .handler = system_restart_handler}; httpd_register_uri_handler(s_server, &system_restart_uri); // File management API httpd_uri_t fs_upload_uri = {.uri = "/api/fs/upload", .method = HTTP_POST, .handler = fs_upload_handler}; httpd_register_uri_handler(s_server, &fs_upload_uri); httpd_uri_t fs_delete_uri = {.uri = "/api/fs/delete", .method = HTTP_POST, .handler = fs_delete_handler}; httpd_register_uri_handler(s_server, &fs_delete_uri); httpd_uri_t fs_list_uri = { .uri = "/api/fs/list", .method = HTTP_GET, .handler = fs_list_handler}; httpd_register_uri_handler(s_server, &fs_list_uri); httpd_uri_t api_status_uri = { .uri = "/api/status", .method = HTTP_GET, .handler = api_status_handler}; httpd_register_uri_handler(s_server, &api_status_uri); httpd_uri_t api_control_uri = { .uri = "/api/control", .method = HTTP_POST, .handler = api_control_handler}; httpd_register_uri_handler(s_server, &api_control_uri); // Captive portal detection endpoints // Apple iOS/macOS httpd_uri_t apple_captive1 = {.uri = "/hotspot-detect.html", .method = HTTP_GET, .handler = captive_apple_handler}; httpd_register_uri_handler(s_server, &apple_captive1); httpd_uri_t apple_captive2 = {.uri = "/library/test/success.html", .method = HTTP_GET, .handler = captive_apple_handler}; httpd_register_uri_handler(s_server, &apple_captive2); // Android httpd_uri_t android_captive = {.uri = "/generate_204", .method = HTTP_GET, .handler = captive_android_handler}; httpd_register_uri_handler(s_server, &android_captive); // Windows httpd_uri_t windows_captive = {.uri = "/connecttest.txt", .method = HTTP_GET, .handler = captive_windows_handler}; httpd_register_uri_handler(s_server, &windows_captive); #ifdef CONFIG_DAC_TAS58XX httpd_uri_t eq_page_uri = { .uri = "/eq", .method = HTTP_GET, .handler = eq_page_handler}; httpd_register_uri_handler(s_server, &eq_page_uri); httpd_uri_t eq_get_uri = { .uri = "/api/eq", .method = HTTP_GET, .handler = eq_get_handler}; httpd_register_uri_handler(s_server, &eq_get_uri); httpd_uri_t eq_post_uri = { .uri = "/api/eq", .method = HTTP_POST, .handler = eq_post_handler}; httpd_register_uri_handler(s_server, &eq_post_uri); #endif log_stream_register(s_server); ESP_LOGI(TAG, "Web server started on port %d with captive portal support", port); return ESP_OK; } void web_server_stop(void) { if (s_server) { httpd_stop(s_server); s_server = NULL; ESP_LOGI(TAG, "Web server stopped"); } }