#include "web_server.h" #include #include #include #include #include "immich_client.h" #include "power_manager.h" #include "wifi_manager.h" #include "config.h" void AppWebServer::begin(SettingsManager& settings, ImmichClient& immich, PowerManager& power, WiFiManager& wifi) { _settings = &settings; _immich = &immich; _power = &power; _wifi = &wifi; if (!LittleFS.begin(true)) { Serial.println("[web] LittleFS mount failed"); } if (_wifi->isAPMode()) { setupCaptivePortal(); } else { setupAPIRoutes(); setupStaticFiles(); setupOTA(); } _server.begin(); Serial.printf("[web] Server started (mode: %s)\n", _wifi->isAPMode() ? "AP/captive" : "LAN"); } void AppWebServer::setActionCallback(std::function cb) { _actionCb = cb; } bool AppWebServer::isOTAInProgress() { return _otaInProgress; } void AppWebServer::setupCaptivePortal() { // Serve setup page for all requests (captive portal behavior) _server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) { request->send(LittleFS, "/setup.html", "text/html"); }); // Kick off initial async WiFi scan WiFi.scanNetworks(true); // Handle WiFi scan (non-blocking — returns cached results or "scanning" status) _server.on("/api/wifi/scan", HTTP_GET, [](AsyncWebServerRequest* request) { int n = WiFi.scanComplete(); if (n == WIFI_SCAN_RUNNING) { // Scan in progress — tell client to retry request->send(200, "application/json", "{\"scanning\":true}"); return; } if (n == WIFI_SCAN_FAILED) { // No scan started or failed — kick one off WiFi.scanNetworks(true); request->send(200, "application/json", "{\"scanning\":true}"); return; } // Scan complete — return results JsonDocument doc; JsonArray arr = doc.to(); for (int i = 0; i < n; i++) { JsonObject net = arr.add(); net["ssid"] = WiFi.SSID(i); net["rssi"] = WiFi.RSSI(i); net["secure"] = WiFi.encryptionType(i) != WIFI_AUTH_OPEN; } String response; serializeJson(doc, response); // Start a fresh scan for next request WiFi.scanDelete(); WiFi.scanNetworks(true); request->send(200, "application/json", response); }); // Handle setup submission _server.on("/api/setup", HTTP_POST, [](AsyncWebServerRequest* request) { request->send(200); }, nullptr, [this](AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { if (index + len == total) { handlePostSetup(request, data, len); } }); // Captive portal redirect for all other paths _server.onNotFound([](AsyncWebServerRequest* request) { request->redirect("/"); }); } void AppWebServer::setupAPIRoutes() { _server.on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* req) { handleGetStatus(req); }); _server.on("/api/albums", HTTP_GET, [this](AsyncWebServerRequest* req) { handleGetAlbums(req); }); _server.on("/api/albums/select", HTTP_POST, [](AsyncWebServerRequest* req) { req->send(200); }, nullptr, [this](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) { if (index + len == total) handlePostAlbumsSelect(req, data, len); }); _server.on("/api/settings", HTTP_GET, [this](AsyncWebServerRequest* req) { handleGetSettings(req); }); _server.on("/api/settings", HTTP_POST, [](AsyncWebServerRequest* req) { req->send(200); }, nullptr, [this](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) { if (index + len == total) handlePostSettings(req, data, len); }); // Action endpoints _server.on("/api/action/next", HTTP_POST, [this](AsyncWebServerRequest* req) { if (_actionCb) _actionCb("next"); req->send(200, "application/json", "{\"ok\":true}"); }); _server.on("/api/action/random", HTTP_POST, [this](AsyncWebServerRequest* req) { if (_actionCb) _actionCb("random"); req->send(200, "application/json", "{\"ok\":true}"); }); _server.on("/api/action/pause", HTTP_POST, [this](AsyncWebServerRequest* req) { if (_actionCb) _actionCb("pause"); req->send(200, "application/json", "{\"ok\":true}"); }); _server.on("/api/action/play", HTTP_POST, [this](AsyncWebServerRequest* req) { if (_actionCb) _actionCb("play"); req->send(200, "application/json", "{\"ok\":true}"); }); _server.on("/api/action/sleep", HTTP_POST, [this](AsyncWebServerRequest* req) { req->send(200, "application/json", "{\"ok\":true}"); delay(100); Settings s = _settings->get(); _power->enterDeepSleep(s.interval_min); }); // Test Immich connectivity _server.on("/api/test-connection", HTTP_POST, [this](AsyncWebServerRequest* req) { Settings s = _settings->get(); Serial.printf("[web] Testing connection to: %s (key: %s...)\n", s.immich_url.c_str(), s.immich_key.substring(0, 8).c_str()); // Reinitialize immich client with current settings _immich->begin(s.immich_url, s.immich_key); auto albums = _immich->fetchAlbums(); JsonDocument doc; if (!albums.empty()) { doc["ok"] = true; doc["msg"] = String("Connected! Found ") + String(albums.size()) + " albums."; } else { doc["ok"] = false; doc["msg"] = "Failed to connect. Check URL and API key."; } String response; serializeJson(doc, response); req->send(200, "application/json", response); }); _server.on("/api/action/reboot", HTTP_POST, [](AsyncWebServerRequest* req) { req->send(200, "application/json", "{\"ok\":true,\"msg\":\"Rebooting...\"}"); delay(500); ESP.restart(); }); } void AppWebServer::setupStaticFiles() { _server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); } void AppWebServer::setupOTA() { _server.on("/api/firmware", HTTP_POST, [this](AsyncWebServerRequest* request) { _otaInProgress = false; bool success = !Update.hasError(); request->send(200, "application/json", success ? "{\"ok\":true,\"msg\":\"Rebooting...\"}" : "{\"ok\":false,\"msg\":\"Update failed\"}"); if (success) { delay(500); ESP.restart(); } }, [this](AsyncWebServerRequest* request, const String& filename, size_t index, uint8_t* data, size_t len, bool final) { if (index == 0) { _otaInProgress = true; Serial.printf("[web] OTA start: %s\n", filename.c_str()); if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH)) { Serial.println("[web] OTA begin failed"); } } if (Update.isRunning()) { Update.write(data, len); } if (final) { if (Update.end(true)) { Serial.printf("[web] OTA complete: %u bytes\n", index + len); } else { Serial.println("[web] OTA finalize failed"); Update.printError(Serial); } } }); } void AppWebServer::handleGetStatus(AsyncWebServerRequest* request) { JsonDocument doc; doc["battery"] = _power->getBatteryPercent(); doc["charging"] = _power->isCharging(); doc["wifi_rssi"] = _wifi->getRSSI(); doc["ip"] = _wifi->getIP(); doc["uptime"] = millis() / 1000; doc["free_heap"] = ESP.getFreeHeap(); doc["free_psram"] = ESP.getFreePsram(); String response; serializeJson(doc, response); request->send(200, "application/json", response); } void AppWebServer::handleGetAlbums(AsyncWebServerRequest* request) { auto albums = _immich->fetchAlbums(); JsonDocument doc; JsonArray arr = doc.to(); for (auto& album : albums) { JsonObject obj = arr.add(); obj["id"] = album.id; obj["title"] = album.title; obj["assetCount"] = album.assetCount; } String response; serializeJson(doc, response); request->send(200, "application/json", response); } void AppWebServer::handlePostAlbumsSelect(AsyncWebServerRequest* request, uint8_t* data, size_t len) { String body = String((char*)data).substring(0, len); JsonDocument doc; if (deserializeJson(doc, body)) { request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); return; } String albumsJson; serializeJson(doc["album_ids"], albumsJson); _settings->saveField("albums_json", albumsJson.c_str()); request->send(200, "application/json", "{\"ok\":true}"); } void AppWebServer::handleGetSettings(AsyncWebServerRequest* request) { Settings s = _settings->get(); JsonDocument doc; doc["interval_min"] = s.interval_min; doc["cycle_mode"] = static_cast(s.cycle_mode); doc["img_quality"] = static_cast(s.img_quality); doc["meta_flags"] = s.meta_flags; doc["meta_pos"] = static_cast(s.meta_pos); doc["date_fmt"] = s.date_fmt; doc["time_fmt"] = s.time_fmt; doc["pipeline_mode"] = s.pipeline_mode; doc["dither_noise"] = s.dither_noise; doc["led_brightness"] = s.led_brightness; doc["show_battery"] = s.show_battery; doc["immich_url"] = s.immich_url; doc["immich_key"] = s.immich_key; doc["albums_json"] = s.albums_json; String response; serializeJson(doc, response); request->send(200, "application/json", response); } void AppWebServer::handlePostSettings(AsyncWebServerRequest* request, uint8_t* data, size_t len) { String body = String((char*)data).substring(0, len); JsonDocument doc; if (deserializeJson(doc, body)) { request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); return; } Settings s = _settings->get(); if (!doc["interval_min"].isNull()) s.interval_min = doc["interval_min"]; if (!doc["cycle_mode"].isNull()) s.cycle_mode = static_cast((uint8_t)doc["cycle_mode"]); if (!doc["img_quality"].isNull()) s.img_quality = static_cast((uint8_t)doc["img_quality"]); if (!doc["meta_flags"].isNull()) s.meta_flags = doc["meta_flags"]; if (!doc["meta_pos"].isNull()) s.meta_pos = static_cast((uint8_t)doc["meta_pos"]); if (!doc["date_fmt"].isNull()) s.date_fmt = doc["date_fmt"].as(); if (!doc["time_fmt"].isNull()) s.time_fmt = doc["time_fmt"].as(); if (!doc["pipeline_mode"].isNull()) s.pipeline_mode = doc["pipeline_mode"]; if (!doc["dither_noise"].isNull()) s.dither_noise = doc["dither_noise"]; if (!doc["led_brightness"].isNull()) s.led_brightness = doc["led_brightness"]; if (!doc["show_battery"].isNull()) s.show_battery = doc["show_battery"]; if (!doc["immich_url"].isNull()) s.immich_url = doc["immich_url"].as(); if (!doc["immich_key"].isNull()) s.immich_key = doc["immich_key"].as(); _settings->save(s); request->send(200, "application/json", "{\"ok\":true}"); } void AppWebServer::handlePostSetup(AsyncWebServerRequest* request, uint8_t* data, size_t len) { String body = String((char*)data).substring(0, len); JsonDocument doc; if (deserializeJson(doc, body)) { request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); return; } Settings s = _settings->get(); s.wifi_ssid = doc["wifi_ssid"].as(); s.wifi_pass = doc["wifi_pass"].as(); s.immich_url = doc["immich_url"] | DEFAULT_IMMICH_URL; s.immich_key = doc["immich_key"].as(); _settings->save(s); request->send(200, "application/json", "{\"ok\":true,\"msg\":\"Rebooting...\"}"); delay(1000); ESP.restart(); }