feat: web server with REST API, captive portal, and management UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 10:59:26 -04:00
parent 83454d8e4f
commit 21e3a2f8c1
6 changed files with 670 additions and 0 deletions

204
data/app.js Normal file
View File

@@ -0,0 +1,204 @@
const API = '';
let currentPage = 'dashboard';
async function api(path, opts = {}) {
const res = await fetch(API + path, {
headers: { 'Content-Type': 'application/json' },
...opts
});
return res.json();
}
function toast(msg) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3000);
}
function navigate(page) {
currentPage = page;
document.querySelectorAll('#nav-links a').forEach(a => {
a.classList.toggle('active', a.dataset.page === page);
});
renderPage(page);
}
async function renderPage(page) {
const el = document.getElementById('content');
switch (page) {
case 'dashboard': return renderDashboard(el);
case 'albums': return renderAlbums(el);
case 'slideshow': return renderSlideshow(el);
case 'display': return renderDisplay(el);
case 'device': return renderDevice(el);
case 'firmware': return renderFirmware(el);
}
}
async function renderDashboard(el) {
const status = await api('/api/status');
el.innerHTML = `
<div class="card">
<h2>Status</h2>
<div class="stat"><span>Battery</span><span>${status.battery}%${status.charging ? ' ⚡' : ''}</span></div>
<div class="stat"><span>WiFi Signal</span><span>${status.wifi_rssi} dBm</span></div>
<div class="stat"><span>IP Address</span><span>${status.ip}</span></div>
<div class="stat"><span>Uptime</span><span>${Math.floor(status.uptime/60)}m</span></div>
<div class="stat"><span>Free RAM</span><span>${Math.floor(status.free_heap/1024)}KB</span></div>
<div class="stat"><span>Free PSRAM</span><span>${Math.floor(status.free_psram/1024)}KB</span></div>
</div>
<div class="card">
<h2>Controls</h2>
<div class="btn-group">
<button class="btn btn-primary" onclick="api('/api/action/next',{method:'POST'}).then(()=>toast('Next photo'))">Next</button>
<button class="btn btn-primary" onclick="api('/api/action/random',{method:'POST'}).then(()=>toast('Random photo'))">Random</button>
<button class="btn btn-primary" onclick="api('/api/action/pause',{method:'POST'}).then(()=>toast('Paused'))">Pause</button>
<button class="btn btn-primary" onclick="api('/api/action/play',{method:'POST'}).then(()=>toast('Playing'))">Play</button>
</div>
</div>`;
}
async function renderAlbums(el) {
const [albums, settings] = await Promise.all([api('/api/albums'), api('/api/settings')]);
const selected = JSON.parse(settings.albums_json || '[]');
el.innerHTML = `
<div class="card">
<h2>Albums</h2>
<ul class="checkbox-list">
${albums.map(a => `
<li>
<input type="checkbox" value="${a.id}" ${selected.includes(a.id) ? 'checked' : ''}>
<span>${a.title} (${a.assetCount})</span>
</li>`).join('')}
</ul>
<div class="btn-group">
<button class="btn btn-primary" id="saveAlbums">Save Selection</button>
</div>
</div>`;
document.getElementById('saveAlbums').onclick = async () => {
const ids = [...el.querySelectorAll('input[type=checkbox]:checked')].map(c => c.value);
await api('/api/albums/select', { method: 'POST', body: JSON.stringify({ album_ids: ids }) });
toast('Albums saved');
};
}
async function renderSlideshow(el) {
const settings = await api('/api/settings');
const intervals = [1, 5, 15, 30, 60];
const modes = ['Random', 'Chronological', 'Reverse Chrono', 'Favorites Weighted'];
el.innerHTML = `
<div class="card">
<h2>Interval</h2>
<div class="preset-btns">
${intervals.map(i => `<button class="${settings.interval_min===i?'active':''}" data-interval="${i}">${i}m</button>`).join('')}
</div>
</div>
<div class="card">
<h2>Cycling Mode</h2>
<div class="field">
<select id="cycleMode">
${modes.map((m, i) => `<option value="${i}" ${settings.cycle_mode===i?'selected':''}>${m}</option>`).join('')}
</select>
</div>
</div>
<div class="btn-group"><button class="btn btn-primary" id="saveSlideshow">Save</button></div>`;
el.querySelectorAll('[data-interval]').forEach(btn => {
btn.onclick = () => {
el.querySelectorAll('[data-interval]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
};
});
document.getElementById('saveSlideshow').onclick = async () => {
const interval = parseInt(el.querySelector('[data-interval].active')?.dataset.interval || '5');
const mode = parseInt(document.getElementById('cycleMode').value);
await api('/api/settings', { method: 'POST', body: JSON.stringify({ interval_min: interval, cycle_mode: mode }) });
toast('Slideshow settings saved');
};
}
async function renderDisplay(el) {
const settings = await api('/api/settings');
const flags = settings.meta_flags;
el.innerHTML = `
<div class="card">
<h2>Image Quality</h2>
<div class="field">
<select id="imgQuality">
<option value="0" ${settings.img_quality===0?'selected':''}>Preview (faster)</option>
<option value="1" ${settings.img_quality===1?'selected':''}>Original (higher quality)</option>
</select>
</div>
</div>
<div class="card">
<h2>Metadata Overlay</h2>
<ul class="checkbox-list">
<li><input type="checkbox" data-flag="1" ${flags&1?'checked':''}><span>Date</span></li>
<li><input type="checkbox" data-flag="2" ${flags&2?'checked':''}><span>Location</span></li>
<li><input type="checkbox" data-flag="4" ${flags&4?'checked':''}><span>People</span></li>
<li><input type="checkbox" data-flag="8" ${flags&8?'checked':''}><span>Album</span></li>
<li><input type="checkbox" data-flag="16" ${flags&16?'checked':''}><span>Camera</span></li>
</ul>
<div class="field">
<label>Position</label>
<select id="metaPos">
<option value="0" ${settings.meta_pos===0?'selected':''}>Bottom</option>
<option value="1" ${settings.meta_pos===1?'selected':''}>Top</option>
</select>
</div>
</div>
<div class="btn-group"><button class="btn btn-primary" id="saveDisplay">Save</button></div>`;
document.getElementById('saveDisplay').onclick = async () => {
let flags = 0;
el.querySelectorAll('[data-flag]').forEach(cb => { if (cb.checked) flags |= parseInt(cb.dataset.flag); });
await api('/api/settings', { method: 'POST', body: JSON.stringify({
img_quality: parseInt(document.getElementById('imgQuality').value),
meta_flags: flags,
meta_pos: parseInt(document.getElementById('metaPos').value)
})});
toast('Display settings saved');
};
}
async function renderDevice(el) {
el.innerHTML = `
<div class="card">
<h2>Device</h2>
<div class="btn-group">
<button class="btn btn-danger" onclick="if(confirm('Enter deep sleep?'))api('/api/action/sleep',{method:'POST'})">Deep Sleep</button>
<button class="btn btn-danger" onclick="if(confirm('Reboot?'))fetch('/api/action/reboot',{method:'POST'})">Reboot</button>
</div>
</div>`;
}
async function renderFirmware(el) {
el.innerHTML = `
<div class="card">
<h2>Firmware Update</h2>
<div class="field">
<label>Upload .bin file</label>
<input type="file" id="fwFile" accept=".bin">
</div>
<button class="btn btn-primary" id="uploadFw">Upload & Flash</button>
<div id="fwStatus" style="margin-top:12px"></div>
</div>`;
document.getElementById('uploadFw').onclick = async () => {
const file = document.getElementById('fwFile').files[0];
if (!file) return toast('Select a file first');
const formData = new FormData();
formData.append('firmware', file);
document.getElementById('fwStatus').textContent = 'Uploading...';
const res = await fetch('/api/firmware', { method: 'POST', body: formData });
const data = await res.json();
document.getElementById('fwStatus').textContent = data.msg || (data.ok ? 'Success!' : 'Failed');
};
}
// Navigation
document.querySelectorAll('#nav-links a').forEach(a => {
a.addEventListener('click', (e) => { e.preventDefault(); navigate(a.dataset.page); });
});
// Initial render
navigate('dashboard');

24
data/index.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PaperColor</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav>
<h1>PaperColor</h1>
<div id="nav-links">
<a href="#" data-page="dashboard" class="active">Dashboard</a>
<a href="#" data-page="albums">Albums</a>
<a href="#" data-page="slideshow">Slideshow</a>
<a href="#" data-page="display">Display</a>
<a href="#" data-page="device">Device</a>
<a href="#" data-page="firmware">Firmware</a>
</div>
</nav>
<main id="content"></main>
<script src="/app.js"></script>
</body>
</html>

89
data/setup.html Normal file
View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PaperColor Setup</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, sans-serif; max-width: 400px; margin: 40px auto; padding: 20px; background: #f5f5f5; }
h1 { margin-bottom: 20px; color: #333; }
.field { margin-bottom: 16px; }
label { display: block; font-weight: 600; margin-bottom: 4px; color: #555; }
input, select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 16px; }
button { width: 100%; padding: 12px; background: #2563eb; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; margin-top: 10px; }
button:hover { background: #1d4ed8; }
.status { margin-top: 16px; padding: 10px; border-radius: 6px; display: none; }
.status.error { display: block; background: #fee2e2; color: #991b1b; }
.status.success { display: block; background: #d1fae5; color: #065f46; }
#networks { margin-bottom: 16px; }
</style>
</head>
<body>
<h1>PaperColor Setup</h1>
<form id="setupForm">
<div class="field">
<label>WiFi Network</label>
<select id="wifiSsid"><option value="">Scanning...</option></select>
</div>
<div class="field">
<label>WiFi Password</label>
<input type="password" id="wifiPass" required>
</div>
<div class="field">
<label>Immich URL</label>
<input type="url" id="immichUrl" value="https://photos.example.com">
</div>
<div class="field">
<label>Immich API Key</label>
<input type="text" id="immichKey" required placeholder="Your API key">
</div>
<button type="submit">Save & Connect</button>
</form>
<div id="status" class="status"></div>
<script>
async function scanNetworks() {
try {
const res = await fetch('/api/wifi/scan');
const networks = await res.json();
const select = document.getElementById('wifiSsid');
select.innerHTML = networks.map(n =>
`<option value="${n.ssid}">${n.ssid} (${n.rssi}dBm)</option>`
).join('');
} catch (e) {
console.error('Scan failed:', e);
}
}
document.getElementById('setupForm').addEventListener('submit', async (e) => {
e.preventDefault();
const status = document.getElementById('status');
try {
const res = await fetch('/api/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
wifi_ssid: document.getElementById('wifiSsid').value,
wifi_pass: document.getElementById('wifiPass').value,
immich_url: document.getElementById('immichUrl').value,
immich_key: document.getElementById('immichKey').value
})
});
const data = await res.json();
if (data.ok) {
status.className = 'status success';
status.textContent = 'Saved! Device is rebooting...';
} else {
throw new Error(data.error || 'Unknown error');
}
} catch (e) {
status.className = 'status error';
status.textContent = 'Error: ' + e.message;
}
});
scanNetworks();
</script>
</body>
</html>

29
data/style.css Normal file
View File

@@ -0,0 +1,29 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #f8fafc; color: #1e293b; }
nav { background: #1e293b; color: white; padding: 16px 24px; display: flex; align-items: center; gap: 24px; flex-wrap: wrap; }
nav h1 { font-size: 18px; white-space: nowrap; }
#nav-links { display: flex; gap: 12px; flex-wrap: wrap; }
#nav-links a { color: #94a3b8; text-decoration: none; padding: 4px 8px; border-radius: 4px; font-size: 14px; }
#nav-links a.active { color: white; background: #334155; }
main { max-width: 800px; margin: 24px auto; padding: 0 16px; }
.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.card h2 { font-size: 16px; margin-bottom: 12px; color: #475569; }
.stat { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f1f5f9; }
.stat:last-child { border-bottom: none; }
.field { margin-bottom: 16px; }
.field label { display: block; font-weight: 600; margin-bottom: 4px; font-size: 14px; color: #64748b; }
.field input, .field select { width: 100%; padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; }
.btn { padding: 8px 16px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; }
.btn-primary { background: #2563eb; color: white; }
.btn-primary:hover { background: #1d4ed8; }
.btn-danger { background: #dc2626; color: white; }
.btn-danger:hover { background: #b91c1c; }
.btn-group { display: flex; gap: 8px; margin-top: 12px; }
.checkbox-list { list-style: none; }
.checkbox-list li { padding: 8px 0; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; gap: 8px; }
.checkbox-list input[type="checkbox"] { width: 18px; height: 18px; }
.preset-btns { display: flex; gap: 8px; flex-wrap: wrap; }
.preset-btns button { padding: 6px 12px; border: 1px solid #e2e8f0; border-radius: 4px; background: white; cursor: pointer; }
.preset-btns button.active { background: #2563eb; color: white; border-color: #2563eb; }
.toast { position: fixed; bottom: 20px; right: 20px; background: #065f46; color: white; padding: 12px 20px; border-radius: 6px; display: none; }
.toast.show { display: block; }

284
src/web_server.cpp Normal file
View File

@@ -0,0 +1,284 @@
#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();
}

40
src/web_server.h Normal file
View File

@@ -0,0 +1,40 @@
#pragma once
#include <Arduino.h>
#include <ESPAsyncWebServer.h>
#include <functional>
#include "settings.h"
class ImmichClient;
class PowerManager;
class WiFiManager;
class AppWebServer {
public:
void begin(SettingsManager& settings, ImmichClient& immich,
PowerManager& power, WiFiManager& wifi);
void setActionCallback(std::function<void(const String& action)> cb);
bool isOTAInProgress();
private:
AsyncWebServer _server{80};
SettingsManager* _settings = nullptr;
ImmichClient* _immich = nullptr;
PowerManager* _power = nullptr;
WiFiManager* _wifi = nullptr;
std::function<void(const String& action)> _actionCb;
bool _otaInProgress = false;
void setupCaptivePortal();
void setupAPIRoutes();
void setupStaticFiles();
void setupOTA();
void handleGetStatus(AsyncWebServerRequest* request);
void handleGetAlbums(AsyncWebServerRequest* request);
void handlePostAlbumsSelect(AsyncWebServerRequest* request, uint8_t* data, size_t len);
void handleGetSettings(AsyncWebServerRequest* request);
void handlePostSettings(AsyncWebServerRequest* request, uint8_t* data, size_t len);
void handlePostAction(AsyncWebServerRequest* request);
void handlePostSetup(AsyncWebServerRequest* request, uint8_t* data, size_t len);
};