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