feat: web server with REST API, captive portal, and management UI
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
204
data/app.js
Normal file
204
data/app.js
Normal 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
24
data/index.html
Normal 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
89
data/setup.html
Normal 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
29
data/style.css
Normal 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
284
src/web_server.cpp
Normal 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
40
src/web_server.h
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user