285 lines
9.9 KiB
C++
285 lines
9.9 KiB
C++
|
|
#include "web_server.h"
|
||
|
|
#include <ArduinoJson.h>
|
||
|
|
#include <LittleFS.h>
|
||
|
|
#include <Update.h>
|
||
|
|
#include <WiFi.h>
|
||
|
|
#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<void(const String& action)> 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");
|
||
|
|
});
|
||
|
|
|
||
|
|
// Handle WiFi scan
|
||
|
|
_server.on("/api/wifi/scan", HTTP_GET, [](AsyncWebServerRequest* request) {
|
||
|
|
int n = WiFi.scanNetworks();
|
||
|
|
JsonDocument doc;
|
||
|
|
JsonArray arr = doc.to<JsonArray>();
|
||
|
|
for (int i = 0; i < n; i++) {
|
||
|
|
JsonObject net = arr.add<JsonObject>();
|
||
|
|
net["ssid"] = WiFi.SSID(i);
|
||
|
|
net["rssi"] = WiFi.RSSI(i);
|
||
|
|
net["secure"] = WiFi.encryptionType(i) != WIFI_AUTH_OPEN;
|
||
|
|
}
|
||
|
|
String response;
|
||
|
|
serializeJson(doc, response);
|
||
|
|
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);
|
||
|
|
_power->enterDeepSleep();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
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<JsonArray>();
|
||
|
|
for (auto& album : albums) {
|
||
|
|
JsonObject obj = arr.add<JsonObject>();
|
||
|
|
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<uint8_t>(s.cycle_mode);
|
||
|
|
doc["img_quality"] = static_cast<uint8_t>(s.img_quality);
|
||
|
|
doc["meta_flags"] = s.meta_flags;
|
||
|
|
doc["meta_pos"] = static_cast<uint8_t>(s.meta_pos);
|
||
|
|
doc["led_brightness"] = s.led_brightness;
|
||
|
|
doc["immich_url"] = s.immich_url;
|
||
|
|
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<CycleMode>((uint8_t)doc["cycle_mode"]);
|
||
|
|
if (!doc["img_quality"].isNull()) s.img_quality = static_cast<ImageQuality>((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<MetaPosition>((uint8_t)doc["meta_pos"]);
|
||
|
|
if (!doc["led_brightness"].isNull()) s.led_brightness = doc["led_brightness"];
|
||
|
|
if (!doc["immich_url"].isNull()) s.immich_url = doc["immich_url"].as<String>();
|
||
|
|
if (!doc["immich_key"].isNull()) s.immich_key = doc["immich_key"].as<String>();
|
||
|
|
|
||
|
|
_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<String>();
|
||
|
|
s.wifi_pass = doc["wifi_pass"].as<String>();
|
||
|
|
s.immich_url = doc["immich_url"] | DEFAULT_IMMICH_URL;
|
||
|
|
s.immich_key = doc["immich_key"].as<String>();
|
||
|
|
_settings->save(s);
|
||
|
|
|
||
|
|
request->send(200, "application/json", "{\"ok\":true,\"msg\":\"Rebooting...\"}");
|
||
|
|
delay(1000);
|
||
|
|
ESP.restart();
|
||
|
|
}
|