From 21e3a2f8c1acc263027b29fd30883342147b1107 Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 3 Aug 2026 10:59:26 -0400 Subject: [PATCH] feat: web server with REST API, captive portal, and management UI Co-authored-by: Cursor --- data/app.js | 204 ++++++++++++++++++++++++++++++++ data/index.html | 24 ++++ data/setup.html | 89 ++++++++++++++ data/style.css | 29 +++++ src/web_server.cpp | 284 +++++++++++++++++++++++++++++++++++++++++++++ src/web_server.h | 40 +++++++ 6 files changed, 670 insertions(+) create mode 100644 data/app.js create mode 100644 data/index.html create mode 100644 data/setup.html create mode 100644 data/style.css create mode 100644 src/web_server.cpp create mode 100644 src/web_server.h diff --git a/data/app.js b/data/app.js new file mode 100644 index 0000000..d045619 --- /dev/null +++ b/data/app.js @@ -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 = ` +
+

Status

+
Battery${status.battery}%${status.charging ? ' ⚡' : ''}
+
WiFi Signal${status.wifi_rssi} dBm
+
IP Address${status.ip}
+
Uptime${Math.floor(status.uptime/60)}m
+
Free RAM${Math.floor(status.free_heap/1024)}KB
+
Free PSRAM${Math.floor(status.free_psram/1024)}KB
+
+
+

Controls

+
+ + + + +
+
`; +} + +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 = ` +
+

Albums

+
    + ${albums.map(a => ` +
  • + + ${a.title} (${a.assetCount}) +
  • `).join('')} +
+
+ +
+
`; + 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 = ` +
+

Interval

+
+ ${intervals.map(i => ``).join('')} +
+
+
+

Cycling Mode

+
+ +
+
+
`; + 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 = ` +
+

Image Quality

+
+ +
+
+
+

Metadata Overlay

+
    +
  • Date
  • +
  • Location
  • +
  • People
  • +
  • Album
  • +
  • Camera
  • +
+
+ + +
+
+
`; + 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 = ` +
+

Device

+
+ + +
+
`; +} + +async function renderFirmware(el) { + el.innerHTML = ` +
+

Firmware Update

+
+ + +
+ +
+
`; + 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'); diff --git a/data/index.html b/data/index.html new file mode 100644 index 0000000..0e00c61 --- /dev/null +++ b/data/index.html @@ -0,0 +1,24 @@ + + + + + + PaperColor + + + + +
+ + + diff --git a/data/setup.html b/data/setup.html new file mode 100644 index 0000000..2473104 --- /dev/null +++ b/data/setup.html @@ -0,0 +1,89 @@ + + + + + + PaperColor Setup + + + +

PaperColor Setup

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + diff --git a/data/style.css b/data/style.css new file mode 100644 index 0000000..f784afb --- /dev/null +++ b/data/style.css @@ -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; } diff --git a/src/web_server.cpp b/src/web_server.cpp new file mode 100644 index 0000000..c24f51f --- /dev/null +++ b/src/web_server.cpp @@ -0,0 +1,284 @@ +#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"); + }); + + // Handle WiFi scan + _server.on("/api/wifi/scan", HTTP_GET, [](AsyncWebServerRequest* request) { + int n = WiFi.scanNetworks(); + 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); + 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(); + 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["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((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["led_brightness"].isNull()) s.led_brightness = doc["led_brightness"]; + 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(); +} diff --git a/src/web_server.h b/src/web_server.h new file mode 100644 index 0000000..bd479e6 --- /dev/null +++ b/src/web_server.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#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 cb); + bool isOTAInProgress(); + +private: + AsyncWebServer _server{80}; + SettingsManager* _settings = nullptr; + ImmichClient* _immich = nullptr; + PowerManager* _power = nullptr; + WiFiManager* _wifi = nullptr; + std::function _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); +};