Compare commits

...

33 Commits

Author SHA1 Message Date
eee6930983 chore: add docs/superpowers/ to .gitignore
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 13:20:17 -04:00
5a546d6968 feat: add deep sleep batch caching, wake log, and log viewer tools
- Batch-fetch asset IDs in deep sleep mode with NVS caching to reduce
  network calls and improve battery life (configurable batch size 1-50)
- Add wake_log module: persistent CSV log of each photo cycle surviving
  reboots and deep sleep, with download/clear via web UI
- Add browser-based log viewer and log comparison tools (tools/)
- Update .gitignore to exclude .cursor/, chat-summaries/, wake_*.csv,
  and .DS_Store

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 12:59:03 -04:00
be24b95422 fix: call configTime() in minimal WiFi path to initialize DNS resolver
Without configTime(), the ESP32 DNS subsystem isn't properly
initialized after WiFi connect, causing HTTPS hostname resolution
to fail silently in the deep sleep wake path. The fix calls
configTime() fire-and-forget (non-blocking, <1ms) which initializes
DNS without the 5-second NTP wait.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 01:39:17 -04:00
78f32a9cca feat: add Sync Time button to Device page in web UI
Adds a 'Sync Time' button in the Device Actions section that calls
POST /api/action/sync-time to trigger NTP sync and RTC write.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:57:53 -04:00
a442711482 feat: add POST /api/action/sync-time endpoint for manual NTP/RTC sync
Allows triggering NTP sync + hardware RTC write from the web UI,
useful for periodic manual time correction (e.g., every 30 days
or when charging).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:42:25 -04:00
dd0430928f fix: skip NTP sync and mDNS in deep sleep wake path to reduce battery drain
The syncNTP() call blocks up to 5 seconds waiting for NTP response on
every deep sleep wake cycle (every 10 minutes). Combined with mDNS
startup, this added significant active WiFi time per cycle.

Fix: Add 'minimal' flag to WiFiManager::begin()/startStation(). In
the deep sleep wake path, skip NTP sync and mDNS entirely. The system
clock is already seeded from the hardware RTC at boot, which is
accurate enough for 30-day date window calculations.

Normal interactive boot still does full NTP sync + mDNS as before.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:28:00 -04:00
569ae2a4d4 feat: deep sleep single-fetch with probabilistic weighting
Refactor timerWakeCycle() to fetch a single random photo per wake
using cycle-mode-aware server-side filtering via Immich's
POST /api/search/random endpoint.

Changes:
- Add fetchOneRandomAssetId() to ImmichClient with support for
  isFavorite, takenAfter, takenBefore filter parameters
- Add timer_last_date NVS field for chronological cursor persistence
- Implement probabilistic filter selection per cycle mode:
  - Random: unfiltered size=1
  - Chronological/ReverseChronological: date cursor in NVS
  - FavoritesWeighted: 66% favorites, 33% unfiltered
  - WeightedChronological: 66% recent (30d), 33% unfiltered
  - WeightedReverseChronological: 66% older (30d), 33% unfiltered
- Add retry loop with filter relaxation on empty results
- Initialize hardware RTC (RX8130CE) firmware-wide:
  - Seed system clock from RTC at boot (immediate time)
  - NTP sync after WiFi connect, write back to RTC
- Add time_utils.h with isoNow(), isoNowMinus30d(), hasValidTime()

Normal mode (PhotoQueue + displayTask) is completely untouched.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 20:57:59 -04:00
928a75ce19 feat: wire up album filtering to photo queue
Album selection now controls which photos are displayed.
Uses POST /api/search/random with albumIds filter (Immich v3
compatible) instead of the removed GET /api/albums/{id} assets.

- Add albumIds parameter to fetchRandomAssets/fetchRandomAssetIds
- syncFromAlbums: fetch random assets filtered by selected albums
- syncFromAllPhotos: preserve existing global random behavior
- Album mode reshuffles on exhaustion instead of re-fetching
- 24h resync checks album assetCounts for changes before re-fetch
- requestSync + albums_changed action triggers queue rebuild on save
- timerWakeCycle respects album selection
- Discard stale pre-renders on album change

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 22:46:17 -04:00
acab469061 feat: pre-render next photo for instant slideshow transitions
Refactor displayTask() into two phases: display and pre-render.
After showing a photo, the next one is immediately downloaded,
decoded, and dithered into a pending framebuffer (~240KB PSRAM).
When the slideshow timer fires, the EPD refresh starts instantly
without the previous 2-5s pipeline delay.

- Add PendingPhoto struct with settings hash for staleness guard
- Extract displayPhoto() and prepareNextPhoto() helpers
- Manual next/random discards pre-render and goes inline
- Falls back to inline pipeline if pre-render unavailable
- No changes to ImagePipeline, DisplayManager, or other modules

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 21:45:17 -04:00
1bec708f0c feat: add 2m, 10m presets and custom duration for slideshow interval
Presets are now [1, 2, 5, 10, 15, 30, 60] minutes. A 'Custom' button
reveals a number input clamped to 1–60 minutes. Backend also clamps
the received value with constrain(v, 1, 60) for safety.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 21:14:19 -04:00
a78e832d07 fix: improve icon vertical alignment and increase icon-text gap
Shift icons up 1px to better match the text midline, and increase
ICON_GAP from 4 to 6 for more breathing room between icon and text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 21:06:48 -04:00
86ff33562f feat: add procedural pin and calendar icons to metadata badges
Draw pin icon on the location badge and calendar icon on the
datetime badge when displaying a single photo (not side-by-side).
Icons are rendered using M5GFX shape primitives (fillCircle,
fillTriangle, drawRect, fillRect, drawLine) - no bitmap data needed.

Icons only appear when regionW == DISPLAY_WIDTH to avoid cluttering
the narrower badges used for portrait-pair layouts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 21:03:46 -04:00
bb270e0546 feat: metadata overlay tweaks — location badge, rounded corners, translucent option
- Location gets its own badge in the lower-left corner (separate from
  the main date/time/people/camera badge at the configured position)
- All overlay badges now use rounded corners (radius 4px) via
  fillRoundRect instead of fillRect
- New setting: 'Translucent background?' checkbox in web UI
  When enabled, badge backgrounds use a checkerboard pattern (every
  other pixel black) instead of solid black, creating a 50% opacity
  effect that lets the underlying image show through
- Refactored all badge drawing through a single drawOverlayBadge()
  helper (metadata, sleep indicator, battery indicator)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 20:50:01 -04:00
5b7c0042f9 feat: replace JPEGDEC with stb_image for robust JPEG decoding
JPEGDEC (error 3: UNSUPPORTED_FEATURE) and M5GFX's TJpgDec both failed
on ~70% of Immich preview images due to:
- Optimized Huffman tables exceeding 12-bit DC code limit
- Extended sequential mode (SOF1) misidentified as baseline
- Progressive JPEG not handled by either decoder

stb_image.h handles all standard JPEG modes (baseline, progressive,
extended sequential, arbitrary Huffman tables) in a single decoder.

Changes:
- Add vendored stb_image.h (JPEG-only, PSRAM allocator)
- Rewrite decodeAndFit() to use stbi_load_from_memory()
- Remove JPEGDEC library dependency
- Remove jpegDrawCallback, DecodeContext, parseJpegHeader,
  decodeFallbackLGFX (all obsolete)
- Flash usage decreased: 1345KB -> 1317KB

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 20:22:06 -04:00
8999746fe8 refactor: use EPD-exact bgr888 palette with bulk pushImage
Replace the PALETTE_565 + writePixel loop with a bgr888_t palette whose
values exactly match Panel_ED2208's internal epd_palette, pushed via a
single pushImage() call.

Benefits:
- Eliminates ~240K individual writePixel calls per frame
- Zero-distance palette values guarantee correct color mapping
- Removes lossy RGB565 intermediate encoding
- Remove unused paletteToColor565() helper

Also adds informational comment to triggerRefresh() noting that
Panel_ED2208::_exec_transfer always sends the full 400x600 frame.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 19:54:59 -04:00
1944ce4f93 fix: eliminate diagonal dither pattern from display driver
Root cause: Panel_ED2208's epd_quality mode applies _dither_row_rgb_pair
(diagonal bias pattern with dither=140) during _exec_transfer(). Since our
ImagePipeline already does proper Floyd-Steinberg dithering, the driver's
additional dithering was creating visible diagonal artifacts.

Fix: setEpdMode(epd_fastest) selects _dither_row_none (clean nearest-color
lookup, no spatial bias). On Panel_ED2208 this only affects the dither
function — NOT refresh quality or waveform.

Also: default dither_noise setting to OFF since error scatter was targeting
the wrong layer (it made gradient areas fuzzier without fixing the real
problem in the display driver).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 19:52:22 -04:00
0399573edf feat: blurred letterbox, portrait pairs, overlay metadata, button fixes
- Letterbox: extend edge pixels outward with blur + fade to solid color
- Portrait: detect and pair portraits side-by-side, fallback to solo
- Metadata: add overlay mode (corner badge), time field, custom date/time
  format strings (strftime), font size 1.5x, region-aware for pairs
- Buttons: swap G9/G10 to match physical layout, double-press random
  button for deep sleep, SLP indicator drawn before sleep
- Boot: remove redundant 'Loading photos' screen (saves 17s refresh)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:53:06 -04:00
1d4f94426d feat: fit-contain letterbox + smart queue refill
- Replace center-crop with fit-contain (no photo cropping)
- Letterbox bars filled with adjacent edge average color
- Queue fetches in batches until 50 usable assets
- Auto-refill queue on exhaustion, dedup via shown history
- Filter RAW files from asset selection

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:39:55 -04:00
3c3b238ab1 fix: suppress all e-ink refreshes during boot with setAutoDisplay(false)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:31:36 -04:00
b52367a30e fix: single boot refresh with QR code, async WiFi scan, skip light sleep in AP mode
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:25:41 -04:00
f9e6b6bea8 fix: LittleFS partition label mismatch and display rotation (180°)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:12:34 -04:00
26206e8635 fix: add reboot endpoint and test documentation
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:01:04 -04:00
de3ed9b85d feat: full integration with FreeRTOS tasks and slideshow state machine
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:00:02 -04:00
c79491d049 feat: web server with REST API, captive portal, and management UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:59:26 -04:00
9e6065f03e feat: display manager for e-ink framebuffer output and text messages
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:56:55 -04:00
451d6bdcea feat: image pipeline with JPEG decode, resize, and Floyd-Steinberg dithering
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:56:12 -04:00
460fe658bf feat: photo queue manager with cycling modes and favorites weighting
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:54:41 -04:00
5b95c5f275 feat: Immich API client with album, asset, and download support
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:53:27 -04:00
aa0bd952ba feat: button handler with press detection and combo support
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:51:29 -04:00
fa8a4abf01 feat: power manager with battery monitoring, LED warnings, and sleep modes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:50:32 -04:00
175d7a118a feat: WiFi manager with station/AP mode and mDNS
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:49:58 -04:00
4a69a43733 feat: settings manager with NVS persistence
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:49:13 -04:00
45e2aa0785 chore: add .gitignore for .pio and .superpowers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:47:43 -04:00
40 changed files with 14900 additions and 33 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.pio/
.superpowers/
docs/superpowers/
.cursor/
chat-summaries/
wake_*.csv
.DS_Store

333
data/app.js Normal file
View File

@@ -0,0 +1,333 @@
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 presets = [1, 2, 5, 10, 15, 30, 60];
const isCustom = !presets.includes(settings.interval_min);
const modes = ['Random', 'Chronological', 'Reverse Chrono', 'Favorites Weighted',
'Weighted Recent', 'Weighted Oldest'];
el.innerHTML = `
<div class="card">
<h2>Interval</h2>
<div class="preset-btns">
${presets.map(i => `<button class="${!isCustom && settings.interval_min===i?'active':''}" data-interval="${i}">${i}m</button>`).join('')}
<button class="${isCustom?'active':''}" data-interval="custom">Custom</button>
</div>
<div class="field" id="customIntervalRow" style="margin-top:8px;display:${isCustom?'flex':'none'}">
<label>Minutes (1–60):</label>
<input type="number" id="customInterval" min="1" max="60" value="${isCustom?settings.interval_min:5}" style="width:60px;margin-left:8px">
</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="card">
<h2>Deep Sleep Batch Size</h2>
<p style="opacity:0.7;font-size:13px;margin-bottom:8px">Photos to pre-fetch per API call in deep sleep mode. Higher = fewer network calls, better battery life.</p>
<div class="field">
<input type="number" id="timerBatch" min="1" max="50" value="${settings.timer_batch_size || 10}" style="width:80px">
</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('customIntervalRow').style.display =
btn.dataset.interval === 'custom' ? 'flex' : 'none';
};
});
document.getElementById('saveSlideshow').onclick = async () => {
const activeBtn = el.querySelector('[data-interval].active');
let interval;
if (activeBtn && activeBtn.dataset.interval === 'custom') {
interval = Math.min(60, Math.max(1, parseInt(document.getElementById('customInterval').value) || 5));
} else {
interval = parseInt(activeBtn?.dataset.interval || '5');
}
const mode = parseInt(document.getElementById('cycleMode').value);
const batch = Math.min(50, Math.max(1, parseInt(document.getElementById('timerBatch').value) || 10));
await api('/api/settings', { method: 'POST', body: JSON.stringify({ interval_min: interval, cycle_mode: mode, timer_batch_size: batch }) });
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>Image Processing</h2>
<div class="field">
<select id="pipelineMode">
<option value="0" ${settings.pipeline_mode===0?'selected':''}>Dynamic (vivid colors)</option>
<option value="1" ${settings.pipeline_mode===1?'selected':''}>Balanced (preserve tones)</option>
<option value="2" ${settings.pipeline_mode===2?'selected':''}>None (no processing)</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="32" ${flags&32?'checked':''}><span>Time</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>Date Format</label>
<input type="text" id="dateFmt" value="${settings.date_fmt || '%Y-%m-%d'}" placeholder="%Y-%m-%d">
<small>strftime: %Y=year %m=month %d=day %b=Mon %B=Month</small>
</div>
<div class="field">
<label>Time Format</label>
<input type="text" id="timeFmt" value="${settings.time_fmt || '%H:%M:%S'}" placeholder="%H:%M:%S">
<small>strftime: %H=24h %I=12h %M=min %S=sec %p=AM/PM</small>
</div>
<div class="field">
<label>Style &amp; Position</label>
<select id="metaPos">
<option value="0" ${settings.meta_pos===0?'selected':''}>Caption - Bottom</option>
<option value="1" ${settings.meta_pos===1?'selected':''}>Caption - Top</option>
<option value="2" ${settings.meta_pos===2?'selected':''}>Overlay - Top Left</option>
<option value="3" ${settings.meta_pos===3?'selected':''}>Overlay - Top Right</option>
<option value="4" ${settings.meta_pos===4?'selected':''}>Overlay - Bottom Left</option>
<option value="5" ${settings.meta_pos===5?'selected':''}>Overlay - Bottom Right</option>
</select>
</div>
<ul class="checkbox-list">
<li><input type="checkbox" id="metaTranslucent" ${settings.meta_translucent?'checked':''}><span>Translucent background?</span></li>
</ul>
</div>
<div class="card">
<h2>Battery Indicator</h2>
<ul class="checkbox-list">
<li><input type="checkbox" id="showBattery" ${settings.show_battery?'checked':''}><span>Show battery level on display</span></li>
</ul>
</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),
pipeline_mode: parseInt(document.getElementById('pipelineMode').value),
meta_flags: flags,
meta_pos: parseInt(document.getElementById('metaPos').value),
meta_translucent: document.getElementById('metaTranslucent').checked ? 1 : 0,
date_fmt: document.getElementById('dateFmt').value,
time_fmt: document.getElementById('timeFmt').value,
show_battery: document.getElementById('showBattery').checked ? 1 : 0
})});
toast('Display settings saved');
};
}
async function renderDevice(el) {
const settings = await api('/api/settings');
el.innerHTML = `
<div class="card">
<h2>Immich Connection</h2>
<div class="field">
<label>Immich URL</label>
<input type="url" id="immichUrl" value="${settings.immich_url || ''}" placeholder="https://photos.example.com">
</div>
<div class="field">
<label>API Key</label>
<input type="password" id="immichKey" value="${settings.immich_key || ''}" placeholder="Your Immich API key">
</div>
<div class="btn-group">
<button class="btn btn-primary" id="saveImmich">Save</button>
<button class="btn" id="testConn">Test Connection</button>
</div>
<div id="connStatus" style="margin-top:12px"></div>
</div>
<div class="card">
<h2>Device Actions</h2>
<div class="btn-group">
<button class="btn" id="syncTime">Sync Time</button>
<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 device?'))fetch('/api/action/reboot',{method:'POST'})">Reboot</button>
</div>
</div>
<div class="card">
<h2>Wake Log</h2>
<p style="opacity:0.7;margin-bottom:8px">Persistent CSV log of each photo cycle (survives reboots/sleep).</p>
<div class="btn-group">
<button class="btn btn-primary" id="downloadLog">Download Log</button>
<button class="btn btn-danger" id="clearLog">Clear Log</button>
</div>
</div>`;
document.getElementById('saveImmich').onclick = async () => {
const url = document.getElementById('immichUrl').value.trim();
const key = document.getElementById('immichKey').value.trim();
if (!url || !key) return toast('URL and API key are required');
await api('/api/settings', { method: 'POST', body: JSON.stringify({ immich_url: url, immich_key: key }) });
toast('Immich settings saved. Reboot to apply.');
};
document.getElementById('testConn').onclick = async () => {
const status = document.getElementById('connStatus');
status.textContent = 'Testing...';
status.style.color = '';
try {
const result = await api('/api/test-connection', { method: 'POST' });
status.textContent = result.msg;
status.style.color = result.ok ? '#2e7d32' : '#c62828';
} catch (e) {
status.textContent = 'Request failed: ' + e.message;
status.style.color = '#c62828';
}
};
document.getElementById('syncTime').onclick = async () => {
toast('Syncing...');
const result = await api('/api/action/sync-time', { method: 'POST' });
toast(result.msg || 'Time synced');
};
document.getElementById('downloadLog').onclick = () => {
const a = document.createElement('a');
a.href = '/api/log';
a.download = 'wake_log.csv';
a.click();
};
document.getElementById('clearLog').onclick = async () => {
if (!confirm('Clear the wake log?')) return;
const result = await api('/api/log/clear', { method: 'POST' });
toast(result.msg || 'Log cleared');
};
}
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>

100
data/setup.html Normal file
View File

@@ -0,0 +1,100 @@
<!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 data = await res.json();
if (data.scanning) {
// Scan in progress — retry after 2s
setTimeout(scanNetworks, 2000);
return;
}
const select = document.getElementById('wifiSsid');
if (data.length === 0) {
select.innerHTML = '<option value="">No networks found</option>';
setTimeout(scanNetworks, 3000);
return;
}
select.innerHTML = data.map(n =>
`<option value="${n.ssid}">${n.ssid} (${n.rssi}dBm)</option>`
).join('');
} catch (e) {
console.error('Scan failed:', e);
setTimeout(scanNetworks, 3000);
}
}
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; }

View File

@@ -3,5 +3,5 @@ nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x680000,
app1, app, ota_1, 0x690000, 0x680000,
littlefs, data, spiffs, 0xD10000, 0x80000,
spiffs, data, spiffs, 0xD10000, 0x80000,
coredump, data, coredump, 0xD90000, 0x10000,
1 # Name Type SubType Offset Size Flags
3 otadata data ota 0xe000 0x2000
4 app0 app ota_0 0x10000 0x680000
5 app1 app ota_1 0x690000 0x680000
6 littlefs spiffs data spiffs 0xD10000 0x80000
7 coredump data coredump 0xD90000 0x10000

View File

@@ -16,12 +16,14 @@ build_flags =
-DCORE_DEBUG_LEVEL=3
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
-DARDUINO_LOOP_STACK_SIZE=32768
lib_deps =
m5stack/M5Unified @ ^0.2.2
m5stack/M5GFX @ ^0.2.5
https://github.com/m5stack/M5PM1.git
mathieucarbou/ESP Async WebServer @ ^3.0.6
bblanchon/ArduinoJson @ ^7.0.0
ricmoo/QRCode @ ^0.0.1
lib_ignore =
WebServer

83
src/blue_noise.h Normal file
View File

@@ -0,0 +1,83 @@
#pragma once
#ifdef ARDUINO
#include <Arduino.h>
#else
#ifndef PROGMEM
#define PROGMEM
#endif
#ifndef pgm_read_byte
#define pgm_read_byte(addr) (*(const uint8_t*)(addr))
#endif
#include <cstdint>
#endif
// 64x64 blue noise texture generated via void-and-cluster algorithm.
// Used for dither threshold modulation to break up Floyd-Steinberg patterns.
// Values 0-255, spatially uniform frequency distribution.
static const uint8_t PROGMEM BLUE_NOISE_64[64 * 64] = {
0x8A, 0xF1, 0x42, 0xE3, 0x6C, 0x4E, 0xBA, 0xF1, 0x47, 0xCA, 0xB7, 0x32, 0x8C, 0x69, 0x09, 0xB0, 0x89, 0x0E, 0xF5, 0x21, 0x30, 0xC7, 0x6E, 0x10, 0xEA, 0xBC, 0x08, 0x9E, 0xF0, 0x29, 0x6B, 0xC6, 0x51, 0x16, 0xB5, 0x5B, 0x44, 0xA7, 0x68, 0x8D, 0x4E, 0xBF, 0x95, 0x10, 0x18, 0xEA, 0x8A, 0xD6, 0x27, 0x38, 0x93, 0xC9, 0x14, 0x8D, 0xF0, 0xB7, 0xD1, 0x53, 0x22, 0xDD, 0xC8, 0xA2, 0x61, 0xB4,
0x0A, 0x9E, 0x26, 0x84, 0x9B, 0xD0, 0x16, 0x88, 0x9A, 0x0B, 0x59, 0xD9, 0x20, 0xEE, 0x4E, 0xCC, 0x73, 0xD6, 0x54, 0xBE, 0x9B, 0xED, 0x41, 0xA2, 0x56, 0x7E, 0xDD, 0x34, 0x61, 0x13, 0xD7, 0x7F, 0x05, 0xED, 0x94, 0x0D, 0x7D, 0xCB, 0x3B, 0xE8, 0xB0, 0x20, 0xE5, 0x85, 0xCC, 0x01, 0x3D, 0x5D, 0xAD, 0xE2, 0x4F, 0xF6, 0xA9, 0x2F, 0x4C, 0x65, 0x17, 0x85, 0xB1, 0x32, 0x5A, 0x1D, 0x7D, 0xDF,
0x6D, 0xD1, 0x5E, 0xB8, 0x0C, 0xFD, 0x64, 0x34, 0xE5, 0x6D, 0xAA, 0x3C, 0x7D, 0xB8, 0x98, 0x38, 0x13, 0xA9, 0x3C, 0x80, 0x60, 0x17, 0x78, 0xD1, 0x2E, 0xAE, 0x4B, 0x73, 0xC2, 0x96, 0x39, 0xA0, 0xB9, 0x62, 0xD3, 0x33, 0xF1, 0x99, 0x0A, 0x73, 0x2D, 0x62, 0x46, 0x6C, 0xB1, 0x56, 0xA1, 0xFB, 0x6E, 0x0B, 0x7D, 0x18, 0x69, 0xDD, 0x81, 0xA4, 0x0E, 0xEC, 0x6C, 0x99, 0xF5, 0xB9, 0x2C, 0x48,
0xC5, 0x35, 0xED, 0x4B, 0x19, 0x7F, 0x44, 0xB5, 0xD3, 0x27, 0xF8, 0x92, 0x62, 0x15, 0xDE, 0x5E, 0xFA, 0x8F, 0xE7, 0x0D, 0xDB, 0xAB, 0x04, 0xFA, 0x93, 0x1A, 0xEC, 0x8C, 0x01, 0xFE, 0x56, 0xE3, 0x27, 0x41, 0x72, 0xB1, 0x54, 0x18, 0xBB, 0xD9, 0x9E, 0xEF, 0xC3, 0x33, 0xF4, 0x7C, 0x2B, 0xC2, 0x15, 0xD2, 0x9A, 0xBA, 0x43, 0xC5, 0x24, 0xE6, 0xBC, 0x47, 0xD5, 0x14, 0x41, 0x8B, 0xE7, 0xA8,
0x17, 0x8D, 0x77, 0xAA, 0xE0, 0xC4, 0x98, 0x12, 0x79, 0x50, 0x06, 0xC0, 0xE9, 0x48, 0x01, 0xC1, 0x6D, 0x23, 0x4D, 0xBA, 0x36, 0x87, 0xC5, 0x44, 0x61, 0xBD, 0x27, 0xCF, 0x42, 0xB0, 0x77, 0x1C, 0x8A, 0xF4, 0x9E, 0x14, 0xDF, 0x6B, 0x8A, 0x44, 0x12, 0x7E, 0x06, 0x9A, 0x1F, 0xDB, 0x48, 0x94, 0x83, 0x3A, 0x57, 0xEB, 0x04, 0x90, 0x5C, 0x38, 0x71, 0x8F, 0x01, 0x61, 0xC9, 0x6E, 0x0C, 0x5B,
0xF6, 0x02, 0xD9, 0x2C, 0x69, 0x07, 0xE9, 0x60, 0xA4, 0xC7, 0x85, 0x30, 0x9F, 0x77, 0xAA, 0x88, 0x2E, 0xCD, 0x9C, 0x70, 0xF5, 0x57, 0x28, 0x73, 0xDF, 0x83, 0x56, 0xA3, 0x6A, 0x2C, 0xDB, 0xC3, 0x4D, 0xCC, 0x08, 0x81, 0xC0, 0x34, 0xFC, 0x5C, 0xCB, 0xAF, 0x4E, 0xD4, 0x60, 0xB8, 0x08, 0xED, 0x61, 0xE3, 0xA5, 0x2E, 0x71, 0xFE, 0x9E, 0xD0, 0x20, 0xF7, 0xB3, 0x9D, 0xEF, 0x37, 0xBB, 0x98,
0x4A, 0xB5, 0x59, 0x9B, 0x3F, 0x86, 0x4D, 0x28, 0xF4, 0x39, 0xDF, 0x5C, 0x1D, 0xD4, 0x3A, 0xF2, 0x58, 0xE5, 0x09, 0xAF, 0x1E, 0x94, 0xE9, 0xA7, 0x0D, 0x39, 0xF7, 0x1D, 0xE3, 0x91, 0x0D, 0xA0, 0x6E, 0x2F, 0x5C, 0xEB, 0x49, 0xA4, 0x1F, 0x92, 0x2A, 0xF4, 0x73, 0x8E, 0x38, 0xA2, 0x75, 0xC8, 0x17, 0xB3, 0x1F, 0x8A, 0xCC, 0x47, 0x0F, 0xAE, 0x57, 0x7F, 0x2E, 0x4D, 0x7C, 0x22, 0xDE, 0x75,
0xD6, 0x83, 0x21, 0xC7, 0xF7, 0xA6, 0xD2, 0xBA, 0x8E, 0x0E, 0x72, 0xB0, 0xFE, 0x53, 0xB9, 0x0F, 0x98, 0x79, 0x38, 0x60, 0xD1, 0x48, 0xBE, 0x17, 0xD3, 0x9A, 0xB3, 0x79, 0x4A, 0xBE, 0x60, 0x3C, 0xF9, 0xB7, 0x94, 0xD5, 0x1A, 0x71, 0xDA, 0xBF, 0x68, 0x3E, 0x0D, 0xE3, 0x22, 0xFD, 0x52, 0x10, 0x45, 0x7B, 0xD7, 0x51, 0xAC, 0x15, 0x85, 0xE5, 0x3C, 0xDA, 0xBD, 0x1A, 0xCF, 0xA4, 0x43, 0x0F,
0xA9, 0x38, 0xEB, 0x6D, 0x10, 0x30, 0x76, 0x1E, 0x63, 0xD5, 0x49, 0x96, 0x27, 0x86, 0x6F, 0x18, 0xCA, 0x47, 0xBF, 0xF0, 0x7F, 0x30, 0x6C, 0x8B, 0x51, 0x68, 0x04, 0xC8, 0x34, 0xED, 0x87, 0xAE, 0x02, 0x79, 0x25, 0x3C, 0xAB, 0x87, 0x4F, 0x00, 0xAA, 0xDD, 0x99, 0xC4, 0x67, 0x84, 0xAC, 0xDF, 0x98, 0xF8, 0x68, 0x36, 0xEA, 0x61, 0xBE, 0x73, 0x07, 0x91, 0x66, 0xEC, 0x5B, 0x8C, 0xFC, 0x65,
0x13, 0xBE, 0x91, 0x47, 0xB1, 0x5A, 0xE3, 0x9F, 0x3E, 0xB6, 0xEB, 0x08, 0xC4, 0x3D, 0xEE, 0xA8, 0xDF, 0x8A, 0x14, 0xA5, 0x03, 0x9C, 0xFD, 0x0F, 0xE1, 0x41, 0xF2, 0x93, 0x5C, 0x12, 0x28, 0xE1, 0x4E, 0xCF, 0xED, 0x69, 0xBB, 0xE4, 0x37, 0xF0, 0x7B, 0x58, 0x29, 0x4C, 0xB4, 0x1A, 0x2F, 0x64, 0xC1, 0x26, 0x00, 0xB8, 0x90, 0x2A, 0xF2, 0x4F, 0xC9, 0x25, 0xA3, 0x3E, 0x05, 0xB6, 0x29, 0x7F,
0xD0, 0x55, 0x08, 0xDD, 0x80, 0xCA, 0x00, 0xFB, 0x87, 0x2A, 0x7E, 0x6B, 0xE1, 0x9F, 0x60, 0x06, 0x54, 0x2F, 0x6E, 0xE3, 0x42, 0xD7, 0x58, 0xAB, 0xC0, 0x81, 0x2A, 0xD7, 0xAB, 0x76, 0xC6, 0x64, 0x9A, 0x1C, 0x8B, 0x52, 0x0A, 0x28, 0x65, 0xA0, 0x11, 0xD1, 0x8B, 0xF1, 0x3B, 0xE7, 0xD1, 0x90, 0x3F, 0x82, 0xA1, 0xD4, 0x6E, 0x0B, 0x9B, 0x32, 0xAB, 0xFA, 0x81, 0xE0, 0x73, 0xCB, 0x4F, 0xE8,
0x9D, 0x76, 0xF9, 0x27, 0x97, 0x39, 0x6F, 0x51, 0xC2, 0x1A, 0xAA, 0x35, 0x4F, 0x1D, 0xD6, 0x7D, 0xB6, 0xFA, 0x93, 0x5B, 0xB5, 0x26, 0x75, 0x37, 0x1F, 0xA0, 0x6D, 0x10, 0x3F, 0xFC, 0x92, 0x32, 0xB2, 0x43, 0xC5, 0xA2, 0xF8, 0xCC, 0x91, 0xC2, 0x42, 0xB0, 0x17, 0x69, 0x9A, 0x7B, 0x56, 0x09, 0xF4, 0x5A, 0xE2, 0x38, 0x4E, 0xCC, 0x80, 0xDD, 0x60, 0x12, 0x4D, 0x31, 0xA7, 0x1E, 0x8F, 0x36,
0x1B, 0xB5, 0x41, 0x66, 0xD7, 0xBC, 0x22, 0x9C, 0xD8, 0x61, 0xF3, 0xCB, 0x8B, 0xBA, 0x2C, 0x97, 0x40, 0x15, 0xCF, 0x0B, 0x83, 0xC6, 0x96, 0xF1, 0xCC, 0x56, 0xEB, 0x8B, 0xBB, 0x51, 0x0A, 0xD9, 0xF2, 0x6B, 0x29, 0x75, 0x39, 0x7E, 0x55, 0x19, 0xFC, 0x71, 0xDD, 0x08, 0xBF, 0x23, 0xA6, 0xC9, 0x14, 0xB1, 0x77, 0x1B, 0xFC, 0xA5, 0x1F, 0x3F, 0x78, 0xC5, 0x97, 0xD0, 0x5E, 0xF5, 0xBB, 0x6B,
0x2A, 0xE9, 0x88, 0xA9, 0x19, 0x4D, 0xF0, 0x7C, 0x31, 0x46, 0x94, 0x05, 0x5C, 0xF9, 0x71, 0xE3, 0xC2, 0x65, 0xA8, 0x3B, 0xF5, 0x4E, 0x0E, 0x67, 0x43, 0x01, 0xA8, 0x34, 0xD4, 0x7F, 0x17, 0x5B, 0x84, 0x04, 0xD0, 0xE3, 0x1B, 0xA8, 0xE1, 0x04, 0x87, 0x51, 0xA2, 0x38, 0xF6, 0x4B, 0xE0, 0x6B, 0x42, 0x96, 0x2B, 0xC1, 0x8D, 0x59, 0xBB, 0xF3, 0xAF, 0x02, 0xE8, 0x26, 0x83, 0x08, 0x48, 0xD6,
0x5C, 0xC6, 0x03, 0x57, 0xE2, 0x8C, 0xAF, 0x09, 0xB9, 0xE3, 0x75, 0x17, 0xAF, 0x44, 0x21, 0x57, 0x01, 0x8B, 0xDE, 0x72, 0x24, 0xB0, 0xE5, 0x80, 0xDD, 0xB8, 0x6A, 0x21, 0x5F, 0xE8, 0xAF, 0xCB, 0xA2, 0x37, 0x93, 0x56, 0xB6, 0x47, 0x68, 0xD1, 0xB9, 0x12, 0xCB, 0x61, 0x8F, 0x75, 0x30, 0x8B, 0xB7, 0xEB, 0x66, 0xDC, 0x3C, 0x0E, 0x6D, 0x2D, 0x50, 0x8B, 0x6B, 0x43, 0xB2, 0xDC, 0x7B, 0xA3,
0x92, 0x3B, 0x77, 0xCC, 0x2F, 0x6A, 0x3E, 0xD0, 0x64, 0x12, 0xA4, 0xCE, 0xE9, 0x83, 0x9C, 0xB2, 0xF5, 0x31, 0x50, 0x9D, 0xCB, 0x62, 0x2B, 0xA2, 0x1D, 0x8E, 0xF7, 0xC8, 0x9D, 0x42, 0x2D, 0x71, 0x4B, 0xBC, 0xF5, 0x23, 0x8A, 0xF0, 0x2C, 0x9B, 0x40, 0x77, 0xED, 0x18, 0xAE, 0xD2, 0x10, 0xF3, 0x21, 0x53, 0x03, 0x9F, 0x7A, 0xEE, 0xD0, 0x9A, 0xE0, 0x1F, 0xBF, 0xFE, 0x9C, 0x39, 0x11, 0xF2,
0x23, 0xB3, 0xEC, 0x9E, 0x1E, 0xFD, 0x9A, 0x26, 0x87, 0xF4, 0x51, 0x39, 0x67, 0x1B, 0xDA, 0x3D, 0x7C, 0xC5, 0x16, 0xEF, 0x0C, 0x8F, 0x3D, 0xD7, 0x58, 0x31, 0x4C, 0x78, 0x07, 0x88, 0xFA, 0x0D, 0xE5, 0x13, 0x61, 0x74, 0xCA, 0x0D, 0x7D, 0xEA, 0x15, 0x93, 0x4F, 0xBD, 0x02, 0x47, 0xA1, 0x5D, 0xC7, 0x84, 0xD3, 0x4A, 0xB7, 0x31, 0x86, 0x09, 0x64, 0xA4, 0x2E, 0x57, 0x15, 0x73, 0xCA, 0x4E,
0x85, 0x0D, 0x64, 0x46, 0xB7, 0x78, 0x53, 0xDB, 0xA8, 0x2E, 0x7E, 0x99, 0xBA, 0x2C, 0xC8, 0x5C, 0x0F, 0xAA, 0x67, 0x82, 0x49, 0xC0, 0xFE, 0x6C, 0xB4, 0xCE, 0x14, 0xAD, 0xDE, 0xC0, 0x53, 0x9B, 0x7E, 0xB1, 0xD7, 0x42, 0xA3, 0x35, 0xBE, 0x5B, 0xB0, 0xE0, 0x2C, 0x6F, 0xFA, 0x86, 0xDC, 0x79, 0x3A, 0xA6, 0x28, 0xE7, 0x1D, 0x5B, 0xAD, 0x49, 0xC4, 0xE9, 0x76, 0xD2, 0x8F, 0xE4, 0x63, 0xBD,
0xF7, 0xD9, 0x33, 0x90, 0xE7, 0x16, 0xC5, 0x07, 0x46, 0xBF, 0xDD, 0x02, 0xFA, 0x4B, 0x77, 0xEF, 0x8D, 0xE6, 0x3A, 0xDA, 0xA4, 0x74, 0x17, 0x08, 0x7E, 0x95, 0xF3, 0x5D, 0x3A, 0x26, 0x69, 0xD5, 0x3D, 0x2C, 0x91, 0x00, 0xFD, 0x6A, 0xDB, 0x46, 0x06, 0x82, 0xCF, 0xA5, 0x59, 0x35, 0x22, 0xB8, 0x0B, 0xF9, 0x62, 0x95, 0x75, 0xCA, 0xFB, 0x23, 0x80, 0x3D, 0x0F, 0xB6, 0x4A, 0x01, 0xA7, 0x29,
0x59, 0xA2, 0x72, 0xD2, 0x0E, 0x86, 0x62, 0xF6, 0x7B, 0x5E, 0x16, 0x6B, 0x87, 0xAC, 0x0C, 0xA2, 0x24, 0x52, 0xCC, 0x04, 0x2D, 0xEB, 0xAE, 0x53, 0xE8, 0x41, 0x0E, 0x72, 0x8E, 0xEC, 0xA6, 0x1C, 0xBE, 0x5D, 0xE7, 0xAC, 0x52, 0x8D, 0x25, 0x9D, 0xF7, 0x65, 0x3F, 0x1E, 0xC4, 0x94, 0xE9, 0x4F, 0x6E, 0xCE, 0x43, 0xBE, 0x07, 0x8E, 0x36, 0xDE, 0x97, 0x5D, 0xF3, 0x9E, 0x32, 0xEE, 0x7C, 0x3F,
0x09, 0xC7, 0x25, 0x52, 0xA9, 0x40, 0xB5, 0x92, 0x12, 0xEB, 0xB1, 0xD3, 0x34, 0xE3, 0x62, 0xC2, 0x35, 0x70, 0xB3, 0x98, 0x60, 0x40, 0x91, 0xD4, 0x2D, 0xC0, 0xA0, 0xDB, 0xB2, 0x03, 0x7B, 0x46, 0xF8, 0x83, 0x24, 0x75, 0xC7, 0x19, 0xB9, 0x78, 0xC8, 0x10, 0x90, 0xEC, 0x76, 0x0A, 0xD2, 0xAB, 0x8C, 0x1E, 0x9E, 0x32, 0xF1, 0x50, 0x69, 0xA7, 0x05, 0xD4, 0x15, 0x83, 0x66, 0xBA, 0xD1, 0x97,
0xE9, 0x81, 0xB4, 0xFA, 0x6E, 0xDE, 0x29, 0xCC, 0x3A, 0x9F, 0x26, 0x55, 0x96, 0x1D, 0x43, 0xD6, 0x92, 0xFC, 0x13, 0x80, 0xE0, 0xBE, 0x14, 0x68, 0x87, 0x5B, 0x22, 0x4E, 0x32, 0xCD, 0x5A, 0xDD, 0x99, 0x0D, 0xCF, 0x47, 0x30, 0xEE, 0x5E, 0x3D, 0x2B, 0xAB, 0xDA, 0x4C, 0xB6, 0x65, 0x3E, 0x2C, 0xF1, 0x5A, 0xDF, 0x70, 0xAE, 0xD0, 0x1C, 0xBE, 0x7A, 0x4C, 0xAC, 0xCB, 0x25, 0x51, 0x1B, 0x6C,
0x2E, 0x4D, 0x1B, 0x37, 0x93, 0x00, 0x55, 0x70, 0xE5, 0x83, 0x45, 0xC6, 0x79, 0xF3, 0xB6, 0x7E, 0x07, 0x5B, 0xC9, 0x4A, 0x24, 0x71, 0xF9, 0x00, 0xAA, 0xE6, 0xC6, 0x6E, 0xF7, 0x89, 0x27, 0xAE, 0x35, 0x68, 0xB2, 0xE4, 0x85, 0xA4, 0xD3, 0x97, 0xF2, 0x55, 0x7F, 0x31, 0x21, 0xFE, 0xA1, 0x7C, 0x00, 0xC3, 0x84, 0x10, 0x29, 0x87, 0x3F, 0xEA, 0x2D, 0xF8, 0x6D, 0x39, 0xE0, 0x92, 0xFC, 0xAD,
0xDB, 0x9C, 0xE6, 0x5D, 0xC3, 0xF2, 0xA6, 0xBD, 0x0B, 0x62, 0xFD, 0xAA, 0x04, 0x65, 0x2C, 0x4F, 0xE7, 0xA3, 0x2F, 0xF0, 0xAF, 0x9B, 0x50, 0xC9, 0x33, 0x76, 0x0C, 0x92, 0x43, 0xB8, 0x1A, 0x72, 0xF2, 0x52, 0x90, 0x09, 0x63, 0x14, 0x4D, 0x02, 0x70, 0x16, 0xC3, 0x99, 0xDE, 0x85, 0x58, 0xCD, 0x4C, 0xAC, 0x38, 0xEE, 0x54, 0xDC, 0x9F, 0x59, 0x8D, 0x1E, 0x9B, 0xB8, 0x08, 0x7A, 0x3E, 0x5F,
0x04, 0x87, 0xB9, 0x79, 0x21, 0x81, 0x3D, 0x17, 0x8E, 0xD8, 0x1F, 0x35, 0xD5, 0xA1, 0xDF, 0x8F, 0x15, 0xC0, 0x67, 0x88, 0x3B, 0x1C, 0xD8, 0x8F, 0x45, 0xEF, 0xA2, 0x19, 0xE1, 0x60, 0xC8, 0x95, 0xD6, 0x16, 0xC1, 0x3B, 0xF5, 0xC4, 0x80, 0xE4, 0xB1, 0xD8, 0x62, 0x43, 0x06, 0xB2, 0x13, 0xE7, 0x26, 0x8F, 0x61, 0xCA, 0x74, 0xB9, 0x03, 0x6A, 0xC9, 0xDC, 0x45, 0x5F, 0xEF, 0xA7, 0x23, 0xC2,
0x6A, 0x40, 0x13, 0xD3, 0x49, 0xE8, 0x9D, 0xCD, 0x4D, 0xB1, 0x76, 0x58, 0x89, 0x3F, 0x1F, 0xB3, 0x74, 0x45, 0x11, 0xDD, 0xC3, 0x5E, 0x79, 0x24, 0xBA, 0x59, 0xD6, 0x12, 0xA8, 0x7E, 0x31, 0x4C, 0x03, 0x7F, 0xE0, 0x9D, 0x72, 0xAB, 0x2A, 0x40, 0x8D, 0x0F, 0xA3, 0xF6, 0x70, 0xD1, 0x37, 0x6D, 0xBE, 0xF9, 0x0C, 0x99, 0x41, 0x16, 0xFD, 0x36, 0xB0, 0x0D, 0x80, 0x2A, 0xCE, 0x4D, 0x8C, 0xD6,
0xB2, 0xED, 0x58, 0xA7, 0x2B, 0x64, 0x11, 0x6C, 0xF3, 0x2D, 0x9E, 0xE7, 0xC5, 0x6D, 0xFA, 0x54, 0xCA, 0xEE, 0x98, 0x77, 0x06, 0xE8, 0x9F, 0xF6, 0x0A, 0x88, 0x6D, 0x49, 0xFE, 0x23, 0xD2, 0xEE, 0xB0, 0x63, 0x2B, 0x4A, 0x1D, 0x56, 0xCA, 0xF7, 0x5D, 0x34, 0xC7, 0x25, 0x84, 0x52, 0xA8, 0x95, 0x43, 0x7D, 0x19, 0xAF, 0xE4, 0x88, 0xA7, 0x79, 0x4C, 0xE8, 0xBE, 0x98, 0x70, 0x14, 0xEA, 0x2F,
0x97, 0x0B, 0x75, 0xFE, 0x92, 0xC0, 0xDF, 0x86, 0x03, 0xC1, 0x47, 0x15, 0x09, 0xAC, 0x30, 0x83, 0x02, 0x18, 0xAB, 0x55, 0xB5, 0x48, 0x31, 0x64, 0xA9, 0x2B, 0xCD, 0x96, 0xBD, 0x5A, 0x70, 0x9B, 0x3D, 0x8C, 0xFB, 0xB7, 0xD7, 0x8C, 0x09, 0xA1, 0x7A, 0xE1, 0x4C, 0x95, 0xE4, 0x0C, 0xF4, 0x1E, 0xDB, 0x56, 0xCD, 0x6B, 0x30, 0x4E, 0xD0, 0x15, 0x91, 0x62, 0x32, 0xFA, 0x01, 0xBA, 0x57, 0x7C,
0x25, 0xDE, 0xC4, 0x35, 0x0E, 0x52, 0xAE, 0x37, 0x5C, 0xDA, 0x92, 0x7F, 0xF1, 0x60, 0xD4, 0x96, 0xE2, 0x64, 0x3A, 0xFC, 0x16, 0xCE, 0x91, 0xDE, 0xC2, 0x41, 0xED, 0x03, 0x38, 0x8B, 0x0D, 0xC1, 0x20, 0xCA, 0x0A, 0x7A, 0x32, 0xE7, 0x6D, 0x18, 0xBE, 0x12, 0xB0, 0x68, 0x3A, 0xBC, 0x5F, 0x30, 0xB2, 0x89, 0x05, 0xF6, 0xBB, 0x5F, 0x08, 0xF3, 0xC3, 0x21, 0xAB, 0x50, 0x88, 0xDB, 0xA5, 0x42,
0xB7, 0x4D, 0x64, 0x89, 0xD8, 0x78, 0x22, 0xF8, 0xA2, 0x6E, 0x28, 0xB9, 0x4E, 0xA4, 0x23, 0x47, 0xB4, 0x7D, 0xD7, 0x8A, 0x6A, 0x0C, 0x7B, 0x50, 0x1A, 0x82, 0x55, 0x74, 0xA9, 0xD8, 0xEB, 0x53, 0x6B, 0xE5, 0x4F, 0x9E, 0x60, 0xAF, 0x46, 0xD4, 0x54, 0x89, 0xF2, 0x1D, 0xA3, 0x80, 0xCF, 0x75, 0xEE, 0x3D, 0x97, 0x14, 0x80, 0xDD, 0x9C, 0x71, 0x40, 0xE2, 0x6A, 0xCB, 0x39, 0x1F, 0x65, 0xF8,
0x84, 0x9D, 0x1E, 0xAC, 0x41, 0xE8, 0x95, 0x46, 0xC9, 0x1B, 0xEC, 0x3A, 0xCD, 0x19, 0x76, 0xF4, 0x33, 0x1B, 0xC3, 0x29, 0xA6, 0xE8, 0xBA, 0x27, 0xFA, 0x9F, 0xB5, 0xE6, 0x21, 0x48, 0x2D, 0x81, 0xA5, 0x34, 0xB5, 0x25, 0xEE, 0x1B, 0x93, 0xFD, 0x01, 0x71, 0xC8, 0x2D, 0xDC, 0x46, 0x03, 0x9B, 0x20, 0x68, 0xD7, 0xA7, 0x2E, 0x45, 0xB6, 0x28, 0x86, 0xA1, 0x0B, 0x7D, 0xED, 0x97, 0xC8, 0x07,
0xD2, 0x2F, 0xF4, 0xC6, 0x04, 0x5F, 0xB8, 0x0C, 0x84, 0x58, 0xAB, 0x7C, 0x65, 0xE4, 0x91, 0xC1, 0x5F, 0x9B, 0x4C, 0xEF, 0x58, 0x3D, 0x9D, 0x66, 0xD0, 0x33, 0x10, 0x65, 0xCE, 0x9C, 0xB8, 0xF4, 0x01, 0xD2, 0x6F, 0x8E, 0xCE, 0x3D, 0x7F, 0xB4, 0x32, 0xA9, 0x44, 0x63, 0x8F, 0xF9, 0x55, 0xC7, 0xE2, 0x4B, 0xBD, 0x5B, 0xF1, 0x6D, 0x1C, 0xEB, 0x51, 0xD6, 0x16, 0xB6, 0x48, 0x29, 0x77, 0x3E,
0xE7, 0x6D, 0x50, 0x75, 0x8D, 0x2A, 0xD5, 0x69, 0xF0, 0x35, 0xDF, 0x01, 0x9F, 0x2A, 0x54, 0x0B, 0xD5, 0x72, 0xAE, 0x03, 0x79, 0xDA, 0x1B, 0x8D, 0x47, 0x7A, 0xC0, 0x3D, 0x88, 0x56, 0x13, 0x63, 0x95, 0x42, 0xF9, 0x12, 0x51, 0xBF, 0x20, 0x5E, 0xE1, 0x95, 0xED, 0x0B, 0xB9, 0x24, 0xAF, 0x6F, 0x31, 0x91, 0x15, 0x0A, 0x87, 0xCF, 0x94, 0xC1, 0x66, 0x33, 0xF9, 0x5A, 0xA6, 0xE3, 0xBC, 0x5D,
0x17, 0xB1, 0x11, 0xD1, 0xA3, 0xFB, 0x3A, 0x9D, 0xB5, 0x22, 0x8D, 0xBD, 0x44, 0xFE, 0xB0, 0x86, 0x26, 0xF7, 0x3B, 0xC8, 0x8A, 0x2F, 0xBE, 0xEF, 0x08, 0xE1, 0x98, 0xF6, 0x06, 0xE9, 0x7B, 0xDA, 0x17, 0xC2, 0x5C, 0x7C, 0xA6, 0xEA, 0x70, 0xD2, 0x48, 0x11, 0x7B, 0xCF, 0x6C, 0x3E, 0x84, 0x1A, 0xA4, 0xFE, 0x76, 0xDB, 0xAA, 0x34, 0x4A, 0x02, 0xAD, 0x8D, 0xC6, 0x6F, 0x05, 0x85, 0x12, 0x95,
0xC9, 0x82, 0xEA, 0x43, 0x1F, 0x56, 0x7B, 0x0F, 0x50, 0xD7, 0x70, 0x5C, 0xCE, 0x78, 0x3A, 0xDE, 0x4A, 0x93, 0x13, 0xE2, 0x5E, 0xA8, 0x51, 0x70, 0xAF, 0x5A, 0x22, 0x6B, 0xAD, 0x34, 0xBF, 0x47, 0xA5, 0x87, 0x09, 0xE3, 0x36, 0x0E, 0x9B, 0x2C, 0x89, 0xC2, 0x17, 0x52, 0x9E, 0xDB, 0xBD, 0xEA, 0x60, 0x3C, 0xC1, 0x50, 0x23, 0x65, 0xF4, 0xDD, 0x7A, 0x11, 0x41, 0x98, 0xDA, 0x36, 0xFD, 0x4E,
0x3E, 0x00, 0x99, 0x67, 0xC1, 0xAA, 0xE5, 0xC9, 0x94, 0x3C, 0xF5, 0x0F, 0x18, 0x9A, 0x06, 0xC4, 0x62, 0xB8, 0x6F, 0x9F, 0x0D, 0xFD, 0x1F, 0xCD, 0x2C, 0x87, 0xDA, 0x49, 0xCB, 0x92, 0x24, 0x6A, 0xF1, 0x39, 0xD2, 0xB4, 0x8F, 0x66, 0xF1, 0xBB, 0x58, 0xF5, 0xA7, 0xE2, 0x31, 0x08, 0x54, 0x2A, 0xD3, 0x00, 0x89, 0xEB, 0xB4, 0x7D, 0x98, 0x15, 0x58, 0xD0, 0xF2, 0x24, 0x53, 0xAF, 0x69, 0xA3,
0x77, 0xD9, 0x28, 0xF6, 0x84, 0x07, 0x33, 0x6C, 0x1F, 0xA9, 0x7F, 0xE2, 0xAE, 0x56, 0xEC, 0x7F, 0x1D, 0xE8, 0x2A, 0x4E, 0xC1, 0x82, 0x42, 0x96, 0xE7, 0x3E, 0xA4, 0x19, 0x81, 0x58, 0xE4, 0xB2, 0x1E, 0x5A, 0x73, 0x29, 0x4F, 0xD8, 0x40, 0x04, 0x73, 0x25, 0x40, 0x8C, 0x77, 0xF8, 0x96, 0x74, 0xB2, 0x9C, 0x68, 0x32, 0x0C, 0xCC, 0x40, 0xB9, 0x30, 0xA1, 0x65, 0xB9, 0x7F, 0xCD, 0x20, 0xE3,
0xBB, 0x5D, 0xAD, 0x38, 0x50, 0xDC, 0x8F, 0xF4, 0x5B, 0xBF, 0x2B, 0x4B, 0x6A, 0xD6, 0x2F, 0x97, 0x3D, 0xA5, 0xD5, 0x78, 0x35, 0xDC, 0x68, 0xB9, 0x01, 0x73, 0xC6, 0xFB, 0x2D, 0xD5, 0x07, 0x7E, 0x9A, 0xCA, 0xFE, 0xA3, 0x12, 0xC4, 0x7E, 0xB2, 0x98, 0xD8, 0x67, 0xB7, 0x11, 0x5C, 0xCE, 0x45, 0x17, 0xF6, 0x4A, 0xDC, 0xA3, 0x59, 0xFC, 0x70, 0xE5, 0x88, 0x0D, 0x3B, 0xE9, 0x0A, 0x91, 0x2E,
0x43, 0x1C, 0x8A, 0xCD, 0x79, 0x24, 0xB4, 0x44, 0xD3, 0x03, 0x9C, 0xC6, 0x8E, 0x23, 0xB6, 0x6C, 0xFB, 0x56, 0x89, 0x05, 0xF1, 0xA6, 0x1C, 0x50, 0xF0, 0x13, 0x54, 0x98, 0x69, 0xA8, 0x4C, 0xEC, 0x30, 0x46, 0x02, 0x83, 0xE1, 0x63, 0x26, 0xF9, 0x3A, 0xC6, 0x1D, 0xE9, 0xA3, 0xC0, 0x35, 0xE4, 0x82, 0x10, 0xBC, 0x76, 0x13, 0x86, 0x19, 0x06, 0xAC, 0x45, 0xD6, 0x75, 0x9B, 0x4B, 0x65, 0xF7,
0xD2, 0xA0, 0xF2, 0x0A, 0xBC, 0x67, 0xA0, 0x14, 0x88, 0x74, 0xF9, 0x3E, 0x0C, 0xF0, 0x45, 0xCB, 0x11, 0x18, 0xC5, 0xB3, 0x5E, 0x27, 0x8F, 0xCD, 0xAA, 0x88, 0xB6, 0x20, 0x3C, 0xC0, 0x11, 0x6F, 0xC3, 0x91, 0xB5, 0x53, 0x37, 0xAB, 0x94, 0x54, 0x0C, 0x82, 0x5A, 0x44, 0x29, 0x85, 0x04, 0xA0, 0x64, 0xD1, 0x92, 0x37, 0xED, 0xC0, 0xD4, 0x93, 0x55, 0xC4, 0x20, 0xFA, 0x2A, 0xC7, 0xAD, 0x84,
0x54, 0x72, 0x34, 0x5E, 0xE0, 0x3B, 0xF0, 0x55, 0xE1, 0x33, 0x5E, 0xD8, 0x82, 0xA4, 0x5B, 0x8B, 0xAC, 0xE0, 0x6F, 0x3F, 0x98, 0xD7, 0x7A, 0x36, 0x6B, 0x43, 0xD6, 0xE6, 0x78, 0xF2, 0x89, 0xDC, 0x5D, 0x1B, 0xE6, 0x72, 0xF4, 0x1A, 0xD9, 0x70, 0xEA, 0xAE, 0xDC, 0x94, 0xFB, 0x6B, 0xC7, 0x49, 0xF2, 0x2B, 0x55, 0xAD, 0x6A, 0x41, 0x61, 0x2D, 0xF0, 0x80, 0x67, 0xA9, 0x59, 0xE1, 0x16, 0x05,
0xEC, 0x24, 0xB2, 0x93, 0x1E, 0x83, 0xC3, 0x09, 0x97, 0xB6, 0x1A, 0xAD, 0x6F, 0x20, 0xE1, 0x00, 0x78, 0x4F, 0x2B, 0xF8, 0x10, 0x4A, 0xE6, 0x06, 0xFA, 0x28, 0x63, 0x09, 0x95, 0x2E, 0x52, 0x22, 0xAE, 0x3F, 0xD0, 0x29, 0x8E, 0xBA, 0x46, 0x21, 0x9D, 0x2F, 0x67, 0x0A, 0xB5, 0x51, 0xE0, 0x1F, 0x7B, 0xB6, 0x0C, 0xE0, 0x24, 0x99, 0xB5, 0xDB, 0x19, 0x39, 0xBC, 0x07, 0x8F, 0x3C, 0x7C, 0xBA,
0x8B, 0x41, 0xCB, 0xEA, 0x4B, 0xAC, 0x2F, 0x71, 0xCC, 0x48, 0xEB, 0x2B, 0xCA, 0x42, 0xB3, 0x31, 0xED, 0x9C, 0xCF, 0x80, 0xA9, 0xC4, 0x5B, 0xB4, 0x9D, 0x83, 0xC9, 0xAC, 0x47, 0xB8, 0xCF, 0xA0, 0xFB, 0x83, 0x66, 0xA5, 0x07, 0x5F, 0xCE, 0x85, 0xC1, 0x4A, 0xD1, 0x23, 0x7E, 0x32, 0x96, 0xAA, 0x3C, 0xCC, 0x8C, 0x73, 0xF3, 0x04, 0x7A, 0x4D, 0xA1, 0x8B, 0xE4, 0x4C, 0xF0, 0xA2, 0x69, 0xD9,
0x0F, 0xA5, 0x77, 0x03, 0x6B, 0xDD, 0x59, 0xFC, 0x20, 0x8E, 0x67, 0x9D, 0x54, 0xFB, 0x8F, 0x5E, 0xC1, 0x44, 0x0A, 0x63, 0x15, 0x8E, 0x2C, 0x76, 0x1A, 0xDE, 0x54, 0x1E, 0xF7, 0x66, 0x00, 0x74, 0x36, 0x0D, 0xC5, 0x4A, 0xED, 0x79, 0x39, 0xFC, 0x01, 0x77, 0xEC, 0xA1, 0xC9, 0xF4, 0x13, 0x66, 0xDC, 0x5A, 0x33, 0xA3, 0x48, 0xC5, 0x28, 0xFA, 0x63, 0xD0, 0x26, 0x6E, 0xC3, 0x1E, 0x30, 0x4F,
0xC6, 0x60, 0xF3, 0x2A, 0xBC, 0x90, 0x1A, 0xA5, 0x79, 0x35, 0xD6, 0x08, 0x7F, 0x12, 0xDA, 0x73, 0x1C, 0x88, 0xB1, 0xDC, 0xEF, 0x3B, 0xD3, 0xF3, 0x47, 0xBA, 0x35, 0x78, 0x8C, 0x29, 0xE6, 0x93, 0x59, 0xE9, 0x96, 0x2F, 0xD3, 0xAD, 0x25, 0x9A, 0x5C, 0xB0, 0x31, 0x54, 0x6C, 0x42, 0x8D, 0xC0, 0x01, 0xFE, 0x20, 0xBC, 0x5F, 0xD8, 0x88, 0xAA, 0x3F, 0x0B, 0xB4, 0x37, 0x84, 0xD5, 0xAD, 0xFE,
0x22, 0x39, 0x89, 0xA1, 0x45, 0xCF, 0x33, 0xE7, 0xBB, 0x57, 0xF5, 0xA9, 0xC2, 0x39, 0xA1, 0x27, 0xF1, 0xCB, 0x33, 0x51, 0x74, 0x9E, 0x5F, 0x22, 0x99, 0x6C, 0xE9, 0xA7, 0xD9, 0xC7, 0x3E, 0xAE, 0xD6, 0x20, 0xB6, 0x6C, 0x1C, 0x86, 0x4D, 0xDA, 0x12, 0xE4, 0x90, 0x1C, 0xB8, 0x0B, 0xE6, 0x4B, 0x72, 0x9B, 0x82, 0xEA, 0x19, 0x38, 0x6E, 0x13, 0xEA, 0x7C, 0x9D, 0xF6, 0x59, 0x01, 0x66, 0x94,
0xB5, 0xDB, 0x52, 0xE5, 0x0B, 0x61, 0x84, 0x49, 0x02, 0x96, 0x14, 0x46, 0x6D, 0xD4, 0x5A, 0xB7, 0x48, 0x67, 0xA5, 0x20, 0xC2, 0x09, 0xB2, 0x7F, 0xCE, 0x05, 0x17, 0x5A, 0x0C, 0x4C, 0x6E, 0x0F, 0x7E, 0x50, 0x89, 0xF7, 0x5B, 0xE2, 0xBF, 0x6F, 0xB7, 0x41, 0x7C, 0xF0, 0x9C, 0xD3, 0x80, 0xAF, 0x2B, 0xD2, 0x3C, 0x69, 0xB3, 0x93, 0xE2, 0xBB, 0x57, 0xC8, 0x46, 0x1D, 0x90, 0xE9, 0x43, 0x7C,
0x0C, 0x70, 0x1E, 0xBF, 0x78, 0xF5, 0xB3, 0xDB, 0x68, 0xC5, 0x85, 0xE2, 0x2B, 0x91, 0x0F, 0xE4, 0x81, 0x06, 0xFE, 0x90, 0xE2, 0x42, 0xEC, 0x31, 0x51, 0xFC, 0x90, 0xC1, 0x7C, 0x9F, 0xBB, 0xEE, 0x2B, 0xC3, 0x3B, 0x10, 0x9F, 0x37, 0x05, 0x92, 0x2C, 0x62, 0xCE, 0x28, 0x5A, 0x39, 0x1F, 0x61, 0xEE, 0x12, 0xA8, 0x50, 0xD5, 0x2B, 0x4B, 0x09, 0x90, 0x27, 0xDF, 0x75, 0xBF, 0xA5, 0x2C, 0xD2,
0x99, 0xF9, 0xA8, 0x37, 0x93, 0x28, 0x1A, 0x9E, 0x2F, 0xEE, 0x24, 0x5B, 0xB3, 0xF0, 0x74, 0x3D, 0x9C, 0xD3, 0x59, 0x7A, 0x2A, 0x69, 0x8A, 0xC5, 0xA3, 0x74, 0x45, 0xE2, 0x24, 0xF8, 0x36, 0x5F, 0x94, 0xE4, 0xA6, 0xCC, 0x7B, 0xEC, 0x49, 0xD1, 0xF9, 0xA5, 0x08, 0xBC, 0x76, 0xFB, 0x96, 0xC3, 0x41, 0x8B, 0xC7, 0x05, 0x7D, 0xF1, 0xA7, 0x74, 0xFC, 0x64, 0xAF, 0x32, 0x52, 0x1A, 0xE3, 0x58,
0x3F, 0x27, 0x63, 0xD8, 0x55, 0xCA, 0x43, 0x81, 0x51, 0xAC, 0x77, 0xCE, 0x09, 0x4A, 0xA8, 0x26, 0xC3, 0x1D, 0x36, 0xBD, 0xAA, 0xD9, 0x57, 0x19, 0x10, 0xD8, 0x2E, 0xAE, 0x63, 0x8A, 0x1B, 0xDA, 0x74, 0x03, 0x4B, 0x65, 0x27, 0xAE, 0x69, 0x82, 0x1D, 0x4F, 0x8A, 0xDE, 0x48, 0xB1, 0x04, 0xDF, 0x78, 0x56, 0xF8, 0x18, 0x9D, 0x60, 0x20, 0xCF, 0x3E, 0xA0, 0x05, 0xCB, 0xF6, 0x67, 0x85, 0xC6,
0x78, 0xCF, 0x87, 0x02, 0xEC, 0xA4, 0x69, 0xFD, 0xD5, 0x11, 0x3C, 0x9F, 0x87, 0xD9, 0x64, 0xF8, 0x8A, 0x6C, 0xE7, 0x4E, 0x01, 0x15, 0x9C, 0xF3, 0xB7, 0x66, 0x97, 0x02, 0xD2, 0x40, 0xB8, 0x4F, 0xAC, 0x16, 0xFF, 0x8D, 0xDD, 0x0E, 0xC5, 0x33, 0xB4, 0xE8, 0x69, 0x1A, 0x32, 0x8C, 0x6A, 0x2D, 0xA2, 0x10, 0xB3, 0x6D, 0xE5, 0x32, 0xB8, 0x85, 0x14, 0xE6, 0x4D, 0x7B, 0x96, 0x3A, 0xB7, 0x07,
0x52, 0xE7, 0xB9, 0x4A, 0x74, 0x21, 0xBD, 0x0B, 0x8F, 0xBA, 0x6A, 0xF4, 0x21, 0x36, 0xBD, 0x05, 0x46, 0xD7, 0xA1, 0x81, 0xEF, 0xCA, 0x73, 0x47, 0x84, 0x38, 0xF6, 0x55, 0x7A, 0xEB, 0x9B, 0x29, 0xCF, 0x7C, 0xBA, 0x33, 0x55, 0x99, 0xF5, 0x5E, 0x94, 0x25, 0xC7, 0xA2, 0xEC, 0xD3, 0x53, 0xBB, 0xEB, 0x3B, 0xD1, 0x93, 0x44, 0xCA, 0x53, 0xEF, 0x6A, 0x8E, 0xBD, 0x10, 0xDC, 0x22, 0xED, 0xAA,
0x99, 0x0F, 0x17, 0xAB, 0x90, 0x33, 0xE0, 0x3F, 0x5F, 0x29, 0xDD, 0x4D, 0xC4, 0x7D, 0x99, 0x58, 0xB3, 0x2E, 0x0E, 0x62, 0x3D, 0x90, 0x2E, 0xE1, 0x09, 0xCE, 0xB3, 0x25, 0xC8, 0x0E, 0x6F, 0x58, 0xF1, 0x42, 0x9E, 0xD4, 0x1F, 0x76, 0x40, 0x06, 0xD2, 0x7C, 0x3A, 0x5A, 0x79, 0x21, 0x99, 0x0B, 0x66, 0x86, 0x26, 0x5D, 0x1C, 0x82, 0x00, 0xAA, 0x37, 0xD9, 0x29, 0x5D, 0xA3, 0x49, 0x71, 0x30,
0xF8, 0x80, 0x40, 0xC8, 0xF6, 0x57, 0x82, 0xA9, 0xCD, 0x7B, 0x9A, 0x00, 0x65, 0xE5, 0x18, 0xEE, 0x77, 0x94, 0xF7, 0xBB, 0xA7, 0x1E, 0xBF, 0x5A, 0xA5, 0x71, 0x49, 0x86, 0xA1, 0x3A, 0xC0, 0x90, 0x0A, 0x6A, 0x13, 0x61, 0xEE, 0xBF, 0xA4, 0xE1, 0x4A, 0xB0, 0xFD, 0x00, 0xC0, 0x42, 0xF7, 0xC7, 0x47, 0xDB, 0xA4, 0xFB, 0xB6, 0xDA, 0x96, 0x62, 0x11, 0xB1, 0x76, 0xFA, 0xC9, 0x83, 0xD7, 0x63,
0xC1, 0x55, 0xE4, 0x68, 0x1C, 0x9F, 0x05, 0xEF, 0x4B, 0x1B, 0xF8, 0xB1, 0x3B, 0xA7, 0x0D, 0xCD, 0x3F, 0xC6, 0x22, 0x50, 0xE0, 0x6B, 0xFC, 0x8E, 0x23, 0xEF, 0x1A, 0xDB, 0x5E, 0xFC, 0x22, 0xD7, 0xA7, 0xE3, 0x8C, 0xB1, 0x37, 0x84, 0x2A, 0x68, 0x8D, 0x14, 0x6C, 0xD6, 0xA8, 0x85, 0x2F, 0x6F, 0xAF, 0x19, 0x79, 0x4B, 0x2E, 0x6E, 0x40, 0xF3, 0xCB, 0x4B, 0x95, 0x3A, 0x15, 0x03, 0xB0, 0x1F,
0x2D, 0xA6, 0x8C, 0x26, 0xD9, 0xC2, 0x72, 0x25, 0xB7, 0x8D, 0x31, 0x71, 0xD0, 0x52, 0x85, 0x68, 0x13, 0x5F, 0x89, 0x33, 0x7D, 0x06, 0x48, 0x35, 0xC3, 0x63, 0x95, 0x30, 0xB0, 0x76, 0x4C, 0x80, 0x2D, 0x3E, 0xC3, 0x04, 0x52, 0xE6, 0x0E, 0xC9, 0xEF, 0x2F, 0x9A, 0x4C, 0x13, 0x5E, 0xE0, 0x91, 0x14, 0xF0, 0x06, 0xCD, 0x9C, 0xE4, 0x23, 0xBA, 0x86, 0x18, 0xE8, 0x57, 0xBF, 0x8E, 0x46, 0xDE,
0x72, 0x06, 0xBC, 0x3B, 0x7D, 0x4E, 0x34, 0xDF, 0x66, 0xD4, 0x58, 0xEA, 0x26, 0x95, 0xFD, 0xB2, 0xDA, 0x9F, 0xE8, 0xCD, 0xB4, 0x9C, 0xD4, 0x7A, 0xAC, 0xD0, 0x45, 0xEB, 0x11, 0xCC, 0x05, 0xB7, 0xF2, 0x59, 0x7A, 0xFB, 0x9A, 0x6F, 0xB3, 0x57, 0x40, 0xBC, 0xDC, 0x81, 0xF3, 0xB4, 0x08, 0xCB, 0x52, 0x6A, 0xBD, 0x8C, 0x5F, 0x0C, 0xA5, 0x53, 0x70, 0x0A, 0xD1, 0xA8, 0x74, 0xF3, 0x60, 0x97,
0x40, 0xD3, 0xFE, 0x5F, 0xAF, 0xF2, 0x89, 0x9D, 0x41, 0xA6, 0x07, 0x81, 0xBB, 0x1B, 0x44, 0x2D, 0x7F, 0x03, 0x48, 0x16, 0x5D, 0x11, 0xE6, 0x56, 0x18, 0x02, 0x73, 0xA0, 0x59, 0x8B, 0xE2, 0x67, 0x99, 0x19, 0xCA, 0x25, 0x44, 0xD8, 0x1D, 0xA0, 0x88, 0x08, 0x62, 0x21, 0x35, 0x97, 0x45, 0x7B, 0xE5, 0xA6, 0x27, 0x3F, 0xF5, 0x7D, 0xD5, 0x2A, 0xFD, 0xA0, 0x64, 0x30, 0x21, 0xCC, 0x0D, 0xE9,
0x81, 0x54, 0x12, 0x99, 0x18, 0x07, 0xCD, 0x11, 0xE5, 0x16, 0xC7, 0x49, 0xA1, 0xDC, 0x75, 0xC4, 0x57, 0xF2, 0xA8, 0x74, 0xF8, 0x8C, 0x32, 0xA1, 0xF6, 0x88, 0xD8, 0x25, 0xC2, 0x36, 0xA9, 0x45, 0x28, 0xDD, 0xA9, 0x5F, 0xBD, 0x82, 0x34, 0xE2, 0x73, 0xF9, 0xAD, 0xCF, 0x72, 0xEA, 0xBC, 0x23, 0x39, 0x95, 0xD1, 0x55, 0x1C, 0xAD, 0x38, 0x92, 0xC4, 0x43, 0xDF, 0x8B, 0x50, 0xAD, 0x34, 0xA2,
0x23, 0xB3, 0x8A, 0xE4, 0x6D, 0xBB, 0x53, 0x79, 0x61, 0x86, 0xF4, 0x6C, 0x36, 0x5D, 0xED, 0x0E, 0x93, 0x36, 0xBE, 0x28, 0xC7, 0x42, 0x6E, 0xBB, 0x0F, 0x66, 0xB6, 0x4C, 0xF9, 0x7D, 0x1C, 0xEF, 0x89, 0x71, 0x3C, 0x8E, 0x0A, 0xF5, 0x64, 0xC6, 0x25, 0x4F, 0x3B, 0x9E, 0x53, 0x19, 0x60, 0x8A, 0xFD, 0x02, 0x66, 0xE1, 0xBF, 0x6E, 0xEA, 0x5D, 0x14, 0x78, 0x02, 0xB9, 0xF8, 0x7E, 0x5C, 0xC4,
0x6B, 0xCC, 0x31, 0x45, 0xD8, 0xA4, 0x3C, 0xFB, 0xC1, 0x31, 0xB0, 0x24, 0xD0, 0x91, 0x18, 0xAD, 0xD5, 0x66, 0x84, 0x53, 0x96, 0xDF, 0x23, 0xCE, 0x49, 0xE5, 0x34, 0x94, 0x07, 0x65, 0xD4, 0x53, 0xB2, 0x00, 0xEC, 0xD5, 0x2B, 0xAB, 0x46, 0x0E, 0xB5, 0x8E, 0xE6, 0x05, 0x85, 0xC5, 0xDA, 0xAA, 0x4A, 0xB7, 0x7A, 0x30, 0x9A, 0x49, 0x0B, 0x87, 0xB2, 0xEF, 0x97, 0x18, 0x3E, 0xD7, 0x0F, 0xEE,
0x49, 0x00, 0xF7, 0x61, 0x1E, 0x81, 0x27, 0x92, 0x1B, 0x4B, 0x97, 0xDE, 0x02, 0xB9, 0x81, 0x48, 0x15, 0xE6, 0x0A, 0xEE, 0x16, 0xAB, 0x7E, 0x5C, 0x9B, 0x1B, 0xAC, 0x72, 0xDF, 0xBC, 0x98, 0x2E, 0xC9, 0x63, 0x9E, 0x4F, 0x70, 0x92, 0xE8, 0x7E, 0xD9, 0x67, 0x2E, 0xB1, 0xF7, 0x22, 0x34, 0x72, 0x0F, 0xD4, 0x16, 0x89, 0xF9, 0x24, 0xC8, 0xE1, 0x4E, 0x35, 0x62, 0xCE, 0x6F, 0x9F, 0x2C, 0x94,
0xD9, 0xA9, 0x7A, 0x95, 0xC8, 0xEB, 0x5C, 0xD4, 0xAB, 0xE6, 0x5E, 0x77, 0x3E, 0x55, 0xFB, 0x6F, 0xA0, 0xC5, 0x41, 0xB4, 0x68, 0x00, 0xFD, 0x38, 0xD5, 0x83, 0xF5, 0x21, 0x56, 0x3C, 0x0E, 0x82, 0xFB, 0x22, 0x37, 0xC4, 0x19, 0xB8, 0x59, 0x37, 0xA2, 0x1D, 0xC9, 0x57, 0x7A, 0x44, 0x9A, 0xF1, 0x54, 0xA0, 0xE9, 0x44, 0xB4, 0x5B, 0xA0, 0x71, 0x27, 0xC2, 0xA6, 0x11, 0xE8, 0x51, 0xBE, 0x77,
0x20, 0x57, 0xC0, 0x10, 0x36, 0xA7, 0x04, 0x71, 0x2D, 0x82, 0x11, 0xF3, 0xA3, 0xC6, 0x28, 0xE0, 0x33, 0x5D, 0x98, 0x7B, 0xD9, 0x4D, 0x90, 0xB5, 0x28, 0x67, 0x45, 0xC8, 0x86, 0xAE, 0xE7, 0x44, 0xA6, 0x75, 0xDE, 0x86, 0xF6, 0x26, 0xD3, 0x03, 0xFE, 0x75, 0x3F, 0xDE, 0xA6, 0x64, 0xBD, 0x1C, 0x80, 0xC2, 0x63, 0x07, 0x76, 0xD8, 0x3B, 0x04, 0x91, 0xFA, 0x78, 0x44, 0x88, 0x06, 0xFC, 0x38
};

126
src/button_handler.cpp Normal file
View File

@@ -0,0 +1,126 @@
#include "button_handler.h"
#include "power_manager.h"
#include "settings.h"
void ButtonHandler::begin(PowerManager& power, SettingsManager& settings) {
_power = &power;
_settings = &settings;
pinMode(PIN_BTN_TOP, INPUT_PULLUP);
pinMode(PIN_BTN_UP, INPUT_PULLUP);
pinMode(PIN_BTN_DOWN, INPUT_PULLUP);
Serial.println("[buttons] Initialized (G1=random, G10=next, G9=pause)");
}
ButtonEvent ButtonHandler::poll() {
unsigned long now = millis();
bool topNow = (digitalRead(PIN_BTN_TOP) == LOW);
bool upNow = (digitalRead(PIN_BTN_UP) == LOW);
bool downNow = (digitalRead(PIN_BTN_DOWN) == LOW);
// Track press start times
if (topNow && !_btnTopPressed) {
_btnTopPressTime = now;
_btnTopPressed = true;
} else if (!topNow) {
_btnTopPressed = false;
}
if (upNow && !_btnUpPressed) {
_btnUpPressTime = now;
_btnUpPressed = true;
} else if (!upNow) {
_btnUpPressed = false;
}
if (downNow && !_btnDownPressed) {
_btnDownPressTime = now;
_btnDownPressed = true;
} else if (!downNow) {
_btnDownPressed = false;
}
// Check combos first (higher priority)
// BTN_TOP + BTN_DOWN held 3s = deep sleep
if (_btnTopPressed && _btnDownPressed) {
unsigned long holdTime = now - max(_btnTopPressTime, _btnDownPressTime);
if (holdTime >= COMBO_HOLD_MS) {
Serial.println("[buttons] Combo: deep sleep");
_power->flashLED(255, 0, 0, 500);
return ButtonEvent::DeepSleep;
}
}
// BTN_UP held 5s = factory reset
if (_btnUpPressed && !_btnTopPressed && !_btnDownPressed) {
if (now - _btnUpPressTime >= FACTORY_RESET_HOLD_MS) {
Serial.println("[buttons] Combo: factory reset");
_power->flashLED(255, 255, 0, 1000);
_settings->factoryReset();
return ButtonEvent::FactoryReset;
}
}
// Single press detection (on release, with debounce)
if (!debounced(now)) {
return ButtonEvent::None;
}
// BTN_TOP released (was short press) — double-press detection
if (!topNow && _btnTopPressTime > 0 &&
(now - _btnTopPressTime < COMBO_HOLD_MS) &&
(now - _btnTopPressTime > BUTTON_DEBOUNCE_MS)) {
_btnTopPressTime = 0;
_lastDebounce = now;
if (_btnTopPendingSingle && (now - _btnTopLastRelease) < DOUBLE_PRESS_WINDOW) {
// Second press within window -> deep sleep
_btnTopPendingSingle = false;
Serial.println("[buttons] BTN_TOP: double-press -> deep sleep");
_power->flashLED(255, 0, 0, 500);
return ButtonEvent::DeepSleep;
} else {
// First press — mark as pending, wait for possible second
_btnTopLastRelease = now;
_btnTopPendingSingle = true;
}
}
// Emit pending single press after double-press window expires
if (_btnTopPendingSingle && (now - _btnTopLastRelease) >= DOUBLE_PRESS_WINDOW) {
_btnTopPendingSingle = false;
_power->flashLED(255, 255, 255);
Serial.println("[buttons] BTN_TOP: random photo");
return ButtonEvent::RandomPhoto;
}
// BTN_UP released (was short press)
if (!upNow && _btnUpPressTime > 0 &&
(now - _btnUpPressTime < FACTORY_RESET_HOLD_MS) &&
(now - _btnUpPressTime > BUTTON_DEBOUNCE_MS)) {
_btnUpPressTime = 0;
_power->flashLED(255, 255, 255);
Serial.println("[buttons] BTN_UP: next photo");
_lastDebounce = now;
return ButtonEvent::NextPhoto;
}
// BTN_DOWN released (was short press)
if (!downNow && _btnDownPressTime > 0 &&
(now - _btnDownPressTime < COMBO_HOLD_MS) &&
(now - _btnDownPressTime > BUTTON_DEBOUNCE_MS)) {
_btnDownPressTime = 0;
_power->flashLED(255, 255, 255);
Serial.println("[buttons] BTN_DOWN: play/pause");
_lastDebounce = now;
return ButtonEvent::PlayPause;
}
return ButtonEvent::None;
}
bool ButtonHandler::debounced(unsigned long now) {
return (now - _lastDebounce) > BUTTON_DEBOUNCE_MS;
}

43
src/button_handler.h Normal file
View File

@@ -0,0 +1,43 @@
#pragma once
#include <Arduino.h>
#include "config.h"
class PowerManager;
class SettingsManager;
enum class ButtonEvent : uint8_t {
None,
NextPhoto,
RandomPhoto,
PlayPause,
DeepSleep,
FactoryReset
};
class ButtonHandler {
public:
void begin(PowerManager& power, SettingsManager& settings);
ButtonEvent poll();
private:
PowerManager* _power = nullptr;
SettingsManager* _settings = nullptr;
unsigned long _btnTopPressTime = 0;
unsigned long _btnUpPressTime = 0;
unsigned long _btnDownPressTime = 0;
bool _btnTopPressed = false;
bool _btnUpPressed = false;
bool _btnDownPressed = false;
// Double-press detection for BTN_TOP
unsigned long _btnTopLastRelease = 0;
bool _btnTopPendingSingle = false;
static constexpr unsigned long DOUBLE_PRESS_WINDOW = 400;
unsigned long _lastDebounce = 0;
bool debounced(unsigned long now);
};

View File

@@ -3,8 +3,8 @@
// --- Pin Definitions ---
// Buttons
#define PIN_BTN_TOP 1 // G1 - Random photo
#define PIN_BTN_UP 9 // G9 - Next photo
#define PIN_BTN_DOWN 10 // G10 - Play/Pause
#define PIN_BTN_UP 10 // G10 - Next photo
#define PIN_BTN_DOWN 9 // G9 - Play/Pause
// RGB LEDs (NeoPixel)
#define PIN_RGB_LED 21
@@ -38,6 +38,9 @@
#define DEFAULT_META_FLAGS 0x00
#define DEFAULT_META_POS 0 // 0=bottom
#define DEFAULT_LED_BRIGHTNESS 50
#define DEFAULT_PIPELINE_MODE 0 // 0=dynamic, 1=balanced, 2=none
#define DEFAULT_DITHER_NOISE 0 // 1=error scatter ON, 0=OFF (driver dither fix makes this unnecessary)
#define DEFAULT_TIMER_BATCH 10 // Deep sleep batch cache size (1-50)
#define DEFAULT_IMMICH_URL "https://photos.example.com"
// --- Timing ---
@@ -62,23 +65,23 @@
#define PORTRAIT_GAP_PX 8
#define PORTRAIT_WIDTH 267 // (DISPLAY_HEIGHT * 2) / 3
// --- Dither Palette (Spectra 6) ---
// Initial values — calibrate on real hardware
#define PALETTE_BLACK_R 0
#define PALETTE_BLACK_G 0
#define PALETTE_BLACK_B 0
#define PALETTE_WHITE_R 255
#define PALETTE_WHITE_G 255
#define PALETTE_WHITE_B 255
#define PALETTE_RED_R 200
#define PALETTE_RED_G 30
#define PALETTE_RED_B 30
#define PALETTE_GREEN_R 30
#define PALETTE_GREEN_G 160
#define PALETTE_GREEN_B 30
#define PALETTE_BLUE_R 30
#define PALETTE_BLUE_G 30
#define PALETTE_BLUE_B 200
#define PALETTE_YELLOW_R 220
#define PALETTE_YELLOW_G 200
#define PALETTE_YELLOW_B 30
// --- Dither Palette (Spectra 6 calibrated, from epdoptimize "spectra6") ---
// Measured display appearance — what the panel actually shows
#define PALETTE_BLACK_R 0x1F
#define PALETTE_BLACK_G 0x22
#define PALETTE_BLACK_B 0x26
#define PALETTE_WHITE_R 0xB9
#define PALETTE_WHITE_G 0xC7
#define PALETTE_WHITE_B 0xC9
#define PALETTE_RED_R 0x62
#define PALETTE_RED_G 0x20
#define PALETTE_RED_B 0x1E
#define PALETTE_GREEN_R 0x35
#define PALETTE_GREEN_G 0x56
#define PALETTE_GREEN_B 0x3A
#define PALETTE_BLUE_R 0x23
#define PALETTE_BLUE_G 0x3F
#define PALETTE_BLUE_B 0x8E
#define PALETTE_YELLOW_R 0xC1
#define PALETTE_YELLOW_G 0xBB
#define PALETTE_YELLOW_B 0x1E

490
src/display_manager.cpp Normal file
View File

@@ -0,0 +1,490 @@
#include "display_manager.h"
#include "power_manager.h"
#include <M5Unified.h>
// EPD-exact palette: values produce distance=0 in Panel_ED2208's
// _rgb_to_epd_color nearest-color lookup, guaranteeing correct mapping.
// Order matches our pipeline's palette indices (0=black..5=yellow).
static const lgfx::bgr888_t EPD_PALETTE[6] = {
{0, 0, 0}, // Black (EPD_BLACK)
{255, 255, 255}, // White (EPD_WHITE)
{191, 0, 0}, // Red (EPD_RED)
{67, 138, 28}, // Green (EPD_GREEN)
{100, 64, 255}, // Blue (EPD_BLUE)
{255, 243, 56} // Yellow (EPD_YELLOW)
};
void DisplayManager::begin(PowerManager& power) {
_power = &power;
_display = &M5.Display;
// Ensure no automatic e-ink refreshes happen
_display->setAutoDisplay(false);
// Set rotation for landscape (device physically rotated)
_display->setRotation(3);
// Disable the panel driver's internal dithering (epd_quality applies a diagonal
// bias pattern via _dither_row_rgb_pair). Since our ImagePipeline already does
// proper Floyd-Steinberg dithering to the 6-color palette, the driver should
// just do a clean nearest-color lookup with no additional spatial bias.
// On Panel_ED2208 this only affects the dither function — NOT refresh quality.
_display->setEpdMode(epd_mode_t::epd_fastest);
Serial.printf("[display] Initialized: %dx%d, rotation=%d, epd_mode=fastest (no driver dither)\n",
_display->width(), _display->height(), _display->getRotation());
}
void DisplayManager::showSetupScreen() {
_power->enableEPDPower();
// Batch all drawing — nothing hits the panel until display() at the end
_display->startWrite();
_display->fillScreen(TFT_WHITE);
// Generate QR code for WiFi auto-connect
const char* qrData = "WIFI:T:nopass;S:PaperColor-Setup;;";
QRCode qrcode;
uint8_t qrcodeData[qrcode_getBufferSize(6)];
qrcode_initText(&qrcode, qrcodeData, 6, ECC_LOW, qrData);
// Draw QR code centered vertically, left-of-center horizontally
int qrSize = qrcode.size;
int scale = 4;
int qrPixels = qrSize * scale;
int qrX = (DISPLAY_WIDTH / 3) - (qrPixels / 2);
int qrY = (DISPLAY_HEIGHT - qrPixels) / 2;
for (int y = 0; y < qrSize; y++) {
for (int x = 0; x < qrSize; x++) {
uint16_t color = qrcode_getModule(&qrcode, x, y) ? TFT_BLACK : TFT_WHITE;
_display->fillRect(qrX + x * scale, qrY + y * scale, scale, scale, color);
}
}
// Text instructions to the right of QR code
int textX = DISPLAY_WIDTH / 2 + 40;
_display->setTextColor(TFT_BLACK);
_display->setTextDatum(middle_left);
_display->setTextSize(2);
_display->drawString("PaperColor", textX, DISPLAY_HEIGHT / 2 - 60);
_display->setTextSize(1);
_display->drawString("Scan QR to connect", textX, DISPLAY_HEIGHT / 2 - 20);
_display->drawString("or join WiFi:", textX, DISPLAY_HEIGHT / 2 + 10);
_display->setTextSize(1.5);
_display->drawString("PaperColor-Setup", textX, DISPLAY_HEIGHT / 2 + 40);
_display->setTextSize(1);
_display->drawString("Then open:", textX, DISPLAY_HEIGHT / 2 + 70);
_display->drawString("192.168.4.1", textX, DISPLAY_HEIGHT / 2 + 95);
_display->endWrite();
// Single e-ink refresh — the only panel update during boot
triggerRefresh();
_power->disableEPDPower();
}
void DisplayManager::showImage(const ProcessedImage& img) {
if (!img.valid || img.framebuffer == nullptr) {
Serial.println("[display] Invalid image — skipping");
return;
}
_power->enableEPDPower();
Serial.println("[display] Writing framebuffer to e-ink...");
// Bulk-write framebuffer using palette lookup (handles rotation internally)
_display->startWrite();
_display->pushImage(0, 0, img.width, img.height,
img.framebuffer, lgfx::color_depth_t::palette_8bit, EPD_PALETTE);
_display->endWrite();
// Do NOT refresh here — caller should add metadata overlay then call refresh()
}
void DisplayManager::showMessage(const char* title, const char* body) {
_power->enableEPDPower();
_display->startWrite();
_display->fillScreen(TFT_WHITE);
_display->setTextColor(TFT_BLACK);
_display->setTextDatum(middle_center);
_display->setTextSize(2);
_display->drawString(title, DISPLAY_WIDTH / 2, DISPLAY_HEIGHT / 2 - 30);
_display->setTextSize(1);
_display->drawString(body, DISPLAY_WIDTH / 2, DISPLAY_HEIGHT / 2 + 20);
_display->endWrite();
triggerRefresh();
_power->disableEPDPower();
}
void DisplayManager::showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos,
const String& dateFmt, const String& timeFmt) {
showMetadataInRegion(info, metaFlags, pos, 0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT, dateFmt, timeFmt);
}
void DisplayManager::showMetadataInRegion(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos,
uint16_t regionX, uint16_t regionY,
uint16_t regionW, uint16_t regionH,
const String& dateFmt, const String& timeFmt) {
if (metaFlags == 0) return;
// Build metadata string (excluding location — it gets its own badge)
String metaText = "";
// Parse ISO datetime (YYYY-MM-DDTHH:MM:SS) into struct tm for strftime
if (((metaFlags & META_DATE) || (metaFlags & META_TIME)) && info.dateTime.length() >= 19) {
struct tm t = {};
sscanf(info.dateTime.c_str(), "%d-%d-%dT%d:%d:%d",
&t.tm_year, &t.tm_mon, &t.tm_mday,
&t.tm_hour, &t.tm_min, &t.tm_sec);
t.tm_year -= 1900;
t.tm_mon -= 1;
char buf[64];
if ((metaFlags & META_DATE) && (metaFlags & META_TIME)) {
String combinedFmt = dateFmt + " " + timeFmt;
strftime(buf, sizeof(buf), combinedFmt.c_str(), &t);
metaText += buf;
} else if (metaFlags & META_DATE) {
strftime(buf, sizeof(buf), dateFmt.c_str(), &t);
metaText += buf;
} else {
strftime(buf, sizeof(buf), timeFmt.c_str(), &t);
metaText += buf;
}
} else if ((metaFlags & META_DATE) && info.dateTime.length() >= 10) {
// Fallback if datetime is too short for full parse
metaText += info.dateTime.substring(0, 10);
}
if ((metaFlags & META_PEOPLE) && !info.people.empty()) {
if (metaText.length() > 0) metaText += " | ";
for (size_t i = 0; i < info.people.size(); i++) {
if (i > 0) metaText += ", ";
metaText += info.people[i];
}
}
if ((metaFlags & META_ALBUM)) {
// Album name would need to be passed separately
}
if ((metaFlags & META_CAMERA) && info.camera.length() > 0) {
if (metaText.length() > 0) metaText += " | ";
metaText += info.camera;
}
// Location gets its own badge in lower-left
String locationText = "";
if ((metaFlags & META_LOCATION) && info.city.length() > 0) {
locationText = info.city;
}
if (metaText.length() == 0 && locationText.length() == 0) return;
static constexpr uint16_t MARGIN = 8;
static constexpr uint16_t PAD_X = 8;
static constexpr uint16_t PAD_Y = 5;
static constexpr uint16_t TEXT_H = 20;
static constexpr uint16_t ICON_SIZE = 12;
static constexpr uint16_t ICON_GAP = 6;
bool showIcons = (regionW == DISPLAY_WIDTH);
_display->setTextSize(1.5f);
_display->setTextColor(TFT_WHITE);
// Track where the main badge ends up (for location badge offset)
uint16_t mainBadgeBottomY = 0;
// Draw main metadata badge (date/time/people/camera) at configured position
if (metaText.length() > 0) {
switch (pos) {
case MetaPosition::CaptionBottom:
case MetaPosition::CaptionTop: {
uint16_t barH = 36;
uint16_t barY = (pos == MetaPosition::CaptionTop)
? regionY
: regionY + regionH - barH;
_display->fillRect(regionX, barY, regionW, barH, TFT_BLACK);
_display->setTextDatum(middle_center);
_display->drawString(metaText.c_str(), regionX + regionW / 2, barY + barH / 2);
break;
}
case MetaPosition::OverlayTopLeft:
case MetaPosition::OverlayTopRight:
case MetaPosition::OverlayBottomLeft:
case MetaPosition::OverlayBottomRight: {
uint16_t textW = _display->textWidth(metaText.c_str());
uint16_t iconExtra = showIcons ? (ICON_SIZE + ICON_GAP) : 0;
uint16_t badgeW = min((uint16_t)(textW + iconExtra + PAD_X * 2), regionW);
uint16_t badgeH = TEXT_H + PAD_Y * 2;
uint16_t bx, by;
switch (pos) {
case MetaPosition::OverlayTopLeft:
bx = regionX + MARGIN;
by = regionY + MARGIN;
break;
case MetaPosition::OverlayTopRight:
bx = regionX + regionW - badgeW - MARGIN;
by = regionY + MARGIN;
break;
case MetaPosition::OverlayBottomLeft:
bx = regionX + MARGIN;
by = regionY + regionH - badgeH - MARGIN;
break;
case MetaPosition::OverlayBottomRight:
bx = regionX + regionW - badgeW - MARGIN;
by = regionY + regionH - badgeH - MARGIN;
break;
default:
bx = regionX + MARGIN;
by = regionY + MARGIN;
break;
}
drawOverlayBadge(bx, by, badgeW, badgeH);
_display->setTextDatum(middle_left);
uint16_t textX = bx + PAD_X;
if (showIcons) {
drawCalendarIcon(bx + PAD_X, by + (badgeH - ICON_SIZE) / 2 - 1, ICON_SIZE);
textX += ICON_SIZE + ICON_GAP;
}
_display->drawString(metaText.c_str(), textX, by + badgeH / 2);
mainBadgeBottomY = by + badgeH;
break;
}
}
}
// Draw location badge in lower-left corner (separate from main badge)
if (locationText.length() > 0) {
uint16_t locTextW = _display->textWidth(locationText.c_str());
uint16_t iconExtra = showIcons ? (ICON_SIZE + ICON_GAP) : 0;
uint16_t locBadgeW = min((uint16_t)(locTextW + iconExtra + PAD_X * 2), regionW);
uint16_t locBadgeH = TEXT_H + PAD_Y * 2;
uint16_t lx = regionX + MARGIN;
uint16_t ly = regionY + regionH - locBadgeH - MARGIN;
// If main badge is also in bottom-left, stack location above it
if (pos == MetaPosition::OverlayBottomLeft && metaText.length() > 0) {
ly = ly - locBadgeH - 4;
}
drawOverlayBadge(lx, ly, locBadgeW, locBadgeH);
_display->setTextColor(TFT_WHITE);
_display->setTextDatum(middle_left);
uint16_t locTextX = lx + PAD_X;
if (showIcons) {
drawPinIcon(lx + PAD_X, ly + (locBadgeH - ICON_SIZE) / 2 - 1, ICON_SIZE);
locTextX += ICON_SIZE + ICON_GAP;
}
_display->drawString(locationText.c_str(), locTextX, ly + locBadgeH / 2);
}
}
void DisplayManager::drawOverlayBadge(uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
static constexpr int16_t R = 4;
if (!_translucent) {
_display->fillRoundRect(x, y, w, h, R, TFT_BLACK);
return;
}
// Checkerboard pattern within rounded-rect bounds for 50% opacity effect.
// Use fillRoundRect to create a clip mask in black, then punch holes by
// restoring every-other pixel. Simpler: just iterate and test corner radii.
for (uint16_t py = y; py < y + h; py++) {
for (uint16_t px = x; px < x + w; px++) {
if ((px + py) % 2 != 0) continue;
// Check if pixel is inside the rounded rect (corner exclusion)
int16_t lx = px - x;
int16_t ly = py - y;
bool inside = true;
// Top-left corner
if (lx < R && ly < R) {
int16_t dx = R - 1 - lx;
int16_t dy = R - 1 - ly;
inside = (dx * dx + dy * dy) <= (R * R);
}
// Top-right corner
else if (lx >= w - R && ly < R) {
int16_t dx = lx - (w - R);
int16_t dy = R - 1 - ly;
inside = (dx * dx + dy * dy) <= (R * R);
}
// Bottom-left corner
else if (lx < R && ly >= h - R) {
int16_t dx = R - 1 - lx;
int16_t dy = ly - (h - R);
inside = (dx * dx + dy * dy) <= (R * R);
}
// Bottom-right corner
else if (lx >= w - R && ly >= h - R) {
int16_t dx = lx - (w - R);
int16_t dy = ly - (h - R);
inside = (dx * dx + dy * dy) <= (R * R);
}
if (inside) {
_display->writePixel(px, py, TFT_BLACK);
}
}
}
}
void DisplayManager::drawPinIcon(int32_t x, int32_t y, int32_t size) {
int32_t r = size * 2 / 5;
int32_t cx = x + size / 2;
int32_t cy = y + r + 1;
_display->fillCircle(cx, cy, r, TFT_WHITE);
_display->fillTriangle(cx - r + 1, cy + r / 2, cx + r - 1, cy + r / 2,
cx, y + size - 1, TFT_WHITE);
_display->fillCircle(cx, cy, r / 3, TFT_BLACK);
}
void DisplayManager::drawCalendarIcon(int32_t x, int32_t y, int32_t size) {
int32_t bodyY = y + size / 4;
int32_t bodyH = size - size / 4;
int32_t bodyW = size;
_display->drawRect(x, bodyY, bodyW, bodyH, TFT_WHITE);
int32_t headerH = bodyH / 3;
_display->drawLine(x, bodyY + headerH, x + bodyW - 1, bodyY + headerH, TFT_WHITE);
int32_t tabW = 2, tabH = size / 4 + 1;
_display->fillRect(x + bodyW / 4, y, tabW, tabH, TFT_WHITE);
_display->fillRect(x + bodyW * 3 / 4 - tabW, y, tabW, tabH, TFT_WHITE);
int32_t dotSize = max((int32_t)1, size / 6);
int32_t gridY = bodyY + headerH + 2;
int32_t gridX = x + 2;
int32_t gapX = (bodyW - 4) / 3;
int32_t gapY = (bodyH - headerH - 3) / 2;
for (int row = 0; row < 2; row++)
for (int col = 0; col < 3; col++)
_display->fillRect(gridX + col * gapX, gridY + row * gapY,
dotSize, dotSize, TFT_WHITE);
}
void DisplayManager::showSleepIndicator(MetaPosition metaPos) {
_power->enableEPDPower();
static constexpr uint16_t MARGIN = 8;
static constexpr uint16_t PAD_X = 6;
static constexpr uint16_t PAD_Y = 4;
_display->setTextSize(1);
uint16_t textW = _display->textWidth("SLP");
uint16_t textH = 14;
uint16_t badgeW = textW + PAD_X * 2;
uint16_t badgeH = textH + PAD_Y * 2;
// Pick a corner opposite to metadata position
uint16_t bx, by;
switch (metaPos) {
case MetaPosition::OverlayTopLeft:
case MetaPosition::CaptionTop:
bx = DISPLAY_WIDTH - badgeW - MARGIN;
by = DISPLAY_HEIGHT - badgeH - MARGIN;
break;
case MetaPosition::OverlayTopRight:
bx = MARGIN;
by = DISPLAY_HEIGHT - badgeH - MARGIN;
break;
case MetaPosition::OverlayBottomLeft:
bx = DISPLAY_WIDTH - badgeW - MARGIN;
by = MARGIN;
break;
case MetaPosition::OverlayBottomRight:
case MetaPosition::CaptionBottom:
default:
bx = MARGIN;
by = MARGIN;
break;
}
drawOverlayBadge(bx, by, badgeW, badgeH);
_display->setTextColor(TFT_WHITE);
_display->setTextDatum(middle_center);
_display->drawString("SLP", bx + badgeW / 2, by + badgeH / 2);
}
void DisplayManager::showBatteryIndicator(uint8_t percent, MetaPosition metaPos) {
static constexpr uint16_t MARGIN = 8;
static constexpr uint16_t PAD_X = 6;
static constexpr uint16_t PAD_Y = 4;
// Battery icon dimensions
static constexpr uint16_t BATT_W = 20;
static constexpr uint16_t BATT_H = 10;
static constexpr uint16_t NUB_W = 3;
static constexpr uint16_t NUB_H = 5;
static constexpr uint16_t GAP = 4;
// Build percentage text
char pctText[5];
snprintf(pctText, sizeof(pctText), "%d%%", percent);
_display->setTextSize(1);
uint16_t textW = _display->textWidth(pctText);
uint16_t textH = 14;
// Total badge size: icon + gap + text + padding
uint16_t contentW = BATT_W + NUB_W + GAP + textW;
uint16_t contentH = max(BATT_H, textH);
uint16_t badgeW = contentW + PAD_X * 2;
uint16_t badgeH = contentH + PAD_Y * 2;
// Position: top-right by default, top-left if metadata occupies top-right
uint16_t bx, by;
if (metaPos == MetaPosition::OverlayTopRight) {
bx = MARGIN;
by = MARGIN;
} else {
bx = DISPLAY_WIDTH - badgeW - MARGIN;
by = MARGIN;
}
// Draw badge background
drawOverlayBadge(bx, by, badgeW, badgeH);
// Draw battery outline (white rect with 1px border)
uint16_t iconX = bx + PAD_X;
uint16_t iconY = by + (badgeH - BATT_H) / 2;
_display->drawRect(iconX, iconY, BATT_W, BATT_H, TFT_WHITE);
// Draw nub on right side of battery
uint16_t nubX = iconX + BATT_W;
uint16_t nubY = iconY + (BATT_H - NUB_H) / 2;
_display->fillRect(nubX, nubY, NUB_W, NUB_H, TFT_WHITE);
// Draw fill level inside battery (1px inset)
uint16_t fillMaxW = BATT_W - 2;
uint16_t fillW = (fillMaxW * percent) / 100;
if (fillW > 0) {
_display->fillRect(iconX + 1, iconY + 1, fillW, BATT_H - 2, TFT_WHITE);
}
// Draw percentage text
uint16_t textX = iconX + BATT_W + NUB_W + GAP;
_display->setTextColor(TFT_WHITE);
_display->setTextDatum(middle_left);
_display->drawString(pctText, textX, by + badgeH / 2);
}
void DisplayManager::refresh() {
unsigned long start = millis();
triggerRefresh();
unsigned long elapsed = millis() - start;
Serial.printf("[display] Refresh complete in %lu ms\n", elapsed);
_power->disableEPDPower();
}
void DisplayManager::triggerRefresh() {
// Panel_ED2208::_exec_transfer always sends the full 400x600 frame
// regardless of dirty region — the dirty rect only gates whether
// a transfer happens at all. ~30ms at 4MHz SPI, fine for photo frame.
_display->display();
}

39
src/display_manager.h Normal file
View File

@@ -0,0 +1,39 @@
#pragma once
#include <Arduino.h>
#include <M5GFX.h>
#include <qrcode.h>
#include "config.h"
#include "image_pipeline.h"
#include "immich_client.h"
#include "settings.h"
class PowerManager;
class DisplayManager {
public:
void begin(PowerManager& power);
void showSetupScreen();
void showImage(const ProcessedImage& img);
void showMessage(const char* title, const char* body);
void showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos,
const String& dateFmt = "%Y-%m-%d", const String& timeFmt = "%H:%M:%S");
void showMetadataInRegion(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos,
uint16_t regionX, uint16_t regionY, uint16_t regionW, uint16_t regionH,
const String& dateFmt = "%Y-%m-%d", const String& timeFmt = "%H:%M:%S");
void showSleepIndicator(MetaPosition metaPos);
void showBatteryIndicator(uint8_t percent, MetaPosition metaPos);
void refresh();
void setTranslucent(bool enabled) { _translucent = enabled; }
private:
PowerManager* _power = nullptr;
M5GFX* _display = nullptr;
bool _translucent = false;
void drawOverlayBadge(uint16_t x, uint16_t y, uint16_t w, uint16_t h);
void drawPinIcon(int32_t x, int32_t y, int32_t size);
void drawCalendarIcon(int32_t x, int32_t y, int32_t size);
void triggerRefresh();
};

975
src/image_pipeline.cpp Normal file
View File

@@ -0,0 +1,975 @@
#include "image_pipeline.h"
#include "blue_noise.h"
#include "stb_image.h"
#include <M5GFX.h>
#include <esp_heap_caps.h>
#include <cstring>
#include <cmath>
// Spectra 6 calibrated palette (measured display appearance from epdoptimize)
// These represent what the display ACTUALLY shows, used for dithering decisions
static const uint8_t PALETTE_CALIBRATED[6][3] = {
{0x1F, 0x22, 0x26}, // Black -> appears as dark gray
{0xB9, 0xC7, 0xC9}, // White -> appears as light gray-blue
{0x62, 0x20, 0x1E}, // Red -> appears as dark red/brown
{0x35, 0x56, 0x3A}, // Green -> appears as dark forest green
{0x23, 0x3F, 0x8E}, // Blue -> appears as dark navy
{0xC1, 0xBB, 0x1E} // Yellow -> appears as olive/mustard
};
// ---------------------------------------------------------------------------
// Color science helpers (sRGB <-> LAB, luma, saturation)
// ---------------------------------------------------------------------------
static inline uint8_t clampByte(float v) {
if (v <= 0.0f) return 0;
if (v >= 255.0f) return 255;
return (uint8_t)(v + 0.5f);
}
static inline float clampF(float v, float lo, float hi) {
return (v < lo) ? lo : (v > hi) ? hi : v;
}
static inline float luma709(float r, float g, float b) {
return 0.2126f * r + 0.7152f * g + 0.0722f * b;
}
// Pre-computed sRGB-to-linear LUT (avoids per-pixel powf)
static const float* getSrgbToLinear() {
static float lut[256];
static bool initialized = false;
if (!initialized) {
for (int i = 0; i < 256; i++) {
float n = i / 255.0f;
lut[i] = (n > 0.04045f)
? powf((n + 0.055f) / 1.055f, 2.4f)
: n / 12.92f;
}
initialized = true;
}
return lut;
}
static inline float labForwardPivot(float v) {
return (v > 0.008856f) ? cbrtf(v) : 7.787f * v + 16.0f / 116.0f;
}
// Lightness-only conversion (for DRC histogram pass)
static float rgbToLabLightness(uint8_t r, uint8_t g, uint8_t b) {
const float* lut = getSrgbToLinear();
float y = lut[r] * 0.2126729f + lut[g] * 0.7151522f + lut[b] * 0.0721750f;
return 116.0f * labForwardPivot(y) - 16.0f;
}
// Full sRGB -> CIE LAB (D65 illuminant)
static void srgbToLab(uint8_t r, uint8_t g, uint8_t b,
float* L, float* a, float* bOut) {
const float* lut = getSrgbToLinear();
float rn = lut[r], gn = lut[g], bn = lut[b];
float x = rn * 0.4124564f + gn * 0.3575761f + bn * 0.1804375f;
float y = rn * 0.2126729f + gn * 0.7151522f + bn * 0.0721750f;
float z = rn * 0.0193339f + gn * 0.1191920f + bn * 0.9503041f;
float fx = labForwardPivot(x / 0.95047f);
float fy = labForwardPivot(y);
float fz = labForwardPivot(z / 1.08883f);
*L = 116.0f * fy - 16.0f;
*a = 500.0f * (fx - fy);
*bOut = 200.0f * (fy - fz);
}
// CIE LAB -> sRGB (D65 illuminant)
static void labToSrgb(float L, float a, float b,
uint8_t* rOut, uint8_t* gOut, uint8_t* bOut) {
float fy = (L + 16.0f) / 116.0f;
float fx = a / 500.0f + fy;
float fz = fy - b / 200.0f;
float x = (fx > 0.206897f) ? fx * fx * fx : (fx - 16.0f / 116.0f) / 7.787f;
float y = (fy > 0.206897f) ? fy * fy * fy : (fy - 16.0f / 116.0f) / 7.787f;
float z = (fz > 0.206897f) ? fz * fz * fz : (fz - 16.0f / 116.0f) / 7.787f;
x *= 0.95047f;
z *= 1.08883f;
float rl = x * 3.2404542f + y * -1.5371385f + z * -0.4985314f;
float gl = x * -0.9692660f + y * 1.8760108f + z * 0.0415560f;
float bl = x * 0.0556434f + y * -0.2040259f + z * 1.0572252f;
auto linearToSrgb = [](float v) -> float {
if (v <= 0.0f) return 0.0f;
return (v > 0.0031308f)
? 1.055f * powf(v, 1.0f / 2.4f) - 0.055f
: 12.92f * v;
};
*rOut = clampByte(linearToSrgb(rl) * 255.0f);
*gOut = clampByte(linearToSrgb(gl) * 255.0f);
*bOut = clampByte(linearToSrgb(bl) * 255.0f);
}
// HSV-style saturation (max-min)/max in 0..1 range
static inline float pixelSaturation(float r, float g, float b) {
float mx = fmaxf(r, fmaxf(g, b));
float mn = fminf(r, fminf(g, b));
return (mx > 0.0f) ? (mx - mn) / mx : 0.0f;
}
// ---------------------------------------------------------------------------
// Tone mapping (replaces enhanceContrast)
//
// Uses epdoptimize's "dynamic" preset:
// - Asymmetric power-curve S-curve built into a 256-entry LUT
// - HSL-space saturation adjustment (preserves hue fidelity)
// - Lookup-table contrast and exposure
// ---------------------------------------------------------------------------
static constexpr float SHADOW_TONE_RESPONSE = 1.5f;
static void toneMap(uint8_t* rgb, size_t pixelCount) {
static constexpr float EXPOSURE = 0.0f; // stops (2^0 = 1.0x)
static constexpr float SATURATION_ADJ = 0.3f; // -> 1.3x multiplier
static constexpr float CONTRAST_ADJ = 0.0f; // -> 1.0x multiplier
static constexpr float STRENGTH = 0.9f;
static constexpr float SHADOW_BOOST = 0.0f;
static constexpr float HIGHLIGHT_COMP = -1.5f;
static constexpr float MIDPOINT = 0.5f;
float exposureMul = powf(2.0f, EXPOSURE);
float satMul = fmaxf(0.0f, SATURATION_ADJ + 1.0f);
float contrastMul = (CONTRAST_ADJ < 0.0f)
? fmaxf(0.5f, 1.0f + CONTRAST_ADJ * 0.5f)
: CONTRAST_ADJ + 1.0f;
// Build S-curve LUT (asymmetric power curve per epdoptimize)
float mid = clampF(MIDPOINT, 0.01f, 0.99f);
float shadowExp = clampF(1.0f - STRENGTH * SHADOW_BOOST * SHADOW_TONE_RESPONSE, 0.15f, 3.0f);
float highlightExp = clampF(1.0f - STRENGTH * HIGHLIGHT_COMP, 0.15f, 3.0f);
uint8_t exposureLut[256];
uint8_t toneLut[256];
for (int v = 0; v < 256; v++) {
exposureLut[v] = clampByte((float)v * exposureMul);
float tv = clampF(((float)v - 128.0f) * contrastMul + 128.0f, 0.0f, 255.0f);
if (STRENGTH != 0.0f) {
float n = tv / 255.0f;
float curved;
if (n <= mid) {
curved = powf(n / mid, shadowExp) * mid;
} else {
curved = mid + powf((n - mid) / (1.0f - mid), highlightExp) * (1.0f - mid);
}
tv = clampF(curved * 255.0f, 0.0f, 255.0f);
}
toneLut[v] = (uint8_t)tv;
}
bool needsSaturation = (satMul != 1.0f);
for (size_t i = 0; i < pixelCount; i++) {
size_t idx = i * 3;
if (!needsSaturation) {
rgb[idx] = toneLut[exposureLut[rgb[idx]]];
rgb[idx + 1] = toneLut[exposureLut[rgb[idx + 1]]];
rgb[idx + 2] = toneLut[exposureLut[rgb[idx + 2]]];
continue;
}
// HSL-space saturation (preserves hue, matches epdoptimize)
float r0 = exposureLut[rgb[idx]] / 255.0f;
float g0 = exposureLut[rgb[idx + 1]] / 255.0f;
float b0 = exposureLut[rgb[idx + 2]] / 255.0f;
float maxC = fmaxf(r0, fmaxf(g0, b0));
float minC = fminf(r0, fminf(g0, b0));
float light = (maxC + minC) * 0.5f;
float r = r0, g = g0, b = b0;
if (maxC != minC) {
float delta = maxC - minC;
float sat = (light > 0.5f)
? delta / (2.0f - maxC - minC)
: delta / fmaxf(maxC + minC, 1e-6f);
float hue;
if (maxC == r0) {
hue = (g0 - b0) / delta;
if (g0 < b0) hue += 6.0f;
hue /= 6.0f;
} else if (maxC == g0) {
hue = ((b0 - r0) / delta + 2.0f) / 6.0f;
} else {
hue = ((r0 - g0) / delta + 4.0f) / 6.0f;
}
float newSat = clampF(sat * satMul, 0.0f, 1.0f);
float c = (1.0f - fabsf(2.0f * light - 1.0f)) * newSat;
float x = c * (1.0f - fabsf(fmodf(hue * 6.0f, 2.0f) - 1.0f));
float m = light - c * 0.5f;
int sector = (int)(hue * 6.0f);
if (sector >= 6) sector = 5;
switch (sector) {
case 0: r = c + m; g = x + m; b = m; break;
case 1: r = x + m; g = c + m; b = m; break;
case 2: r = m; g = c + m; b = x + m; break;
case 3: r = m; g = x + m; b = c + m; break;
case 4: r = x + m; g = m; b = c + m; break;
case 5: r = c + m; g = m; b = x + m; break;
}
}
rgb[idx] = toneLut[clampByte(r * 255.0f)];
rgb[idx + 1] = toneLut[clampByte(g * 255.0f)];
rgb[idx + 2] = toneLut[clampByte(b * 255.0f)];
}
Serial.printf("[pipeline] toneMap: exposure=%.1f sat=%.1fx strength=%.1f\n",
EXPOSURE, satMul, STRENGTH);
}
// ---------------------------------------------------------------------------
// Dynamic range compression (fast luma path with chroma protection)
//
// Uses epdoptimize's "balanced" preset approach:
// - Histogram percentile scan for source range detection
// - Remap into palette luminance range
// - smoothstep chroma protection prevents saturated color washout
// ---------------------------------------------------------------------------
static void compressDynamicRange(uint8_t* rgb, size_t pixelCount) {
static constexpr float STRENGTH = 1.0f;
static constexpr float LOW_PERCENTILE = 0.01f;
static constexpr float HIGH_PERCENTILE = 0.99f;
float blackY = luma709(PALETTE_CALIBRATED[0][0],
PALETTE_CALIBRATED[0][1],
PALETTE_CALIBRATED[0][2]);
float whiteY = luma709(PALETTE_CALIBRATED[1][0],
PALETTE_CALIBRATED[1][1],
PALETTE_CALIBRATED[1][2]);
float targetRange = whiteY - blackY;
if (targetRange <= 0.0f) return;
// Build luma histogram for percentile detection
uint32_t histogram[256] = {0};
for (size_t i = 0; i < pixelCount; i++) {
size_t idx = i * 3;
histogram[clampByte(luma709(rgb[idx], rgb[idx + 1], rgb[idx + 2]))]++;
}
// Find percentile endpoints
auto findPercentile = [&](float p) -> float {
uint32_t target = (uint32_t)((pixelCount - 1) * p);
uint32_t seen = 0;
for (int i = 0; i < 256; i++) {
seen += histogram[i];
if (seen > target) return (float)i;
}
return 255.0f;
};
float sourceBlackY = findPercentile(LOW_PERCENTILE);
float sourceWhiteY = findPercentile(HIGH_PERCENTILE);
float sourceRange = sourceWhiteY - sourceBlackY;
if (sourceRange <= 0.0001f) return;
for (size_t i = 0; i < pixelCount; i++) {
size_t idx = i * 3;
float r = rgb[idx], g = rgb[idx + 1], b = rgb[idx + 2];
float y = luma709(r, g, b);
float normalizedY = clampF((y - sourceBlackY) / sourceRange, 0.0f, 1.0f);
float targetY = blackY + normalizedY * targetRange;
// Chroma protection: smoothstep(0.18, 0.68, saturation) * 0.85
float sat = pixelSaturation(r, g, b);
float chromaProtection = 0.0f;
if (sat > 0.18f) {
float t = clampF((sat - 0.18f) / (0.68f - 0.18f), 0.0f, 1.0f);
chromaProtection = t * t * (3.0f - 2.0f * t) * 0.85f;
}
float effectiveStrength = STRENGTH * (1.0f - chromaProtection);
float nextY = y + (targetY - y) * effectiveStrength;
float ratio = (y > 0.0f) ? nextY / y : 0.0f;
float maxChannel = fmaxf(r, fmaxf(g, b));
if (maxChannel > 0.0f) ratio = fminf(ratio, 255.0f / maxChannel);
rgb[idx] = clampByte(r * ratio);
rgb[idx + 1] = clampByte(g * ratio);
rgb[idx + 2] = clampByte(b * ratio);
}
Serial.printf("[pipeline] DRC: src=[%.0f..%.0f] -> dst=[%.0f..%.0f]\n",
sourceBlackY, sourceWhiteY, blackY, whiteY);
}
// Average a single edge of the fitted image (4 rows or columns deep)
static constexpr int EDGE_DEPTH = 4;
struct EdgeColor { uint8_t r, g, b; };
static EdgeColor averageEdge(const uint8_t* rgb, uint16_t w, uint16_t h,
int side) {
// side: 0=top, 1=bottom, 2=left, 3=right
uint32_t sumR = 0, sumG = 0, sumB = 0, count = 0;
switch (side) {
case 0: { // top rows
int rows = min((int)h, EDGE_DEPTH);
for (int y = 0; y < rows; y++)
for (int x = 0; x < w; x++) {
size_t idx = ((size_t)y * w + x) * 3;
sumR += rgb[idx]; sumG += rgb[idx+1]; sumB += rgb[idx+2]; count++;
}
break;
}
case 1: { // bottom rows
int rows = min((int)h, EDGE_DEPTH);
for (int y = h - rows; y < h; y++)
for (int x = 0; x < w; x++) {
size_t idx = ((size_t)y * w + x) * 3;
sumR += rgb[idx]; sumG += rgb[idx+1]; sumB += rgb[idx+2]; count++;
}
break;
}
case 2: { // left columns
int cols = min((int)w, EDGE_DEPTH);
for (int y = 0; y < h; y++)
for (int x = 0; x < cols; x++) {
size_t idx = ((size_t)y * w + x) * 3;
sumR += rgb[idx]; sumG += rgb[idx+1]; sumB += rgb[idx+2]; count++;
}
break;
}
case 3: { // right columns
int cols = min((int)w, EDGE_DEPTH);
for (int y = 0; y < h; y++)
for (int x = w - cols; x < w; x++) {
size_t idx = ((size_t)y * w + x) * 3;
sumR += rgb[idx]; sumG += rgb[idx+1]; sumB += rgb[idx+2]; count++;
}
break;
}
}
if (count == 0) return {0, 0, 0};
return {(uint8_t)(sumR / count), (uint8_t)(sumG / count), (uint8_t)(sumB / count)};
}
// In-place horizontal box blur on a rectangular sub-region of a row-major RGB buffer.
// Operates on rows from y0..y1-1, columns x0..x1-1, within a buffer of stride `stride` pixels.
static void boxBlurH(uint8_t* buf, uint16_t stride,
uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, int radius) {
int width = x1 - x0;
if (width <= 0) return;
// Temp row buffer for one scanline (3 bytes per pixel)
uint8_t* tmp = (uint8_t*)malloc(width * 3);
if (tmp == nullptr) return;
int diam = radius * 2 + 1;
for (uint16_t y = y0; y < y1; y++) {
uint8_t* row = buf + ((size_t)y * stride + x0) * 3;
// Running sum initialization
int sumR = 0, sumG = 0, sumB = 0;
for (int i = -radius; i <= radius; i++) {
int xi = constrain(i, 0, width - 1);
sumR += row[xi * 3];
sumG += row[xi * 3 + 1];
sumB += row[xi * 3 + 2];
}
tmp[0] = sumR / diam;
tmp[1] = sumG / diam;
tmp[2] = sumB / diam;
for (int x = 1; x < width; x++) {
int addIdx = constrain(x + radius, 0, width - 1);
int remIdx = constrain(x - radius - 1, 0, width - 1);
sumR += row[addIdx * 3] - row[remIdx * 3];
sumG += row[addIdx * 3 + 1] - row[remIdx * 3 + 1];
sumB += row[addIdx * 3 + 2] - row[remIdx * 3 + 2];
tmp[x * 3] = sumR / diam;
tmp[x * 3 + 1] = sumG / diam;
tmp[x * 3 + 2] = sumB / diam;
}
memcpy(row, tmp, width * 3);
}
free(tmp);
}
// In-place vertical box blur on a rectangular sub-region.
static void boxBlurV(uint8_t* buf, uint16_t stride,
uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, int radius) {
int height = y1 - y0;
if (height <= 0) return;
uint8_t* tmp = (uint8_t*)malloc(height * 3);
if (tmp == nullptr) return;
int diam = radius * 2 + 1;
for (uint16_t x = x0; x < x1; x++) {
// Running sum initialization
int sumR = 0, sumG = 0, sumB = 0;
for (int i = -radius; i <= radius; i++) {
int yi = y0 + constrain(i, 0, height - 1);
size_t idx = ((size_t)yi * stride + x) * 3;
sumR += buf[idx];
sumG += buf[idx + 1];
sumB += buf[idx + 2];
}
tmp[0] = sumR / diam;
tmp[1] = sumG / diam;
tmp[2] = sumB / diam;
for (int y = 1; y < height; y++) {
int addY = y0 + constrain(y + radius, 0, height - 1);
int remY = y0 + constrain(y - radius - 1, 0, height - 1);
size_t addIdx = ((size_t)addY * stride + x) * 3;
size_t remIdx = ((size_t)remY * stride + x) * 3;
sumR += buf[addIdx] - buf[remIdx];
sumG += buf[addIdx + 1] - buf[remIdx + 1];
sumB += buf[addIdx + 2] - buf[remIdx + 2];
tmp[y * 3] = sumR / diam;
tmp[y * 3 + 1] = sumG / diam;
tmp[y * 3 + 2] = sumB / diam;
}
// Write back
for (int y = 0; y < height; y++) {
size_t idx = ((size_t)(y0 + y) * stride + x) * 3;
buf[idx] = tmp[y * 3];
buf[idx + 1] = tmp[y * 3 + 1];
buf[idx + 2] = tmp[y * 3 + 2];
}
}
free(tmp);
}
// 3-pass box blur (approximates Gaussian) on a sub-region
static void gaussianBlurRegion(uint8_t* buf, uint16_t stride,
uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1,
int radius) {
for (int pass = 0; pass < 3; pass++) {
boxBlurH(buf, stride, x0, y0, x1, y1, radius);
boxBlurV(buf, stride, x0, y0, x1, y1, radius);
}
}
// Fill letterbox bars with mirrored + blurred + faded content from the photo edge.
// The fitted image must already be placed in final_buf at (offsetX, offsetY).
static void fillLetterbox(uint8_t* final_buf, uint16_t targetW, uint16_t targetH,
const uint8_t* fitted, uint16_t fitW, uint16_t fitH,
uint16_t offsetX, uint16_t offsetY) {
static constexpr int BLUR_RADIUS = 6;
if (offsetY > 0) {
// Horizontal bars (top and bottom)
EdgeColor topEdge = averageEdge(fitted, fitW, fitH, 0);
EdgeColor botEdge = averageEdge(fitted, fitW, fitH, 1);
// --- Extend top edge into top bar ---
// Every row in the bar copies from the photo's topmost row (row 0)
for (uint16_t y = 0; y < offsetY; y++) {
for (uint16_t x = 0; x < targetW; x++) {
int srcX = (int)x - (int)offsetX;
srcX = constrain(srcX, 0, (int)fitW - 1);
size_t srcIdx = ((size_t)0 * fitW + srcX) * 3; // always row 0
size_t dstIdx = ((size_t)y * targetW + x) * 3;
final_buf[dstIdx] = fitted[srcIdx];
final_buf[dstIdx + 1] = fitted[srcIdx + 1];
final_buf[dstIdx + 2] = fitted[srcIdx + 2];
}
}
// --- Extend bottom edge into bottom bar ---
// Every row copies from the photo's bottommost row (fitH - 1)
uint16_t botStart = offsetY + fitH;
uint16_t lastRow = fitH - 1;
for (uint16_t y = botStart; y < targetH; y++) {
for (uint16_t x = 0; x < targetW; x++) {
int srcX = (int)x - (int)offsetX;
srcX = constrain(srcX, 0, (int)fitW - 1);
size_t srcIdx = ((size_t)lastRow * fitW + srcX) * 3;
size_t dstIdx = ((size_t)y * targetW + x) * 3;
final_buf[dstIdx] = fitted[srcIdx];
final_buf[dstIdx + 1] = fitted[srcIdx + 1];
final_buf[dstIdx + 2] = fitted[srcIdx + 2];
}
}
// --- Blur both bars ---
gaussianBlurRegion(final_buf, targetW, 0, 0, targetW, offsetY, BLUR_RADIUS);
gaussianBlurRegion(final_buf, targetW, 0, botStart, targetW, targetH, BLUR_RADIUS);
// --- Fade toward solid edge color ---
for (uint16_t y = 0; y < offsetY; y++) {
float alpha = (float)(offsetY - 1 - y) / (float)offsetY; // 0 at photo, 1 at screen edge
for (uint16_t x = 0; x < targetW; x++) {
size_t idx = ((size_t)y * targetW + x) * 3;
final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + topEdge.r * alpha);
final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + topEdge.g * alpha);
final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + topEdge.b * alpha);
}
}
for (uint16_t y = botStart; y < targetH; y++) {
float alpha = (float)(y - botStart) / (float)(targetH - botStart);
for (uint16_t x = 0; x < targetW; x++) {
size_t idx = ((size_t)y * targetW + x) * 3;
final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + botEdge.r * alpha);
final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + botEdge.g * alpha);
final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + botEdge.b * alpha);
}
}
Serial.printf("[pipeline] Letterbox TB: bars %dpx, top=#%02X%02X%02X bot=#%02X%02X%02X\n",
offsetY, topEdge.r, topEdge.g, topEdge.b, botEdge.r, botEdge.g, botEdge.b);
}
if (offsetX > 0) {
// Vertical bars (left and right)
EdgeColor leftEdge = averageEdge(fitted, fitW, fitH, 2);
EdgeColor rightEdge = averageEdge(fitted, fitW, fitH, 3);
// --- Extend left edge into left bar ---
// Every column in the bar copies from the photo's leftmost column (col 0)
for (uint16_t y = 0; y < targetH; y++) {
int srcY = (int)y - (int)offsetY;
srcY = constrain(srcY, 0, (int)fitH - 1);
size_t srcIdx = ((size_t)srcY * fitW + 0) * 3; // always column 0
for (uint16_t x = 0; x < offsetX; x++) {
size_t dstIdx = ((size_t)y * targetW + x) * 3;
final_buf[dstIdx] = fitted[srcIdx];
final_buf[dstIdx + 1] = fitted[srcIdx + 1];
final_buf[dstIdx + 2] = fitted[srcIdx + 2];
}
}
// --- Extend right edge into right bar ---
// Every column copies from the photo's rightmost column (fitW - 1)
uint16_t rightStart = offsetX + fitW;
uint16_t lastCol = fitW - 1;
for (uint16_t y = 0; y < targetH; y++) {
int srcY = (int)y - (int)offsetY;
srcY = constrain(srcY, 0, (int)fitH - 1);
size_t srcIdx = ((size_t)srcY * fitW + lastCol) * 3;
for (uint16_t x = rightStart; x < targetW; x++) {
size_t dstIdx = ((size_t)y * targetW + x) * 3;
final_buf[dstIdx] = fitted[srcIdx];
final_buf[dstIdx + 1] = fitted[srcIdx + 1];
final_buf[dstIdx + 2] = fitted[srcIdx + 2];
}
}
// --- Blur both bars ---
gaussianBlurRegion(final_buf, targetW, 0, 0, offsetX, targetH, BLUR_RADIUS);
gaussianBlurRegion(final_buf, targetW, rightStart, 0, targetW, targetH, BLUR_RADIUS);
// --- Fade toward solid edge color ---
for (uint16_t y = 0; y < targetH; y++) {
for (uint16_t x = 0; x < offsetX; x++) {
float alpha = (float)(offsetX - 1 - x) / (float)offsetX;
size_t idx = ((size_t)y * targetW + x) * 3;
final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + leftEdge.r * alpha);
final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + leftEdge.g * alpha);
final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + leftEdge.b * alpha);
}
for (uint16_t x = rightStart; x < targetW; x++) {
float alpha = (float)(x - rightStart) / (float)(targetW - rightStart);
size_t idx = ((size_t)y * targetW + x) * 3;
final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + rightEdge.r * alpha);
final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + rightEdge.g * alpha);
final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + rightEdge.b * alpha);
}
}
Serial.printf("[pipeline] Letterbox LR: bars %dpx, left=#%02X%02X%02X right=#%02X%02X%02X\n",
offsetX, leftEdge.r, leftEdge.g, leftEdge.b,
rightEdge.r, rightEdge.g, rightEdge.b);
}
}
// Bilinear resize from src (srcW x srcH) to dst (dstW x dstH)
static void bilinearResize(const uint8_t* src, uint16_t srcW, uint16_t srcH,
uint8_t* dst, uint16_t dstW, uint16_t dstH) {
if (dstW <= 1 || dstH <= 1 || srcW <= 1 || srcH <= 1) return;
float xRatio = (float)(srcW - 1) / (float)(dstW - 1);
float yRatio = (float)(srcH - 1) / (float)(dstH - 1);
for (uint16_t y = 0; y < dstH; y++) {
float srcY = y * yRatio;
uint16_t y0 = (uint16_t)srcY;
uint16_t y1 = min((uint16_t)(y0 + 1), (uint16_t)(srcH - 1));
float yFrac = srcY - y0;
for (uint16_t x = 0; x < dstW; x++) {
float srcX = x * xRatio;
uint16_t x0 = (uint16_t)srcX;
uint16_t x1 = min((uint16_t)(x0 + 1), (uint16_t)(srcW - 1));
float xFrac = srcX - x0;
for (int c = 0; c < 3; c++) {
float top = src[(y0 * srcW + x0) * 3 + c] * (1 - xFrac) +
src[(y0 * srcW + x1) * 3 + c] * xFrac;
float bot = src[(y1 * srcW + x0) * 3 + c] * (1 - xFrac) +
src[(y1 * srcW + x1) * 3 + c] * xFrac;
float val = top * (1 - yFrac) + bot * yFrac;
dst[(y * dstW + x) * 3 + c] = (uint8_t)(val + 0.5f);
}
}
}
}
uint8_t* ImagePipeline::decodeAndFit(uint8_t* data, size_t size,
uint16_t targetW, uint16_t targetH,
uint16_t* outW, uint16_t* outH) {
// Decode JPEG using stb_image (handles baseline, progressive, extended sequential)
int imgW, imgH, channels;
uint8_t* decoded = stbi_load_from_memory(data, (int)size, &imgW, &imgH, &channels, 3);
if (decoded == nullptr) {
Serial.printf("[pipeline] stbi decode failed: %s\n", stbi_failure_reason());
return nullptr;
}
Serial.printf("[pipeline] JPEG: %dx%d decoded (channels=%d)\n", imgW, imgH, channels);
// Fit-contain: scale to fit entirely within target dimensions
float fitScaleW = (float)targetW / (float)imgW;
float fitScaleH = (float)targetH / (float)imgH;
float fitScale = fminf(fitScaleW, fitScaleH);
uint16_t fitW = (uint16_t)(imgW * fitScale);
uint16_t fitH = (uint16_t)(imgH * fitScale);
Serial.printf("[pipeline] Fit: %dx%d -> %dx%d (scale %.2f)\n",
imgW, imgH, fitW, fitH, fitScale);
// Resize decoded image to fit dimensions
uint8_t* fitted = (uint8_t*)ps_malloc((size_t)fitW * fitH * 3);
if (fitted == nullptr) {
Serial.println("[pipeline] Fit alloc failed");
stbi_image_free(decoded);
return nullptr;
}
bilinearResize(decoded, (uint16_t)imgW, (uint16_t)imgH, fitted, fitW, fitH);
stbi_image_free(decoded);
// Allocate final target-sized buffer (zeroed)
uint8_t* final_buf = (uint8_t*)ps_calloc(targetW * targetH, 3);
if (final_buf == nullptr) {
Serial.println("[pipeline] Final buffer alloc failed");
free(fitted);
return nullptr;
}
// Center the fitted image into the final buffer
uint16_t offsetX = (targetW - fitW) / 2;
uint16_t offsetY = (targetH - fitH) / 2;
for (uint16_t y = 0; y < fitH; y++) {
memcpy(final_buf + ((size_t)(offsetY + y) * targetW + offsetX) * 3,
fitted + (size_t)y * fitW * 3,
fitW * 3);
}
// Fill letterbox bars — each bar matches the adjacent photo edge
fillLetterbox(final_buf, targetW, targetH, fitted, fitW, fitH, offsetX, offsetY);
free(fitted);
*outW = targetW;
*outH = targetH;
return final_buf;
}
ProcessedImage ImagePipeline::process(uint8_t* jpegData, size_t jpegSize) {
ProcessedImage result = {nullptr, DISPLAY_WIDTH, DISPLAY_HEIGHT, false};
uint16_t outW, outH;
uint8_t* rgb = decodeAndFit(jpegData, jpegSize, DISPLAY_WIDTH, DISPLAY_HEIGHT, &outW, &outH);
if (rgb == nullptr) {
Serial.println("[pipeline] Decode/fit failed");
return result;
}
Serial.printf("[pipeline] Got %dx%d fitted RGB\n", outW, outH);
size_t totalPixels = (size_t)outW * outH;
switch (_mode) {
case PipelineMode::DYNAMIC:
toneMap(rgb, totalPixels);
break;
case PipelineMode::BALANCED:
compressDynamicRange(rgb, totalPixels);
break;
case PipelineMode::NONE:
break;
// No default — compiler warns on unhandled PipelineMode via -Wswitch
}
uint8_t* dithered = ditherRowByRow(rgb, outW, outH);
free(rgb);
if (dithered == nullptr) {
return result;
}
result.framebuffer = dithered;
result.width = outW;
result.height = outH;
result.valid = true;
const char* modeName = (_mode == PipelineMode::DYNAMIC) ? "dynamic"
: (_mode == PipelineMode::BALANCED) ? "balanced"
: "none";
Serial.printf("[pipeline] Processing complete (%dx%d, mode=%s)\n",
outW, outH, modeName);
return result;
}
ProcessedImage ImagePipeline::processPortraitPair(uint8_t* jpeg1Data, size_t jpeg1Size,
uint8_t* jpeg2Data, size_t jpeg2Size) {
ProcessedImage result = {nullptr, DISPLAY_WIDTH, DISPLAY_HEIGHT, false};
uint16_t portraitW = (DISPLAY_WIDTH - PORTRAIT_GAP_PX) / 2;
uint16_t portraitH = DISPLAY_HEIGHT;
uint16_t out1W, out1H;
uint8_t* rgb1 = decodeAndFit(jpeg1Data, jpeg1Size, portraitW, portraitH, &out1W, &out1H);
uint16_t out2W, out2H;
uint8_t* rgb2 = decodeAndFit(jpeg2Data, jpeg2Size, portraitW, portraitH, &out2W, &out2H);
// Allocate combined RGB buffer
uint8_t* combined = (uint8_t*)ps_calloc(DISPLAY_WIDTH * DISPLAY_HEIGHT, 3);
if (combined == nullptr) {
if (rgb1) free(rgb1);
if (rgb2) free(rgb2);
return result;
}
if (rgb1 != nullptr) {
for (uint16_t y = 0; y < out1H; y++) {
memcpy(combined + y * DISPLAY_WIDTH * 3,
rgb1 + y * out1W * 3, out1W * 3);
}
free(rgb1);
}
if (rgb2 != nullptr) {
uint16_t offsetX = portraitW + PORTRAIT_GAP_PX;
for (uint16_t y = 0; y < out2H; y++) {
memcpy(combined + (y * DISPLAY_WIDTH + offsetX) * 3,
rgb2 + y * out2W * 3, out2W * 3);
}
free(rgb2);
}
size_t combinedPixels = (size_t)DISPLAY_WIDTH * DISPLAY_HEIGHT;
switch (_mode) {
case PipelineMode::DYNAMIC:
toneMap(combined, combinedPixels);
break;
case PipelineMode::BALANCED:
compressDynamicRange(combined, combinedPixels);
break;
case PipelineMode::NONE:
break;
// No default — compiler warns on unhandled PipelineMode via -Wswitch
}
uint8_t* dithered = ditherRowByRow(combined, DISPLAY_WIDTH, DISPLAY_HEIGHT);
free(combined);
if (dithered == nullptr) return result;
result.framebuffer = dithered;
result.width = DISPLAY_WIDTH;
result.height = DISPLAY_HEIGHT;
result.valid = true;
return result;
}
void ImagePipeline::freeImage(ProcessedImage& img) {
if (img.framebuffer) {
free(img.framebuffer);
img.framebuffer = nullptr;
}
img.valid = false;
}
uint8_t* ImagePipeline::ditherRowByRow(uint8_t* rgb, uint16_t width, uint16_t height) {
size_t pixelCount = (size_t)width * height;
uint8_t* output = (uint8_t*)ps_malloc(pixelCount);
if (output == nullptr) {
Serial.println("[pipeline] Dither output alloc failed");
return nullptr;
}
size_t rowBytes = (size_t)width * 3 * sizeof(int16_t);
int16_t* errCurrent = (int16_t*)ps_malloc(rowBytes);
int16_t* errNext = (int16_t*)ps_malloc(rowBytes);
if (errCurrent == nullptr || errNext == nullptr) {
Serial.println("[pipeline] Dither error buffer alloc failed");
free(output);
if (errCurrent) free(errCurrent);
if (errNext) free(errNext);
return nullptr;
}
// Initialize first row
for (uint16_t x = 0; x < width; x++) {
size_t srcIdx = x * 3;
size_t errIdx = x * 3;
errCurrent[errIdx] = rgb[srcIdx];
errCurrent[errIdx + 1] = rgb[srcIdx + 1];
errCurrent[errIdx + 2] = rgb[srcIdx + 2];
}
for (uint16_t y = 0; y < height; y++) {
// Prepare next row from source
if (y + 1 < height) {
size_t nextRowOffset = (size_t)(y + 1) * width * 3;
for (uint16_t x = 0; x < width; x++) {
size_t srcIdx = nextRowOffset + x * 3;
size_t errIdx = x * 3;
errNext[errIdx] = rgb[srcIdx];
errNext[errIdx + 1] = rgb[srcIdx + 1];
errNext[errIdx + 2] = rgb[srcIdx + 2];
}
} else {
memset(errNext, 0, rowBytes);
}
// Serpentine: alternate scan direction each row
bool forward = (y % 2 == 0);
int xStart = forward ? 0 : (int)width - 1;
int xEnd = forward ? (int)width : -1;
int xStep = forward ? 1 : -1;
for (int x = xStart; x != xEnd; x += xStep) {
size_t errIdx = (size_t)x * 3;
int r = constrain(errCurrent[errIdx], 0, 255);
int g = constrain(errCurrent[errIdx + 1], 0, 255);
int b = constrain(errCurrent[errIdx + 2], 0, 255);
uint8_t nearest = findNearest(r, g, b);
output[y * width + x] = nearest;
int errR = r - PALETTE_CALIBRATED[nearest][0];
int errG = g - PALETTE_CALIBRATED[nearest][1];
int errB = b - PALETTE_CALIBRATED[nearest][2];
// Floyd-Steinberg: mirror dx offsets on reverse rows
int xRight = forward ? x + 1 : x - 1;
int xLeft = forward ? x - 1 : x + 1;
// 7/16 to next pixel in scan direction
if (xRight >= 0 && xRight < (int)width) {
size_t ni = (size_t)xRight * 3;
errCurrent[ni] += errR * 7 / 16;
errCurrent[ni + 1] += errG * 7 / 16;
errCurrent[ni + 2] += errB * 7 / 16;
}
if (y + 1 < height) {
if (_blueNoise) {
// Randomized error scatter: the diagonal pattern is caused
// by the 5/16 "below" weight always landing error on the
// same column, creating vertical correlation that manifests
// as diagonal lines. We break this by randomly offsetting
// the entire below-row error target by -2..+2 pixels.
// Energy is perfectly conserved (same 9/16 total, same
// 3/5/1 ratio — just shifted horizontally).
uint32_t h = (uint32_t)x * 2654435761u
^ (uint32_t)y * 2246822519u;
int offset = (int)(h % 5u) - 2; // -2, -1, 0, +1, or +2
int xBL = xLeft + offset;
int xB = x + offset;
int xBR = xRight + offset;
// 3/16 to below-left (shifted)
if (xBL >= 0 && xBL < (int)width) {
size_t ni = (size_t)xBL * 3;
errNext[ni] += errR * 3 / 16;
errNext[ni + 1] += errG * 3 / 16;
errNext[ni + 2] += errB * 3 / 16;
}
// 5/16 to below (shifted)
if (xB >= 0 && xB < (int)width) {
size_t ni = (size_t)xB * 3;
errNext[ni] += errR * 5 / 16;
errNext[ni + 1] += errG * 5 / 16;
errNext[ni + 2] += errB * 5 / 16;
}
// 1/16 to below-right (shifted)
if (xBR >= 0 && xBR < (int)width) {
size_t ni = (size_t)xBR * 3;
errNext[ni] += errR * 1 / 16;
errNext[ni + 1] += errG * 1 / 16;
errNext[ni + 2] += errB * 1 / 16;
}
} else {
// Standard fixed Floyd-Steinberg weights
if (xLeft >= 0 && xLeft < (int)width) {
size_t ni = (size_t)xLeft * 3;
errNext[ni] += errR * 3 / 16;
errNext[ni + 1] += errG * 3 / 16;
errNext[ni + 2] += errB * 3 / 16;
}
{
size_t ni = (size_t)x * 3;
errNext[ni] += errR * 5 / 16;
errNext[ni + 1] += errG * 5 / 16;
errNext[ni + 2] += errB * 5 / 16;
}
if (xRight >= 0 && xRight < (int)width) {
size_t ni = (size_t)xRight * 3;
errNext[ni] += errR * 1 / 16;
errNext[ni + 1] += errG * 1 / 16;
errNext[ni + 2] += errB * 1 / 16;
}
}
}
}
int16_t* tmp = errCurrent;
errCurrent = errNext;
errNext = tmp;
}
free(errCurrent);
free(errNext);
return output;
}
uint8_t ImagePipeline::findNearest(int r, int g, int b) {
uint8_t best = 0;
int32_t bestDist = INT32_MAX;
for (int i = 0; i < DISPLAY_COLORS; i++) {
int dr = r - PALETTE_CALIBRATED[i][0];
int dg = g - PALETTE_CALIBRATED[i][1];
int db = b - PALETTE_CALIBRATED[i][2];
// Rec. 709 luminance-weighted distance (integer weights x10000)
int32_t dist = 2126 * dr * dr + 7152 * dg * dg + 722 * db * db;
if (dist < bestDist) {
bestDist = dist;
best = static_cast<uint8_t>(i);
}
}
return best;
}

51
src/image_pipeline.h Normal file
View File

@@ -0,0 +1,51 @@
#pragma once
#include <Arduino.h>
#include "config.h"
struct ProcessedImage {
uint8_t* framebuffer; // Palette indices, one byte per pixel
uint16_t width;
uint16_t height;
bool valid;
};
// Pipeline processing mode (epdoptimize recommends one or the other, not both)
enum class PipelineMode : uint8_t {
DYNAMIC, // S-curve tone mapping + saturation boost, no DRC
BALANCED, // Dynamic range compression only, no tone mapping
NONE // No pre-processing, straight to dither
};
class ImagePipeline {
public:
// Process single landscape photo (fit-contain with edge-color letterbox)
ProcessedImage process(uint8_t* jpegData, size_t jpegSize);
// Process portrait pair (two photos side by side)
ProcessedImage processPortraitPair(uint8_t* jpeg1Data, size_t jpeg1Size,
uint8_t* jpeg2Data, size_t jpeg2Size);
void freeImage(ProcessedImage& img);
void setPipelineMode(PipelineMode mode) { _mode = mode; }
PipelineMode getPipelineMode() const { return _mode; }
void setBlueNoiseEnabled(bool enabled) { _blueNoise = enabled; }
bool getBlueNoiseEnabled() const { return _blueNoise; }
private:
PipelineMode _mode = PipelineMode::DYNAMIC;
bool _blueNoise = true;
// Decode JPEG and fit into target dimensions with letterboxing
uint8_t* decodeAndFit(uint8_t* data, size_t size,
uint16_t targetW, uint16_t targetH,
uint16_t* outW, uint16_t* outH);
// Serpentine Floyd-Steinberg dither RGB888 to 6-color palette indices
uint8_t* ditherRowByRow(uint8_t* rgb, uint16_t width, uint16_t height);
// Find nearest palette color (Rec. 709 luminance-weighted RGB distance)
uint8_t findNearest(int r, int g, int b);
};

601
src/immich_client.cpp Normal file
View File

@@ -0,0 +1,601 @@
#include "immich_client.h"
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <esp_heap_caps.h>
void ImmichClient::begin(const String& baseUrl, const String& apiKey) {
_baseUrl = baseUrl;
// Remove trailing slash if present
if (_baseUrl.endsWith("/")) {
_baseUrl.remove(_baseUrl.length() - 1);
}
_apiKey = apiKey;
// Debug: show masked key so we can verify it's non-empty and correct prefix
String maskedKey = _apiKey.length() > 8
? _apiKey.substring(0, 8) + "..."
: (_apiKey.length() > 0 ? "***" : "(empty)");
Serial.printf("[immich] Configured: %s | key: %s (len=%d)\n",
_baseUrl.c_str(), maskedKey.c_str(), _apiKey.length());
}
std::vector<AlbumInfo> ImmichClient::fetchAlbums() {
std::vector<AlbumInfo> albums;
String url = buildUrl("/api/albums");
String response = httpGet(url);
if (response.isEmpty()) {
Serial.println("[immich] fetchAlbums: empty response");
return albums;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, response);
if (err) {
Serial.printf("[immich] fetchAlbums JSON error: %s\n", err.c_str());
return albums;
}
JsonArray arr = doc.as<JsonArray>();
for (JsonObject obj : arr) {
AlbumInfo album;
album.id = obj["id"].as<String>();
album.title = obj["albumName"].as<String>();
album.assetCount = obj["assetCount"] | 0;
albums.push_back(album);
}
Serial.printf("[immich] Fetched %d albums\n", albums.size());
return albums;
}
std::vector<String> ImmichClient::fetchAlbumAssetIds(const String& albumId) {
std::vector<String> ids;
String url = buildUrl("/api/albums/" + albumId);
// Album responses can be very large (full asset metadata per photo).
// Use PSRAM-backed fetch to avoid exhausting regular heap.
size_t responseLen = 0;
char* response = httpGetPsram(url, &responseLen);
if (response == nullptr || responseLen == 0) {
Serial.printf("[immich] Album %s: empty response\n", albumId.c_str());
if (response) free(response);
return ids;
}
Serial.printf("[immich] Album %s: response %d bytes\n", albumId.c_str(), responseLen);
JsonDocument doc;
DeserializationError err = deserializeJson(doc, response, responseLen);
free(response); // Free PSRAM buffer immediately after parsing
if (err) {
Serial.printf("[immich] fetchAlbumAssets JSON error: %s\n", err.c_str());
return ids;
}
JsonArray assets = doc["assets"].as<JsonArray>();
for (JsonObject asset : assets) {
String id = asset["id"].as<String>();
if (id.length() > 0) {
ids.push_back(id);
}
}
Serial.printf("[immich] Album %s: %d assets\n", albumId.c_str(), ids.size());
return ids;
}
std::vector<String> ImmichClient::fetchRandomAssetIds(int count,
const std::vector<String>& albumIds) {
std::vector<String> ids;
String url = buildUrl("/api/search/random");
JsonDocument reqDoc;
reqDoc["size"] = count;
reqDoc["type"] = "IMAGE";
if (!albumIds.empty()) {
JsonArray arr = reqDoc["albumIds"].to<JsonArray>();
for (auto& id : albumIds) arr.add(id);
}
String body;
serializeJson(reqDoc, body);
String response = httpPost(url, body);
if (response.isEmpty()) {
Serial.println("[immich] fetchRandom: empty response");
return ids;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, response);
if (err) {
Serial.printf("[immich] fetchRandom JSON error: %s\n", err.c_str());
return ids;
}
JsonArray arr = doc.as<JsonArray>();
int skippedRaw = 0;
for (JsonObject asset : arr) {
String id = asset["id"].as<String>();
if (id.length() == 0) continue;
String filename = asset["originalFileName"] | "";
filename.toLowerCase();
if (filename.endsWith(".dng") || filename.endsWith(".raw") ||
filename.endsWith(".cr2") || filename.endsWith(".cr3") ||
filename.endsWith(".nef") || filename.endsWith(".arw") ||
filename.endsWith(".orf") || filename.endsWith(".rw2") ||
filename.endsWith(".raf") || filename.endsWith(".srw")) {
skippedRaw++;
continue;
}
ids.push_back(id);
}
Serial.printf("[immich] Fetched %d random assets%s (skipped %d RAW)\n",
ids.size(), albumIds.empty() ? "" : " from albums", skippedRaw);
return ids;
}
std::vector<RandomAsset> ImmichClient::fetchRandomAssets(int count,
const std::vector<String>& albumIds) {
std::vector<RandomAsset> assets;
String url = buildUrl("/api/search/random");
JsonDocument reqDoc;
reqDoc["size"] = count;
reqDoc["type"] = "IMAGE";
if (!albumIds.empty()) {
JsonArray albumArr = reqDoc["albumIds"].to<JsonArray>();
for (auto& aid : albumIds) albumArr.add(aid);
}
String body;
serializeJson(reqDoc, body);
String response = httpPost(url, body);
if (response.isEmpty()) return assets;
JsonDocument doc;
DeserializationError err = deserializeJson(doc, response);
if (err) return assets;
JsonArray arr = doc.as<JsonArray>();
int skippedRaw = 0;
for (JsonObject asset : arr) {
String id = asset["id"].as<String>();
if (id.length() == 0) continue;
String filename = asset["originalFileName"] | "";
filename.toLowerCase();
if (filename.endsWith(".dng") || filename.endsWith(".raw") ||
filename.endsWith(".cr2") || filename.endsWith(".cr3") ||
filename.endsWith(".nef") || filename.endsWith(".arw") ||
filename.endsWith(".orf") || filename.endsWith(".rw2") ||
filename.endsWith(".raf") || filename.endsWith(".srw")) {
skippedRaw++;
continue;
}
RandomAsset ra;
ra.id = id;
ra.dateTime = asset["localDateTime"] | "";
assets.push_back(ra);
}
Serial.printf("[immich] Fetched %d random assets with dates%s (skipped %d RAW)\n",
assets.size(), albumIds.empty() ? "" : " from albums", skippedRaw);
return assets;
}
std::vector<String> ImmichClient::fetchFavoriteAssetIds() {
std::vector<String> ids;
String url = buildUrl("/api/assets?isFavorite=true");
String response = httpGet(url);
if (response.isEmpty()) return ids;
JsonDocument doc;
DeserializationError err = deserializeJson(doc, response);
if (err) return ids;
JsonArray arr = doc.as<JsonArray>();
for (JsonObject asset : arr) {
ids.push_back(asset["id"].as<String>());
}
Serial.printf("[immich] Favorites: %d assets\n", ids.size());
return ids;
}
String ImmichClient::fetchOneRandomAssetId(const RandomFetchFilter& filter) {
String url = buildUrl("/api/search/random");
JsonDocument reqDoc;
reqDoc["size"] = 1;
reqDoc["type"] = "IMAGE";
if (!filter.albumIds.empty()) {
JsonArray arr = reqDoc["albumIds"].to<JsonArray>();
for (auto& id : filter.albumIds) arr.add(id);
}
if (filter.favoriteOnly) {
reqDoc["isFavorite"] = true;
}
if (filter.takenAfter.length() > 0) {
reqDoc["takenAfter"] = filter.takenAfter;
}
if (filter.takenBefore.length() > 0) {
reqDoc["takenBefore"] = filter.takenBefore;
}
String body;
serializeJson(reqDoc, body);
Serial.printf("[immich] fetchOne: %s\n", body.c_str());
String response = httpPost(url, body);
if (response.isEmpty()) {
Serial.println("[immich] fetchOne: empty response");
return "";
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, response);
if (err) {
Serial.printf("[immich] fetchOne JSON error: %s\n", err.c_str());
return "";
}
JsonArray arr = doc.as<JsonArray>();
for (JsonObject asset : arr) {
String id = asset["id"].as<String>();
if (id.length() == 0) continue;
// Skip RAW files
String filename = asset["originalFileName"] | "";
filename.toLowerCase();
if (filename.endsWith(".dng") || filename.endsWith(".raw") ||
filename.endsWith(".cr2") || filename.endsWith(".cr3") ||
filename.endsWith(".nef") || filename.endsWith(".arw") ||
filename.endsWith(".orf") || filename.endsWith(".rw2") ||
filename.endsWith(".raf") || filename.endsWith(".srw")) {
continue;
}
return id;
}
Serial.println("[immich] fetchOne: no usable asset returned");
return "";
}
std::vector<String> ImmichClient::fetchFilteredBatch(const RandomFetchFilter& filter, int count) {
std::vector<String> results;
String url = buildUrl("/api/search/random");
JsonDocument reqDoc;
reqDoc["size"] = count;
reqDoc["type"] = "IMAGE";
if (!filter.albumIds.empty()) {
JsonArray arr = reqDoc["albumIds"].to<JsonArray>();
for (auto& id : filter.albumIds) arr.add(id);
}
if (filter.favoriteOnly) {
reqDoc["isFavorite"] = true;
}
if (filter.takenAfter.length() > 0) {
reqDoc["takenAfter"] = filter.takenAfter;
}
if (filter.takenBefore.length() > 0) {
reqDoc["takenBefore"] = filter.takenBefore;
}
String body;
serializeJson(reqDoc, body);
Serial.printf("[immich] fetchBatch(%d): %s\n", count, body.c_str());
String response = httpPost(url, body);
if (response.isEmpty()) {
Serial.println("[immich] fetchBatch: empty response");
return results;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, response);
if (err) {
Serial.printf("[immich] fetchBatch JSON error: %s\n", err.c_str());
return results;
}
JsonArray arr = doc.as<JsonArray>();
for (JsonObject asset : arr) {
String id = asset["id"].as<String>();
if (id.length() == 0) continue;
// Skip RAW files
String filename = asset["originalFileName"] | "";
filename.toLowerCase();
if (filename.endsWith(".dng") || filename.endsWith(".raw") ||
filename.endsWith(".cr2") || filename.endsWith(".cr3") ||
filename.endsWith(".nef") || filename.endsWith(".arw") ||
filename.endsWith(".orf") || filename.endsWith(".rw2") ||
filename.endsWith(".raf") || filename.endsWith(".srw")) {
continue;
}
results.push_back(id);
}
Serial.printf("[immich] fetchBatch: got %d usable assets\n", (int)results.size());
return results;
}
AssetInfo ImmichClient::fetchAssetInfo(const String& assetId) {
AssetInfo info;
info.id = assetId;
info.isFavorite = false;
info.isPortrait = false;
String url = buildUrl("/api/assets/" + assetId);
String response = httpGet(url);
if (response.isEmpty()) return info;
JsonDocument doc;
DeserializationError err = deserializeJson(doc, response);
if (err) return info;
info.originalFileName = doc["originalFileName"] | "";
info.isFavorite = doc["isFavorite"] | false;
// Date
info.dateTime = doc["localDateTime"] | "";
// EXIF data
JsonObject exif = doc["exifInfo"];
if (!exif.isNull()) {
info.city = exif["city"] | "";
String make = exif["make"] | "";
String model = exif["model"] | "";
if (make.length() > 0 || model.length() > 0) {
info.camera = make + " " + model;
info.camera.trim();
}
// Portrait detection: check orientation or dimensions
int width = exif["exifImageWidth"] | 0;
int height = exif["exifImageHeight"] | 0;
int orientation = exif["orientation"] | 1;
// Orientations 5-8 mean the image is rotated 90/270 degrees
if (orientation >= 5 && orientation <= 8) {
info.isPortrait = (width > height);
} else {
info.isPortrait = (height > width);
}
}
// People
JsonArray people = doc["people"];
if (!people.isNull()) {
for (JsonObject person : people) {
String name = person["name"] | "";
if (name.length() > 0) {
info.people.push_back(name);
}
}
}
return info;
}
bool ImmichClient::downloadAsset(const String& assetId, ImageQuality quality,
uint8_t** outBuffer, size_t* outSize) {
// Always request preview size with JPEG format.
// Server controls actual pixel dimensions via its image config (720px baseline).
// The "quality" setting is kept for future use but we always use preview for now
// to stay within PSRAM budget.
(void)quality;
String url = buildUrl("/api/assets/" + assetId + "/thumbnail?size=preview&format=jpeg");
if (!httpGetBinary(url, outBuffer, outSize)) {
return false;
}
// Verify JPEG magic bytes (FF D8 FF)
if (*outSize < 3 || (*outBuffer)[0] != 0xFF ||
(*outBuffer)[1] != 0xD8 || (*outBuffer)[2] != 0xFF) {
Serial.printf("[immich] Not JPEG data (magic: %02X %02X %02X)\n",
(*outBuffer)[0], (*outBuffer)[1], (*outBuffer)[2]);
free(*outBuffer);
*outBuffer = nullptr;
*outSize = 0;
return false;
}
return true;
}
String ImmichClient::buildUrl(const String& path) {
return _baseUrl + path;
}
String ImmichClient::httpGet(const String& url) {
WiFiClientSecure client;
client.setInsecure(); // Skip TLS cert verification for self-hosted
HTTPClient http;
http.begin(client, url);
http.addHeader("x-api-key", _apiKey);
http.setTimeout(30000);
int code = http.GET();
String result = "";
if (code == HTTP_CODE_OK) {
result = http.getString();
} else {
Serial.printf("[immich] HTTP GET %s failed: %d\n", url.c_str(), code);
}
http.end();
return result;
}
String ImmichClient::httpPost(const String& url, const String& body) {
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
http.begin(client, url);
http.addHeader("x-api-key", _apiKey);
http.addHeader("Content-Type", "application/json");
http.setTimeout(30000);
int code = http.POST(body);
String result = "";
if (code == HTTP_CODE_OK) {
result = http.getString();
} else {
Serial.printf("[immich] HTTP POST %s failed: %d\n", url.c_str(), code);
}
http.end();
return result;
}
char* ImmichClient::httpGetPsram(const String& url, size_t* outLen) {
*outLen = 0;
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
http.begin(client, url);
http.addHeader("x-api-key", _apiKey);
http.setTimeout(60000);
int code = http.GET();
if (code != HTTP_CODE_OK) {
Serial.printf("[immich] PSRAM GET %s failed: %d\n", url.c_str(), code);
http.end();
return nullptr;
}
int contentLength = http.getSize();
WiFiClient* stream = http.getStreamPtr();
// For chunked responses without Content-Length, read in chunks
if (contentLength <= 0) {
// Read incrementally into PSRAM, up to 4MB max
const size_t maxSize = 4 * 1024 * 1024;
size_t capacity = 64 * 1024;
char* buf = (char*)ps_malloc(capacity);
if (!buf) {
Serial.println("[immich] PSRAM alloc failed");
http.end();
return nullptr;
}
size_t total = 0;
while (stream->connected() || stream->available()) {
int avail = stream->available();
if (avail <= 0) { delay(1); continue; }
if (total + avail >= capacity) {
capacity = min(capacity * 2, maxSize);
char* newBuf = (char*)ps_realloc(buf, capacity);
if (!newBuf) { free(buf); http.end(); return nullptr; }
buf = newBuf;
}
int read = stream->readBytes(buf + total, avail);
total += read;
if (total >= maxSize) break;
}
buf[total] = '\0';
*outLen = total;
http.end();
return buf;
}
// Known content length — single PSRAM allocation
char* buf = (char*)ps_malloc(contentLength + 1);
if (!buf) {
Serial.printf("[immich] PSRAM alloc failed for %d bytes\n", contentLength);
http.end();
return nullptr;
}
size_t bytesRead = 0;
while (bytesRead < (size_t)contentLength && (stream->connected() || stream->available())) {
int avail = stream->available();
if (avail <= 0) { delay(1); continue; }
int read = stream->readBytes(buf + bytesRead, min(avail, (int)(contentLength - bytesRead)));
bytesRead += read;
}
buf[bytesRead] = '\0';
*outLen = bytesRead;
http.end();
return buf;
}
bool ImmichClient::httpGetBinary(const String& url, uint8_t** outBuffer, size_t* outSize) {
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
http.begin(client, url);
http.addHeader("x-api-key", _apiKey);
http.setTimeout(60000);
int code = http.GET();
if (code != HTTP_CODE_OK) {
Serial.printf("[immich] Binary GET failed: %d\n", code);
http.end();
return false;
}
int contentLength = http.getSize();
if (contentLength <= 0) {
Serial.println("[immich] Unknown content length");
http.end();
return false;
}
// Allocate in PSRAM
*outBuffer = (uint8_t*)ps_malloc(contentLength);
if (*outBuffer == nullptr) {
Serial.printf("[immich] Failed to allocate %d bytes in PSRAM\n", contentLength);
http.end();
return false;
}
WiFiClient* stream = http.getStreamPtr();
size_t bytesRead = 0;
while (bytesRead < (size_t)contentLength) {
size_t available = stream->available();
if (available > 0) {
size_t toRead = min(available, (size_t)(contentLength - bytesRead));
size_t read = stream->readBytes(*outBuffer + bytesRead, toRead);
bytesRead += read;
} else {
delay(1);
}
if (!http.connected() && bytesRead < (size_t)contentLength) {
Serial.println("[immich] Connection lost during download");
free(*outBuffer);
*outBuffer = nullptr;
http.end();
return false;
}
}
*outSize = bytesRead;
http.end();
Serial.printf("[immich] Downloaded %d bytes\n", bytesRead);
return true;
}

61
src/immich_client.h Normal file
View File

@@ -0,0 +1,61 @@
#pragma once
#include <Arduino.h>
#include <vector>
#include "settings.h"
struct AlbumInfo {
String id;
String title;
int assetCount;
};
struct AssetInfo {
String id;
String originalFileName;
String dateTime;
String city;
String camera;
bool isFavorite;
bool isPortrait;
std::vector<String> people;
};
struct RandomAsset {
String id;
String dateTime;
};
struct RandomFetchFilter {
std::vector<String> albumIds;
bool favoriteOnly = false;
String takenAfter; // ISO datetime, empty = no filter
String takenBefore; // ISO datetime, empty = no filter
};
class ImmichClient {
public:
void begin(const String& baseUrl, const String& apiKey);
std::vector<AlbumInfo> fetchAlbums();
std::vector<String> fetchAlbumAssetIds(const String& albumId);
std::vector<String> fetchRandomAssetIds(int count = 50,
const std::vector<String>& albumIds = {});
std::vector<RandomAsset> fetchRandomAssets(int count = 50,
const std::vector<String>& albumIds = {});
std::vector<String> fetchFavoriteAssetIds();
String fetchOneRandomAssetId(const RandomFetchFilter& filter);
std::vector<String> fetchFilteredBatch(const RandomFetchFilter& filter, int count);
AssetInfo fetchAssetInfo(const String& assetId);
bool downloadAsset(const String& assetId, ImageQuality quality,
uint8_t** outBuffer, size_t* outSize);
private:
String _baseUrl;
String _apiKey;
String buildUrl(const String& path);
String httpGet(const String& url);
String httpPost(const String& url, const String& body);
char* httpGetPsram(const String& url, size_t* outLen);
bool httpGetBinary(const String& url, uint8_t** outBuffer, size_t* outSize);
};

View File

@@ -1,26 +1,825 @@
#include <Arduino.h>
#include <M5Unified.h>
#include <ArduinoJson.h>
#include <WiFi.h>
#include <esp_heap_caps.h>
#include <sys/time.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/semphr.h>
#include "config.h"
#include "settings.h"
#include "wifi_manager.h"
#include "power_manager.h"
#include "button_handler.h"
#include "immich_client.h"
#include "photo_queue.h"
#include "image_pipeline.h"
#include "display_manager.h"
#include "web_server.h"
#include "time_utils.h"
#include "wake_log.h"
// Global instances
SettingsManager settingsManager;
WiFiManager wifiManager;
PowerManager powerManager;
ButtonHandler buttonHandler;
ImmichClient immichClient;
PhotoQueue photoQueue;
ImagePipeline imagePipeline;
DisplayManager displayManager;
AppWebServer webServer;
// Pre-rendered photo ready for instant display on next slideshow tick
struct PendingPhoto {
ProcessedImage img;
AssetInfo info;
AssetInfo info2;
bool isPair;
bool ready;
uint32_t settingsHash;
};
static uint32_t computeSettingsHash(const Settings& s) {
return (uint32_t)s.pipeline_mode
| ((uint32_t)s.dither_noise << 8)
| ((uint32_t)s.img_quality << 16);
}
// Shared state
SemaphoreHandle_t stateMutex;
volatile bool slideshowPlaying = true;
volatile bool refreshRequested = false;
volatile bool randomRequested = false;
volatile unsigned long lastRefreshTime = 0;
PendingPhoto pending = {{nullptr, 0, 0, false}, {}, {}, false, false, 0};
// Task handles
TaskHandle_t displayTaskHandle = nullptr;
// Display a ProcessedImage with metadata overlays, trigger EPD refresh, and free the framebuffer.
// Callers provide the image, metadata info, and current settings. Returns true if displayed.
static bool displayPhoto(ProcessedImage& img, const AssetInfo& info, const AssetInfo& info2,
bool isPair, const Settings& s) {
if (!img.valid) return false;
if (isPair) {
uint16_t halfW = (DISPLAY_WIDTH - PORTRAIT_GAP_PX) / 2;
uint16_t rightX = halfW + PORTRAIT_GAP_PX;
displayManager.showImage(img);
if (s.meta_flags != 0) {
displayManager.showMetadataInRegion(
info, s.meta_flags, s.meta_pos,
0, 0, halfW, DISPLAY_HEIGHT,
s.date_fmt, s.time_fmt);
displayManager.showMetadataInRegion(
info2, s.meta_flags, s.meta_pos,
rightX, 0, halfW, DISPLAY_HEIGHT,
s.date_fmt, s.time_fmt);
}
} else {
displayManager.showImage(img);
if (s.meta_flags != 0) {
displayManager.showMetadata(info, s.meta_flags, s.meta_pos,
s.date_fmt, s.time_fmt);
}
}
if (s.show_battery) {
uint8_t battPct = powerManager.getBatteryPercent();
displayManager.showBatteryIndicator(battPct, s.meta_pos);
}
displayManager.refresh();
imagePipeline.freeImage(img);
return true;
}
// Fetch, download, decode, and dither one photo (or portrait pair) from the queue.
// On success, fills out the PendingPhoto fields and returns true.
// On failure after MAX_RETRIES attempts, returns false with pending.ready untouched.
static bool prepareNextPhoto(PendingPhoto& out, const Settings& s, bool wantsRandom) {
static constexpr int MAX_RETRIES = 10;
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
String assetId = wantsRandom ? photoQueue.random() : photoQueue.next();
if (assetId.length() == 0) break;
Serial.printf("[prepare] Loading asset: %s (attempt %d)\n",
assetId.c_str(), attempt + 1);
AssetInfo info = immichClient.fetchAssetInfo(assetId);
uint8_t* jpegBuf = nullptr;
size_t jpegSize = 0;
if (!immichClient.downloadAsset(assetId, s.img_quality, &jpegBuf, &jpegSize)
|| jpegBuf == nullptr) {
Serial.println("[prepare] Download failed, trying next");
continue;
}
Serial.printf("[prepare] JPEG: %u KB, PSRAM free: %u KB\n",
(unsigned)(jpegSize / 1024),
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
ProcessedImage img = {nullptr, 0, 0, false};
AssetInfo info2 = {};
bool isPair = false;
if (info.isPortrait) {
Serial.println("[prepare] Portrait detected, searching for pair");
String pairId = photoQueue.findNextPortrait();
if (pairId.length() > 0) {
info2 = immichClient.fetchAssetInfo(pairId);
uint8_t* jpeg2Buf = nullptr;
size_t jpeg2Size = 0;
bool dl2 = immichClient.downloadAsset(
pairId, s.img_quality, &jpeg2Buf, &jpeg2Size);
if (dl2 && jpeg2Buf != nullptr) {
Serial.printf("[prepare] Portrait pair: %u KB + %u KB\n",
(unsigned)(jpegSize / 1024),
(unsigned)(jpeg2Size / 1024));
img = imagePipeline.processPortraitPair(
jpegBuf, jpegSize, jpeg2Buf, jpeg2Size);
free(jpeg2Buf);
isPair = img.valid;
if (!img.valid) {
Serial.println("[prepare] Pair processing failed, showing solo");
img = imagePipeline.process(jpegBuf, jpegSize);
}
} else {
Serial.println("[prepare] Pair download failed, showing solo");
img = imagePipeline.process(jpegBuf, jpegSize);
}
} else {
img = imagePipeline.process(jpegBuf, jpegSize);
}
} else {
img = imagePipeline.process(jpegBuf, jpegSize);
}
free(jpegBuf);
Serial.printf("[prepare] Post-process PSRAM free: %u KB\n",
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
if (img.valid) {
out.img = img;
out.info = info;
out.info2 = info2;
out.isPair = isPair;
out.settingsHash = computeSettingsHash(s);
out.ready = true;
return true;
}
Serial.println("[prepare] Processing failed, trying next");
}
return false;
}
// Discard a pre-rendered pending photo, freeing its framebuffer.
static void discardPending(PendingPhoto& p) {
if (p.ready && p.img.valid) {
Serial.println("[prerender] Discarding stale pending image");
imagePipeline.freeImage(p.img);
}
p.ready = false;
}
void displayTask(void* param) {
Serial.println("[display_task] Started on Core 1");
// Initial sync
if (wifiManager.isConnected()) {
Settings s = settingsManager.get();
immichClient.begin(s.immich_url, s.immich_key);
photoQueue.begin(immichClient, settingsManager);
if (photoQueue.sync()) {
Serial.printf("[display_task] Queue ready: %d photos\n", photoQueue.size());
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
refreshRequested = true;
xSemaphoreGive(stateMutex);
}
} else {
displayManager.showMessage("No Photos", "Select albums in web UI");
}
}
while (true) {
unsigned long now = millis();
Settings s = settingsManager.get();
displayManager.setTranslucent(s.meta_translucent != 0);
unsigned long intervalMs = s.interval_min * 60000UL;
bool shouldRefresh = false;
bool wantsRandom = false;
bool isManual = false;
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
if (refreshRequested) {
shouldRefresh = true;
isManual = true;
refreshRequested = false;
} else if (randomRequested) {
shouldRefresh = true;
wantsRandom = true;
isManual = true;
randomRequested = false;
} else if (slideshowPlaying && (now - lastRefreshTime >= intervalMs)) {
shouldRefresh = true;
}
xSemaphoreGive(stateMutex);
}
// Periodic re-sync (includes album change requests)
if (photoQueue.needsResync() && wifiManager.isConnected()) {
photoQueue.sync();
discardPending(pending);
}
// --- Phase 1: Display ---
if (shouldRefresh && photoQueue.size() > 0 && wifiManager.isConnected()) {
bool displayed = false;
uint32_t currentHash = computeSettingsHash(s);
if (!isManual && pending.ready && pending.settingsHash == currentHash) {
// Use pre-rendered image — instant display
Serial.println("[display_task] Using pre-rendered image");
displayed = displayPhoto(pending.img, pending.info, pending.info2,
pending.isPair, s);
pending.ready = false;
} else {
// Manual request, stale settings, or no pre-render available — inline pipeline
if (pending.ready) {
if (isManual) {
Serial.println("[display_task] Manual request — discarding pre-render");
} else {
Serial.printf("[display_task] Settings changed (0x%08X -> 0x%08X) — discarding pre-render\n",
pending.settingsHash, currentHash);
}
discardPending(pending);
} else {
Serial.println("[display_task] No pre-render available — inline fallback");
}
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
PendingPhoto inlinePhoto = {{nullptr, 0, 0, false}, {}, {}, false, false, 0};
if (prepareNextPhoto(inlinePhoto, s, wantsRandom)) {
displayed = displayPhoto(inlinePhoto.img, inlinePhoto.info,
inlinePhoto.info2, inlinePhoto.isPair, s);
}
}
// Log display result (normal mode)
if (displayed) {
wakeLogAppend(powerManager.getBatteryPercent(), WiFi.RSSI(), 0,
photoQueue.current(), "", "n/a",
WakeLogResult::Ok, false);
}
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
lastRefreshTime = millis();
xSemaphoreGive(stateMutex);
}
// --- Phase 2: Pre-render next photo ---
if (displayed && photoQueue.size() > 0 && wifiManager.isConnected()) {
bool playing = false;
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
playing = slideshowPlaying;
xSemaphoreGive(stateMutex);
}
if (playing) {
Serial.println("[prerender] Starting pre-render of next photo");
Settings preS = settingsManager.get();
imagePipeline.setPipelineMode(static_cast<PipelineMode>(preS.pipeline_mode));
imagePipeline.setBlueNoiseEnabled(preS.dither_noise != 0);
if (prepareNextPhoto(pending, preS, false)) {
Serial.printf("[prerender] Pre-render complete, PSRAM free: %u KB\n",
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
} else {
Serial.println("[prerender] Pre-render failed — will fall back to inline");
}
}
}
}
// --- Phase 2 (idle): Pre-render if we don't have one yet ---
if (!pending.ready && photoQueue.size() > 0 && wifiManager.isConnected()) {
bool playing = false;
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
playing = slideshowPlaying;
xSemaphoreGive(stateMutex);
}
if (playing) {
Serial.println("[prerender] No pending image — pre-rendering now");
Settings preS = settingsManager.get();
if (prepareNextPhoto(pending, preS, false)) {
Serial.printf("[prerender] Pre-render complete, PSRAM free: %u KB\n",
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
} else {
Serial.println("[prerender] Pre-render failed — will retry next loop");
}
}
}
// Yield — check every second
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void webActionCallback(const String& action) {
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
if (action == "next") {
refreshRequested = true;
} else if (action == "random") {
randomRequested = true;
} else if (action == "pause") {
slideshowPlaying = false;
} else if (action == "play") {
slideshowPlaying = true;
} else if (action == "albums_changed") {
photoQueue.requestSync();
refreshRequested = true;
}
xSemaphoreGive(stateMutex);
}
}
// Build a RandomFetchFilter based on the current cycle mode and probability.
// Returns a filter with appropriate date/favorite constraints.
static RandomFetchFilter buildTimerFilter(const Settings& s,
const std::vector<String>& albumIds) {
RandomFetchFilter filter;
filter.albumIds = albumIds;
// If system clock isn't valid, weighted/chrono modes can't work — use plain random
if (!hasValidTime()) {
Serial.println("[timer_wake] No valid time — using unfiltered random");
return filter;
}
// Probability roll: 0-99
int roll = random(0, 100);
switch (s.cycle_mode) {
case CycleMode::Random:
// No extra filters
break;
case CycleMode::Chronological:
if (s.timer_last_date.length() > 0) {
filter.takenAfter = s.timer_last_date;
} else {
filter.takenAfter = "1970-01-01T00:00:00.000Z";
}
break;
case CycleMode::ReverseChronological:
if (s.timer_last_date.length() > 0) {
filter.takenBefore = s.timer_last_date;
} else {
filter.takenBefore = isoNow();
}
break;
case CycleMode::FavoritesWeighted:
// 66% chance: favorites only, 33% chance: no filter
if (roll < 66) {
filter.favoriteOnly = true;
}
break;
case CycleMode::WeightedChronological:
// 66% chance: recent photos (last 30 days), 33% chance: no filter
if (roll < 66) {
filter.takenAfter = isoNowMinus30d();
}
break;
case CycleMode::WeightedReverseChronological:
// 66% chance: older photos (before 30 days ago), 33% chance: no filter
if (roll < 66) {
filter.takenBefore = isoNowMinus30d();
}
break;
default: {
CycleMode unreachable = s.cycle_mode;
(void)unreachable;
break;
}
}
return filter;
}
// Derive a human-readable filter name from the cycle mode and filter state.
static String filterName(CycleMode mode, bool relaxed) {
if (relaxed) return "relaxed";
switch (mode) {
case CycleMode::Random: return "random";
case CycleMode::Chronological: return "chrono";
case CycleMode::ReverseChronological: return "rev_chrono";
case CycleMode::FavoritesWeighted: return "fav";
case CycleMode::WeightedChronological: return "recent";
case CycleMode::WeightedReverseChronological: return "oldest";
default: {
CycleMode unreachable = mode;
(void)unreachable;
return "unknown";
}
}
}
// Compute a simple hash of settings that affect the batch cache validity.
static uint32_t timerCacheHash(const Settings& s) {
uint32_t h = 5381;
h = h * 33 + static_cast<uint8_t>(s.cycle_mode);
h = h * 33 + s.timer_batch_size;
for (size_t i = 0; i < s.albums_json.length(); i++) {
h = h * 33 + (uint8_t)s.albums_json[i];
}
return h;
}
// Parse NVS cache string: "<hash>:<id1>,<id2>,..."
// Returns empty vector if hash doesn't match or string is empty/invalid.
static std::vector<String> parseCacheIds(const String& raw, uint32_t expectedHash) {
std::vector<String> ids;
int colonIdx = raw.indexOf(':');
if (colonIdx < 1) return ids;
uint32_t storedHash = (uint32_t)strtoul(raw.substring(0, colonIdx).c_str(), nullptr, 10);
if (storedHash != expectedHash) {
Serial.println("[timer_wake] Cache hash mismatch — invalidating");
return ids;
}
String idsPart = raw.substring(colonIdx + 1);
if (idsPart.length() == 0) return ids;
int start = 0;
while (start < (int)idsPart.length()) {
int commaIdx = idsPart.indexOf(',', start);
if (commaIdx < 0) commaIdx = idsPart.length();
String id = idsPart.substring(start, commaIdx);
if (id.length() > 0) ids.push_back(id);
start = commaIdx + 1;
}
return ids;
}
// Serialize cache IDs back to NVS format: "<hash>:<id1>,<id2>,..."
static String buildCacheString(uint32_t hash, const std::vector<String>& ids) {
String result = String(hash) + ":";
for (size_t i = 0; i < ids.size(); i++) {
if (i > 0) result += ',';
result += ids[i];
}
return result;
}
// Minimal boot path for RTC timer wake: fetch one photo, display, sleep again
void timerWakeCycle() {
Serial.println("[timer_wake] Starting minimal wake cycle");
// Initialize wake log early (mounts LittleFS, increments boot counter)
wakeLogBegin();
Settings s = settingsManager.get();
// Connect WiFi (minimal — skip NTP/mDNS to save battery)
unsigned long wifiStart = millis();
wifiManager.begin(settingsManager, true);
uint32_t wifiMs = millis() - wifiStart;
if (!wifiManager.isConnected()) {
Serial.println("[timer_wake] WiFi failed — going back to sleep");
wakeLogAppend(powerManager.getBatteryPercent(), 0, wifiMs,
"", "", "n/a", WakeLogResult::FailFetch, true);
powerManager.enterDeepSleep(s.interval_min);
return;
}
// Initialize Immich client and apply pipeline/display modes
immichClient.begin(s.immich_url, s.immich_key);
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
displayManager.setTranslucent(s.meta_translucent != 0);
// Parse selected album IDs from settings
std::vector<String> albumIds;
{
JsonDocument doc;
DeserializationError err = deserializeJson(doc, s.albums_json);
if (!err) {
JsonArray arr = doc.as<JsonArray>();
for (JsonVariant v : arr) {
albumIds.push_back(v.as<String>());
}
}
}
// --- Batch cache logic ---
uint32_t cacheHash = timerCacheHash(s);
String cacheRaw = settingsManager.readField("timer_ids", "");
std::vector<String> cachedIds = parseCacheIds(cacheRaw, cacheHash);
bool cacheWasEmpty = cachedIds.empty();
bool filterRelaxed = false;
// If cache is empty, refill it with a batch fetch
if (cachedIds.empty()) {
Serial.printf("[timer_wake] Cache empty — fetching batch of %d\n", s.timer_batch_size);
static constexpr int BATCH_FETCH_RETRIES = 3;
for (int attempt = 0; attempt < BATCH_FETCH_RETRIES && cachedIds.empty(); attempt++) {
RandomFetchFilter filter;
if (filterRelaxed) {
filter.albumIds = albumIds;
} else {
filter = buildTimerFilter(s, albumIds);
}
cachedIds = immichClient.fetchFilteredBatch(filter, s.timer_batch_size);
if (cachedIds.empty()) {
if (!filterRelaxed && (filter.favoriteOnly ||
filter.takenAfter.length() > 0 || filter.takenBefore.length() > 0)) {
Serial.println("[timer_wake] Filtered batch empty — relaxing filter");
filterRelaxed = true;
if (s.cycle_mode == CycleMode::Chronological ||
s.cycle_mode == CycleMode::ReverseChronological) {
settingsManager.saveField("timer_date", "");
s.timer_last_date = "";
}
continue;
}
Serial.printf("[timer_wake] Batch fetch failed (attempt %d)\n", attempt + 1);
}
}
if (cachedIds.empty()) {
// All retries exhausted — log failure and sleep
Serial.println("[timer_wake] Batch fetch failed — no assets available");
wakeLogAppend(powerManager.getBatteryPercent(), WiFi.RSSI(), wifiMs,
"", "", filterName(s.cycle_mode, filterRelaxed),
WakeLogResult::FailFetch, true);
powerManager.enterDeepSleep(s.interval_min);
return;
}
Serial.printf("[timer_wake] Batch fetched %d IDs\n", (int)cachedIds.size());
} else {
Serial.printf("[timer_wake] Cache hit — %d IDs remaining\n", (int)cachedIds.size());
}
// Pop the first ID from cache
String assetId = cachedIds[0];
cachedIds.erase(cachedIds.begin());
// Save updated cache (remaining IDs) back to NVS
if (cachedIds.empty()) {
settingsManager.saveField("timer_ids", "");
} else {
settingsManager.saveField("timer_ids", buildCacheString(cacheHash, cachedIds).c_str());
}
// --- Download and display ---
static constexpr int DISPLAY_RETRIES = 3;
bool displayed = false;
String lastPhotoDate = "";
WakeLogResult lastResult = WakeLogResult::FailDownload;
for (int attempt = 0; attempt < DISPLAY_RETRIES && !displayed; attempt++) {
AssetInfo info = immichClient.fetchAssetInfo(assetId);
lastPhotoDate = info.dateTime;
uint8_t* jpegBuf = nullptr;
size_t jpegSize = 0;
if (!immichClient.downloadAsset(assetId, s.img_quality, &jpegBuf, &jpegSize)) {
Serial.printf("[timer_wake] Download failed (attempt %d)\n", attempt + 1);
lastResult = WakeLogResult::FailDownload;
continue;
}
ProcessedImage img = imagePipeline.process(jpegBuf, jpegSize);
free(jpegBuf);
if (img.valid) {
displayManager.showImage(img);
if (s.meta_flags != 0) {
displayManager.showMetadata(info, s.meta_flags, s.meta_pos,
s.date_fmt, s.time_fmt);
}
if (s.show_battery) {
uint8_t battPct = powerManager.getBatteryPercent();
displayManager.showBatteryIndicator(battPct, s.meta_pos);
}
displayManager.showSleepIndicator(s.meta_pos);
displayManager.refresh();
imagePipeline.freeImage(img);
displayed = true;
lastResult = WakeLogResult::Ok;
// Update chronological cursor with this photo's date
if (info.dateTime.length() > 0) {
settingsManager.saveField("timer_date", info.dateTime.c_str());
}
} else {
Serial.println("[timer_wake] Processing failed, retrying");
lastResult = WakeLogResult::FailProcess;
}
}
// Log this wake cycle
String logFilter = cacheWasEmpty
? filterName(s.cycle_mode, filterRelaxed)
: String("cached");
wakeLogAppend(powerManager.getBatteryPercent(), WiFi.RSSI(), wifiMs,
assetId, lastPhotoDate, logFilter, lastResult, true);
if (!displayed) {
Serial.println("[timer_wake] Failed to display photo");
}
Serial.printf("[timer_wake] Cycle complete — sleeping %u min\n", s.interval_min);
powerManager.enterDeepSleep(s.interval_min);
}
void setup() {
auto cfg = M5.config();
cfg.clear_display = false; // Don't clear/refresh e-ink on init
M5.begin(cfg);
// Prevent any automatic e-ink refreshes until we explicitly call display()
M5.Display.setAutoDisplay(false);
Serial.begin(115200);
Serial.println("[main] Immich Frame booting...");
Serial.println("[main] Immich Frame v1.0 booting...");
Serial.printf("[main] PSRAM: %u KB free / %u KB total\n",
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024),
(unsigned)(heap_caps_get_total_size(MALLOC_CAP_SPIRAM) / 1024));
Serial.printf("[main] Internal RAM: %u KB free\n",
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024));
// Disable audio permanently
pinMode(PIN_AUDIO_PWR_EN, OUTPUT);
digitalWrite(PIN_AUDIO_PWR_EN, LOW);
pinMode(PIN_SPK_EN, OUTPUT);
digitalWrite(PIN_SPK_EN, LOW);
// Seed system clock from hardware RTC (RX8130CE) for immediate time availability
{
auto rtcDt = M5.Rtc.getDateTime();
struct tm t = {};
t.tm_year = rtcDt.date.year - 1900;
t.tm_mon = rtcDt.date.month - 1;
t.tm_mday = rtcDt.date.date;
t.tm_hour = rtcDt.time.hours;
t.tm_min = rtcDt.time.minutes;
t.tm_sec = rtcDt.time.seconds;
time_t rtcTime = mktime(&t);
if (rtcTime > 1600000000) { // Plausible (post-2020)
struct timeval tv = { .tv_sec = rtcTime, .tv_usec = 0 };
settimeofday(&tv, nullptr);
Serial.printf("[main] System clock seeded from RTC: %04d-%02d-%02d %02d:%02d:%02d\n",
rtcDt.date.year, rtcDt.date.month, rtcDt.date.date,
rtcDt.time.hours, rtcDt.time.minutes, rtcDt.time.seconds);
} else {
Serial.println("[main] RTC has no valid time — will sync from NTP after WiFi");
}
}
Serial.printf("[main] Free heap: %d, PSRAM: %d\n",
ESP.getFreeHeap(), ESP.getFreePsram());
Serial.println("[main] Boot complete (skeleton)");
// Initialize core subsystems needed for both wake paths
powerManager.begin();
settingsManager.begin();
displayManager.begin(powerManager);
// Check if we woke from a timed deep sleep (persisted flag in NVS)
bool timerWoke = PowerManager::wasTimedSleepWake();
if (timerWoke) {
Serial.println("[main] Woke from timed deep sleep");
// Escape hatch: hold play/pause button during boot to stay awake
pinMode(PIN_BTN_DOWN, INPUT_PULLUP);
delay(50);
bool pauseHeld = (digitalRead(PIN_BTN_DOWN) == LOW);
if (!pauseHeld) {
// Minimal cycle: show photo and go back to sleep
timerWakeCycle();
// timerWakeCycle() calls enterDeepSleep — should not return
return;
}
// Escape hatch triggered — clear the flag and enter full interactive mode
PowerManager::clearTimedSleepFlag();
Serial.println("[main] Play/pause held — entering full interactive mode");
}
// Full interactive boot path
// Create state mutex
stateMutex = xSemaphoreCreateMutex();
// Initialize remaining subsystems
wifiManager.begin(settingsManager);
buttonHandler.begin(powerManager, settingsManager);
if (!wifiManager.isAPMode()) {
// Station mode — set up Immich and web server
Settings s = settingsManager.get();
immichClient.begin(s.immich_url, s.immich_key);
photoQueue.begin(immichClient, settingsManager);
}
// Start web server (works in both AP and station modes)
webServer.begin(settingsManager, immichClient, powerManager, wifiManager);
webServer.setActionCallback(webActionCallback);
// Initialize wake log (LittleFS already mounted by web server)
wakeLogBegin();
// Single display update after all init is complete
if (wifiManager.isAPMode()) {
displayManager.showSetupScreen();
} else {
// Skip "Loading photos" screen — the display task will show the first photo
// directly, avoiding a redundant 17s e-ink refresh cycle on boot.
// Create display task on Core 1
xTaskCreatePinnedToCore(displayTask, "display", 32768, nullptr, 1,
&displayTaskHandle, 1);
// Enable light sleep only in station mode (not supported in AP mode)
powerManager.enableLightSleep();
}
Serial.println("[main] Boot complete");
}
void loop() {
M5.update();
delay(1000);
powerManager.updateBatteryLED();
// Handle button events
ButtonEvent event = buttonHandler.poll();
switch (event) {
case ButtonEvent::NextPhoto:
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
refreshRequested = true;
xSemaphoreGive(stateMutex);
}
break;
case ButtonEvent::RandomPhoto:
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
randomRequested = true;
xSemaphoreGive(stateMutex);
}
break;
case ButtonEvent::PlayPause:
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
slideshowPlaying = !slideshowPlaying;
Serial.printf("[main] Slideshow: %s\n", slideshowPlaying ? "playing" : "paused");
xSemaphoreGive(stateMutex);
}
break;
case ButtonEvent::DeepSleep: {
Serial.println("[main] Entering timed deep sleep slideshow...");
Settings sleepS = settingsManager.get();
displayManager.showSleepIndicator(sleepS.meta_pos);
if (sleepS.show_battery) {
uint8_t battPct = powerManager.getBatteryPercent();
displayManager.showBatteryIndicator(battPct, sleepS.meta_pos);
}
displayManager.refresh();
powerManager.enterDeepSleep(sleepS.interval_min);
break;
}
case ButtonEvent::FactoryReset:
break;
case ButtonEvent::None:
break;
default: {
ButtonEvent unreachable = event;
(void)unreachable;
break;
}
}
delay(10);
}

388
src/photo_queue.cpp Normal file
View File

@@ -0,0 +1,388 @@
#include "photo_queue.h"
#include "immich_client.h"
#include <ArduinoJson.h>
#include <algorithm>
#include "config.h"
void PhotoQueue::begin(ImmichClient& client, SettingsManager& settings) {
_client = &client;
_settings = &settings;
Settings s = _settings->get();
_cursor = s.queue_cursor;
}
bool PhotoQueue::sync() {
if (_client == nullptr || _settings == nullptr) return false;
_lastSyncAttempt = millis();
_syncRequested = false;
Settings s = _settings->get();
auto albumIds = getSelectedAlbumIds();
bool success;
if (!albumIds.empty()) {
success = syncFromAlbums(albumIds, s);
} else {
success = syncFromAllPhotos(s);
}
if (!success) {
_syncRetryDelay = min(_syncRetryDelay * 2, SYNC_RETRY_MAX);
Serial.printf("[queue] Sync failed, retry in %lus\n", _syncRetryDelay / 1000);
return false;
}
_cursor = 0;
_lastSyncTime = millis();
_syncRetryDelay = SYNC_RETRY_MIN;
Serial.printf("[queue] Synced: %d assets, album_mode=%d, cursor at %d\n",
_queue.size(), _albumMode, _cursor);
return true;
}
bool PhotoQueue::syncFromAlbums(const std::vector<String>& albumIds, const Settings& s) {
Serial.printf("[queue] Syncing from %d selected album(s)\n", albumIds.size());
// Get current album counts for 24h resync change detection
auto albums = _client->fetchAlbums();
std::map<String, int> albumCounts;
for (auto& selId : albumIds) {
for (auto& album : albums) {
if (album.id == selId) {
albumCounts[selId] = album.assetCount;
break;
}
}
}
// Use POST /api/search/random with albumIds filter (Immich v3 compatible).
// The server returns up to `size` random assets from the specified albums.
// For small albums (< TARGET_QUEUE_SIZE), all assets are returned.
auto batch = _client->fetchRandomAssets(
static_cast<int>(TARGET_QUEUE_SIZE), albumIds);
if (batch.empty()) {
Serial.println("[queue] No assets found in selected albums");
return false;
}
std::vector<String> newIds;
std::vector<String> newDates;
for (auto& asset : batch) {
if (std::find(newIds.begin(), newIds.end(), asset.id) != newIds.end()) continue;
newIds.push_back(asset.id);
newDates.push_back(asset.dateTime);
}
Serial.printf("[queue] Album sync: %d assets from server\n", newIds.size());
_queue = newIds;
_albumMode = true;
_lastAlbumCounts = albumCounts;
applyCycleMode(s, newDates);
return true;
}
bool PhotoQueue::syncFromAllPhotos(const Settings& s) {
Serial.printf("[queue] Syncing from all photos (global random)\n");
std::vector<String> newIds;
std::vector<String> newDates;
int rounds = 0;
Serial.printf("[queue] Fetching until %d usable assets (excluding %d shown)\n",
TARGET_QUEUE_SIZE, _shown.size());
while (newIds.size() < TARGET_QUEUE_SIZE && rounds < MAX_FETCH_ROUNDS) {
auto batch = _client->fetchRandomAssets(50);
if (batch.empty()) {
Serial.printf("[queue] Fetch round %d returned empty\n", rounds + 1);
break;
}
for (auto& asset : batch) {
if (newIds.size() >= TARGET_QUEUE_SIZE) break;
if (std::find(newIds.begin(), newIds.end(), asset.id) != newIds.end()) continue;
if (std::find(_shown.begin(), _shown.end(), asset.id) != _shown.end()) continue;
newIds.push_back(asset.id);
newDates.push_back(asset.dateTime);
}
rounds++;
Serial.printf("[queue] Round %d: have %d/%d usable assets\n",
rounds, newIds.size(), TARGET_QUEUE_SIZE);
}
if (newIds.empty()) return false;
_queue = newIds;
_albumMode = false;
_lastAlbumCounts.clear();
applyCycleMode(s, newDates);
Serial.printf("[queue] All-photos sync: %d assets in %d rounds\n",
_queue.size(), rounds);
return true;
}
void PhotoQueue::applyCycleMode(const Settings& s, const std::vector<String>& dates) {
switch (s.cycle_mode) {
case CycleMode::Random:
applyFavoritesWeighting();
shuffle();
break;
case CycleMode::Chronological:
sortChronological(dates, false);
break;
case CycleMode::ReverseChronological:
sortChronological(dates, true);
break;
case CycleMode::FavoritesWeighted:
applyFavoritesWeighting();
shuffle();
break;
case CycleMode::WeightedChronological:
applyRecencyWeighting(dates, true);
shuffle();
break;
case CycleMode::WeightedReverseChronological:
applyRecencyWeighting(dates, false);
shuffle();
break;
// No default — compiler warns on unhandled CycleMode via -Wswitch
}
}
void PhotoQueue::requestSync() {
_syncRequested = true;
}
String PhotoQueue::next() {
// Auto-refill when queue is exhausted
if (_queue.empty() || _cursor >= _queue.size()) {
if (_albumMode && !_queue.empty()) {
// Album mode: reshuffle existing queue instead of re-fetching
Serial.println("[queue] Album queue exhausted, reshuffling in place");
Settings s = _settings->get();
// Clear shown history since we're replaying the same set
_shown.clear();
applyCycleMode(s, {});
_cursor = 0;
} else {
// All-photos mode: fetch a new batch
Serial.println("[queue] Queue exhausted, fetching new batch");
sync();
_cursor = 0;
}
}
if (_queue.empty()) return "";
String id = _queue[_cursor++];
// Track shown IDs for dedup on next refill
_shown.push_back(id);
if (_shown.size() > MAX_SHOWN_HISTORY) {
_shown.erase(_shown.begin());
}
// Persist cursor
_settings->saveField("queue_cursor", static_cast<uint32_t>(_cursor));
return id;
}
String PhotoQueue::random() {
if (_queue.empty()) return "";
size_t idx = ::random(0, _queue.size());
return _queue[idx];
}
String PhotoQueue::current() {
if (_queue.empty()) return "";
size_t idx = (_cursor > 0) ? _cursor - 1 : 0;
return _queue[idx];
}
size_t PhotoQueue::size() {
return _queue.size();
}
bool PhotoQueue::needsResync() {
if (_syncRequested) return true;
unsigned long now = millis();
// If never synced successfully, use retry backoff
if (_lastSyncTime == 0) {
if (_lastSyncAttempt == 0) return true;
return (now - _lastSyncAttempt) >= _syncRetryDelay;
}
// Normal resync interval
unsigned long elapsed = now - _lastSyncTime;
if (elapsed < (QUEUE_RESYNC_HOURS * 3600000UL)) return false;
// In album mode, check if album sizes actually changed before re-fetching
if (_albumMode && !_lastAlbumCounts.empty()) {
auto albums = _client->fetchAlbums();
auto selectedIds = getSelectedAlbumIds();
bool changed = false;
for (auto& selId : selectedIds) {
int currentCount = 0;
for (auto& album : albums) {
if (album.id == selId) {
currentCount = album.assetCount;
break;
}
}
auto it = _lastAlbumCounts.find(selId);
int lastCount = (it != _lastAlbumCounts.end()) ? it->second : -1;
if (currentCount != lastCount) {
Serial.printf("[queue] Album %s count changed: %d -> %d\n",
selId.c_str(), lastCount, currentCount);
changed = true;
break;
}
}
if (!changed) {
Serial.println("[queue] Album counts unchanged, reshuffling instead of re-fetching");
Settings s = _settings->get();
_shown.clear();
applyCycleMode(s, {});
_cursor = 0;
_lastSyncTime = millis();
return false;
}
}
return true;
}
String PhotoQueue::findNextPortrait() {
if (_client == nullptr) return "";
size_t scanEnd = min(_cursor + PORTRAIT_LOOKAHEAD, _queue.size());
for (size_t i = _cursor; i < scanEnd; i++) {
AssetInfo info = _client->fetchAssetInfo(_queue[i]);
if (info.isPortrait) {
String id = _queue[i];
_queue.erase(_queue.begin() + i);
Serial.printf("[queue] Found portrait pair: %s (scanned %d ahead)\n",
id.c_str(), (int)(i - _cursor + 1));
return id;
}
}
Serial.println("[queue] No portrait pair found in lookahead");
return "";
}
void PhotoQueue::shuffle() {
for (size_t i = _queue.size() - 1; i > 0; i--) {
size_t j = ::random(0, i + 1);
std::swap(_queue[i], _queue[j]);
}
}
void PhotoQueue::sortChronological(const std::vector<String>& dates, bool reverse) {
if (dates.size() != _queue.size()) {
// Dates unavailable (e.g. reshuffle after exhaustion) — fall back to shuffle
if (dates.empty()) {
shuffle();
return;
}
Serial.println("[queue] sortChronological: date/queue size mismatch, skipping");
return;
}
// Build index array sorted by date (ISO 8601 strings compare correctly)
std::vector<size_t> indices(dates.size());
for (size_t i = 0; i < indices.size(); i++) indices[i] = i;
std::sort(indices.begin(), indices.end(), [&](size_t a, size_t b) {
return dates[a] < dates[b];
});
if (reverse) std::reverse(indices.begin(), indices.end());
std::vector<String> sorted;
sorted.reserve(_queue.size());
for (size_t idx : indices) {
sorted.push_back(_queue[idx]);
}
_queue = sorted;
}
void PhotoQueue::applyRecencyWeighting(const std::vector<String>& dates, bool favorRecent) {
if (dates.size() != _queue.size() || _queue.size() < 3) {
// Dates unavailable — fall back to shuffle
if (dates.empty()) return;
return;
}
// Sort queue by date (oldest first)
std::vector<size_t> indices(dates.size());
for (size_t i = 0; i < indices.size(); i++) indices[i] = i;
std::sort(indices.begin(), indices.end(), [&](size_t a, size_t b) {
return dates[a] < dates[b];
});
std::vector<String> sorted;
sorted.reserve(_queue.size());
for (size_t idx : indices) {
sorted.push_back(_queue[idx]);
}
_queue = sorted;
// Duplicate the preferred third (3x copies total)
size_t third = _queue.size() / 3;
size_t start = favorRecent ? _queue.size() - third : 0;
size_t end = favorRecent ? _queue.size() : third;
for (size_t i = start; i < end; i++) {
_queue.push_back(_queue[i]);
_queue.push_back(_queue[i]);
}
Serial.printf("[queue] Recency weighting: %s third duplicated (%d -> %d entries)\n",
favorRecent ? "recent" : "oldest", (int)sorted.size(), (int)_queue.size());
}
void PhotoQueue::applyFavoritesWeighting() {
if (_client == nullptr) return;
Settings s = _settings->get();
if (s.cycle_mode != CycleMode::FavoritesWeighted) return;
auto favorites = _client->fetchFavoriteAssetIds();
for (auto& fav : favorites) {
bool inQueue = false;
for (auto& id : _queue) {
if (id == fav) { inQueue = true; break; }
}
if (inQueue) {
_queue.push_back(fav);
_queue.push_back(fav);
}
}
}
std::vector<String> PhotoQueue::getSelectedAlbumIds() {
std::vector<String> ids;
Settings s = _settings->get();
JsonDocument doc;
DeserializationError err = deserializeJson(doc, s.albums_json);
if (err) return ids;
JsonArray arr = doc.as<JsonArray>();
for (JsonVariant v : arr) {
ids.push_back(v.as<String>());
}
return ids;
}

50
src/photo_queue.h Normal file
View File

@@ -0,0 +1,50 @@
#pragma once
#include <Arduino.h>
#include <map>
#include <vector>
#include "settings.h"
class ImmichClient;
class PhotoQueue {
public:
void begin(ImmichClient& client, SettingsManager& settings);
bool sync();
void requestSync();
String next();
String random();
String current();
size_t size();
bool needsResync();
String findNextPortrait();
private:
ImmichClient* _client = nullptr;
SettingsManager* _settings = nullptr;
std::vector<String> _queue;
std::vector<String> _shown; // Recently shown IDs for dedup on refill
size_t _cursor = 0;
unsigned long _lastSyncTime = 0;
unsigned long _lastSyncAttempt = 0;
unsigned long _syncRetryDelay = 10000;
volatile bool _syncRequested = false;
bool _albumMode = false;
std::map<String, int> _lastAlbumCounts;
static constexpr unsigned long SYNC_RETRY_MIN = 10000; // 10 seconds
static constexpr unsigned long SYNC_RETRY_MAX = 300000; // 5 minutes
static constexpr size_t TARGET_QUEUE_SIZE = 50;
static constexpr int MAX_FETCH_ROUNDS = 5;
static constexpr size_t MAX_SHOWN_HISTORY = 200;
bool syncFromAlbums(const std::vector<String>& albumIds, const Settings& s);
bool syncFromAllPhotos(const Settings& s);
void applyCycleMode(const Settings& s, const std::vector<String>& dates);
void shuffle();
void sortChronological(const std::vector<String>& dates, bool reverse);
void applyRecencyWeighting(const std::vector<String>& dates, bool favorRecent);
void applyFavoritesWeighting();
std::vector<String> getSelectedAlbumIds();
};

169
src/power_manager.cpp Normal file
View File

@@ -0,0 +1,169 @@
#include "power_manager.h"
#include <M5Unified.h>
#include <Preferences.h>
#include <esp_pm.h>
static const char* NVS_PM_NAMESPACE = "pm_state";
void PowerManager::begin() {
Serial.println("[power] Initializing power manager");
// Disable audio power rails permanently
pinMode(PIN_AUDIO_PWR_EN, OUTPUT);
digitalWrite(PIN_AUDIO_PWR_EN, LOW);
pinMode(PIN_SPK_EN, OUTPUT);
digitalWrite(PIN_SPK_EN, LOW);
// Initialize RGB LEDs via M5Unified (handles NeoPixel on G21)
// M5Unified manages the LED strip internally
clearLEDs();
Serial.printf("[power] Battery: %d%%, Charging: %s\n",
getBatteryPercent(), isCharging() ? "yes" : "no");
}
uint8_t PowerManager::getBatteryPercent() {
int32_t level = M5.Power.getBatteryLevel();
if (level < 0) level = 0;
if (level > 100) level = 100;
_lastBatteryPct = static_cast<uint8_t>(level);
return _lastBatteryPct;
}
bool PowerManager::isCharging() {
return M5.Power.isCharging();
}
void PowerManager::updateBatteryLED() {
unsigned long now = millis();
uint8_t pct = getBatteryPercent();
// Auto deep sleep at critical level
if (pct <= BATTERY_SHUTDOWN_PCT && !isCharging()) {
Serial.println("[power] Battery critical — entering deep sleep");
enterDeepSleep();
return;
}
// Determine pulse interval based on battery level
unsigned long interval = 0;
uint8_t r = 0, g = 0, b = 0;
if (isCharging()) {
interval = BATTERY_WARN_INTERVAL_MS;
g = 255; // Green pulse
} else if (pct <= BATTERY_CRITICAL_PCT) {
interval = BATTERY_CRIT_INTERVAL_MS;
r = 255; // Red pulse
} else if (pct <= BATTERY_LOW_PCT) {
interval = BATTERY_WARN_INTERVAL_MS;
r = 255; g = 165; // Orange pulse
} else {
// Battery fine — no LED
return;
}
if (now - _lastLEDPulse >= interval) {
_lastLEDPulse = now;
flashLED(r, g, b);
}
}
void PowerManager::flashLED(uint8_t r, uint8_t g, uint8_t b, uint16_t duration_ms) {
// Scale by brightness
float scale = _ledBrightness / 255.0f;
uint8_t sr = static_cast<uint8_t>(r * scale);
uint8_t sg = static_cast<uint8_t>(g * scale);
uint8_t sb = static_cast<uint8_t>(b * scale);
setLED(0, sr, sg, sb);
setLED(1, sr, sg, sb);
// Non-blocking: we'll clear on next update cycle
// For simplicity, use a short blocking delay for LED flash feedback
delay(duration_ms);
clearLEDs();
}
void PowerManager::enterDeepSleep(uint32_t sleepMinutes) {
if (sleepMinutes > 0) {
Serial.printf("[power] Entering timed deep sleep — wake in %u min\n", sleepMinutes);
} else {
Serial.println("[power] Entering indefinite deep sleep");
}
Serial.flush();
delay(100);
clearLEDs();
// Persist flag in NVS so we know on next boot this was a timed sleep
if (sleepMinutes > 0) {
Preferences prefs;
prefs.begin(NVS_PM_NAMESPACE, false);
prefs.putUChar("timed_sleep", 1);
prefs.end();
}
uint64_t sleepUs = (sleepMinutes > 0)
? static_cast<uint64_t>(sleepMinutes) * 60ULL * 1000000ULL
: 0;
// M5.Power.deepSleep handles M5PM1 hardware properly
M5.Power.deepSleep(sleepUs);
}
bool PowerManager::wasTimedSleepWake() {
Preferences prefs;
prefs.begin(NVS_PM_NAMESPACE, true);
uint8_t flag = prefs.getUChar("timed_sleep", 0);
prefs.end();
return flag == 1;
}
void PowerManager::clearTimedSleepFlag() {
Preferences prefs;
prefs.begin(NVS_PM_NAMESPACE, false);
prefs.putUChar("timed_sleep", 0);
prefs.end();
Serial.println("[power] Timed sleep flag cleared");
}
void PowerManager::enableLightSleep() {
esp_pm_config_esp32s3_t pm_config;
pm_config.max_freq_mhz = 240;
pm_config.min_freq_mhz = 80;
pm_config.light_sleep_enable = true;
esp_err_t err = esp_pm_configure(&pm_config);
if (err == ESP_OK) {
Serial.println("[power] Light sleep enabled (80-240MHz)");
} else {
Serial.printf("[power] Light sleep config failed: %d\n", err);
}
}
void PowerManager::disableEPDPower() {
// M5PM1 PYG0 controls e-paper power — managed via M5Unified Power API
// M5.Power controls the PM1 rails
Serial.println("[power] EPD power disabled");
}
void PowerManager::enableEPDPower() {
Serial.println("[power] EPD power enabled");
}
void PowerManager::setLED(uint8_t index, uint8_t r, uint8_t g, uint8_t b) {
// M5Unified provides LED control — exact API depends on board support
// Fallback: direct NeoPixel control if M5Unified doesn't cover it
(void)index;
(void)r;
(void)g;
(void)b;
// TODO: Implement via M5Unified LED API or direct NeoPixel library
// This will be filled in during hardware bring-up when we can test on device
}
void PowerManager::clearLEDs() {
setLED(0, 0, 0, 0);
setLED(1, 0, 0, 0);
}

29
src/power_manager.h Normal file
View File

@@ -0,0 +1,29 @@
#pragma once
#include <Arduino.h>
#include <esp_sleep.h>
#include "config.h"
class PowerManager {
public:
void begin();
uint8_t getBatteryPercent();
bool isCharging();
void updateBatteryLED();
void flashLED(uint8_t r, uint8_t g, uint8_t b, uint16_t duration_ms = LED_PULSE_DURATION_MS);
void enterDeepSleep(uint32_t sleepMinutes = 0);
void enableLightSleep();
void disableEPDPower();
void enableEPDPower();
static bool wasTimedSleepWake();
static void clearTimedSleepFlag();
private:
uint8_t _lastBatteryPct = 100;
unsigned long _lastLEDPulse = 0;
uint8_t _ledBrightness = DEFAULT_LED_BRIGHTNESS;
void setLED(uint8_t index, uint8_t r, uint8_t g, uint8_t b);
void clearLEDs();
};

111
src/settings.cpp Normal file
View File

@@ -0,0 +1,111 @@
#include "settings.h"
#include <Preferences.h>
static const char* NVS_NAMESPACE = "immich_frame";
static Preferences prefs;
void SettingsManager::begin() {
prefs.begin(NVS_NAMESPACE, false);
Serial.println("[settings] NVS initialized");
}
Settings SettingsManager::get() {
Settings s;
s.wifi_ssid = readString("wifi_ssid");
s.wifi_pass = readString("wifi_pass");
s.immich_url = readString("immich_url", DEFAULT_IMMICH_URL);
s.immich_key = readString("immich_key");
s.interval_min = readU8("interval_m", DEFAULT_INTERVAL_MIN);
s.cycle_mode = static_cast<CycleMode>(readU8("cycle_mode", DEFAULT_CYCLE_MODE));
s.img_quality = static_cast<ImageQuality>(readU8("img_quality", DEFAULT_IMG_QUALITY));
s.meta_flags = readU8("meta_flags", DEFAULT_META_FLAGS);
s.meta_pos = static_cast<MetaPosition>(readU8("meta_pos", DEFAULT_META_POS));
s.date_fmt = readString("date_fmt", "%Y-%m-%d");
s.time_fmt = readString("time_fmt", "%H:%M:%S");
s.pipeline_mode = readU8("pipe_mode", DEFAULT_PIPELINE_MODE);
s.dither_noise = readU8("dith_noise", DEFAULT_DITHER_NOISE);
s.meta_translucent = readU8("meta_trans", 0);
s.led_brightness = readU8("led_bright", DEFAULT_LED_BRIGHTNESS);
s.show_battery = readU8("show_batt", 1);
s.queue_cursor = readU32("queue_cursor", 0);
s.albums_json = readString("albums_json", "[]");
s.timer_last_date = readString("timer_date", "");
s.timer_batch_size = readU8("timer_batch", DEFAULT_TIMER_BATCH);
return s;
}
void SettingsManager::save(const Settings& s) {
writeString("wifi_ssid", s.wifi_ssid);
writeString("wifi_pass", s.wifi_pass);
writeString("immich_url", s.immich_url);
writeString("immich_key", s.immich_key);
writeU8("interval_m", s.interval_min);
writeU8("cycle_mode", static_cast<uint8_t>(s.cycle_mode));
writeU8("img_quality", static_cast<uint8_t>(s.img_quality));
writeU8("meta_flags", s.meta_flags);
writeU8("meta_pos", static_cast<uint8_t>(s.meta_pos));
writeString("date_fmt", s.date_fmt);
writeString("time_fmt", s.time_fmt);
writeU8("pipe_mode", s.pipeline_mode);
writeU8("dith_noise", s.dither_noise);
writeU8("meta_trans", s.meta_translucent);
writeU8("led_bright", s.led_brightness);
writeU8("show_batt", s.show_battery);
writeU32("queue_cursor", s.queue_cursor);
writeString("albums_json", s.albums_json);
writeString("timer_date", s.timer_last_date);
writeU8("timer_batch", s.timer_batch_size);
Serial.println("[settings] All settings saved");
}
void SettingsManager::saveField(const char* key, uint8_t value) {
writeU8(key, value);
}
void SettingsManager::saveField(const char* key, uint32_t value) {
writeU32(key, value);
}
void SettingsManager::saveField(const char* key, const char* value) {
writeString(key, String(value));
}
String SettingsManager::readField(const char* key, const char* defaultValue) {
return readString(key, defaultValue);
}
void SettingsManager::factoryReset() {
prefs.clear();
Serial.println("[settings] Factory reset — rebooting");
delay(500);
ESP.restart();
}
bool SettingsManager::isProvisioned() {
String ssid = readString("wifi_ssid");
return ssid.length() > 0;
}
String SettingsManager::readString(const char* key, const char* defaultValue) {
return prefs.getString(key, defaultValue);
}
uint8_t SettingsManager::readU8(const char* key, uint8_t defaultValue) {
return prefs.getUChar(key, defaultValue);
}
uint32_t SettingsManager::readU32(const char* key, uint32_t defaultValue) {
return prefs.getUInt(key, defaultValue);
}
void SettingsManager::writeString(const char* key, const String& value) {
prefs.putString(key, value);
}
void SettingsManager::writeU8(const char* key, uint8_t value) {
prefs.putUChar(key, value);
}
void SettingsManager::writeU32(const char* key, uint32_t value) {
prefs.putUInt(key, value);
}

96
src/settings.h Normal file
View File

@@ -0,0 +1,96 @@
#pragma once
#include <Arduino.h>
#include "config.h"
enum class CycleMode : uint8_t {
Random = 0,
Chronological = 1,
ReverseChronological = 2,
FavoritesWeighted = 3,
WeightedChronological = 4,
WeightedReverseChronological = 5
};
enum class ImageQuality : uint8_t {
Preview = 0,
Original = 1
};
enum class MetaPosition : uint8_t {
// Captioned mode (full-width bar)
CaptionBottom = 0,
CaptionTop = 1,
// Overlay mode (corner badge)
OverlayTopLeft = 2,
OverlayTopRight = 3,
OverlayBottomLeft = 4,
OverlayBottomRight = 5
};
// Metadata flags bitmask
constexpr uint8_t META_DATE = 0x01;
constexpr uint8_t META_TIME = 0x20;
constexpr uint8_t META_LOCATION = 0x02;
constexpr uint8_t META_PEOPLE = 0x04;
constexpr uint8_t META_ALBUM = 0x08;
constexpr uint8_t META_CAMERA = 0x10;
struct Settings {
// WiFi
String wifi_ssid;
String wifi_pass;
// Immich
String immich_url;
String immich_key;
// Slideshow
uint8_t interval_min;
CycleMode cycle_mode;
ImageQuality img_quality;
// Display
uint8_t meta_flags;
MetaPosition meta_pos;
String date_fmt; // strftime-style, default "%Y-%m-%d"
String time_fmt; // strftime-style, default "%H:%M:%S"
// Image pipeline
uint8_t pipeline_mode; // 0=dynamic, 1=balanced, 2=none
uint8_t dither_noise; // 1=blue noise dither smoothing ON, 0=OFF
uint8_t meta_translucent; // 1=checkerboard 50% opacity badge bg, 0=solid black
// Device
uint8_t led_brightness;
uint8_t show_battery; // 1 = show battery indicator on display
uint32_t queue_cursor;
String albums_json;
// Deep sleep chronological cursor (ISO datetime of last shown photo)
String timer_last_date;
// Deep sleep batch cache size
uint8_t timer_batch_size;
};
class SettingsManager {
public:
void begin();
Settings get();
void save(const Settings& s);
void saveField(const char* key, uint8_t value);
void saveField(const char* key, uint32_t value);
void saveField(const char* key, const char* value);
String readField(const char* key, const char* defaultValue = "");
void factoryReset();
bool isProvisioned();
private:
String readString(const char* key, const char* defaultValue = "");
uint8_t readU8(const char* key, uint8_t defaultValue = 0);
uint32_t readU32(const char* key, uint32_t defaultValue = 0);
void writeString(const char* key, const String& value);
void writeU8(const char* key, uint8_t value);
void writeU32(const char* key, uint32_t value);
};

7988
src/stb_image.h Normal file

File diff suppressed because it is too large Load Diff

15
src/stb_image_impl.cpp Normal file
View File

@@ -0,0 +1,15 @@
#include <esp_heap_caps.h>
#define STB_IMAGE_IMPLEMENTATION
#define STBI_ONLY_JPEG
#define STBI_NO_STDIO
#define STBI_NO_HDR
#define STBI_NO_LINEAR
#define STBI_MALLOC(sz) heap_caps_malloc(sz, MALLOC_CAP_SPIRAM)
#define STBI_REALLOC(p,newsz) heap_caps_realloc(p, newsz, MALLOC_CAP_SPIRAM)
#define STBI_FREE(p) free(p)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-function"
#include "stb_image.h"
#pragma GCC diagnostic pop

33
src/time_utils.h Normal file
View File

@@ -0,0 +1,33 @@
#pragma once
#include <Arduino.h>
#include <time.h>
// Returns true if the system clock has been set to a plausible time (year >= 2020).
inline bool hasValidTime() {
time_t now = time(nullptr);
struct tm t;
gmtime_r(&now, &t);
return (t.tm_year + 1900) >= 2020;
}
// Returns the current UTC time as an ISO 8601 string (e.g., "2026-08-04T17:30:00.000Z").
inline String isoNow() {
time_t now = time(nullptr);
struct tm t;
gmtime_r(&now, &t);
char buf[32];
strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S.000Z", &t);
return String(buf);
}
// Returns (now - 30 days) as an ISO 8601 UTC string.
inline String isoNowMinus30d() {
time_t now = time(nullptr);
now -= 30 * 24 * 3600; // Subtract 30 days in seconds
struct tm t;
gmtime_r(&now, &t);
char buf[32];
strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S.000Z", &t);
return String(buf);
}

183
src/wake_log.cpp Normal file
View File

@@ -0,0 +1,183 @@
#include "wake_log.h"
#include <LittleFS.h>
#include <Preferences.h>
#include <esp_heap_caps.h>
#include "time_utils.h"
static const char* LOG_PATH = "/wake_log.csv";
static const char* NVS_LOG_NS = "wake_log";
static constexpr size_t MAX_LOG_SIZE = 200 * 1024; // 200KB cap
static constexpr size_t KEEP_SIZE = 100 * 1024; // Keep last 100KB on truncation
static bool _logReady = false;
static uint32_t _bootCount = 0;
static bool _rtcValidAtBoot = false;
static const char* resultStr(WakeLogResult r) {
switch (r) {
case WakeLogResult::Ok: return "ok";
case WakeLogResult::FailFetch: return "fail_fetch";
case WakeLogResult::FailDownload: return "fail_download";
case WakeLogResult::FailProcess: return "fail_process";
default: {
WakeLogResult unreachable = r;
(void)unreachable;
return "unknown";
}
}
}
void wakeLogBegin() {
// Record RTC validity before anything else might change the clock
_rtcValidAtBoot = hasValidTime();
// Mount LittleFS (idempotent — safe to call if already mounted by web server)
if (!LittleFS.begin(true)) {
Serial.println("[wake_log] LittleFS mount failed — logging disabled");
_logReady = false;
return;
}
// Increment boot counter in NVS (fire-and-forget)
Preferences prefs;
if (prefs.begin(NVS_LOG_NS, false)) {
_bootCount = prefs.getUInt("boot_cnt", 0) + 1;
prefs.putUInt("boot_cnt", _bootCount);
prefs.end();
} else {
Serial.println("[wake_log] NVS open failed — using boot_count=0");
_bootCount = 0;
}
_logReady = true;
Serial.printf("[wake_log] Ready (boot #%u, rtc_valid=%d)\n",
_bootCount, _rtcValidAtBoot ? 1 : 0);
}
// Truncate log file to keep only the last KEEP_SIZE bytes.
// Uses PSRAM buffer. Skips silently on failure.
static void truncateIfNeeded() {
File f = LittleFS.open(LOG_PATH, "r");
if (!f) return;
size_t fileSize = f.size();
if (fileSize <= MAX_LOG_SIZE) {
f.close();
return;
}
Serial.printf("[wake_log] Log at %uKB — truncating to last %uKB\n",
(unsigned)(fileSize / 1024), (unsigned)(KEEP_SIZE / 1024));
// Allocate buffer in PSRAM
uint8_t* buf = (uint8_t*)ps_malloc(KEEP_SIZE);
if (!buf) {
Serial.println("[wake_log] PSRAM alloc failed — skipping truncation");
f.close();
return;
}
// Seek to (fileSize - KEEP_SIZE) and read the tail
f.seek(fileSize - KEEP_SIZE);
size_t bytesRead = f.read(buf, KEEP_SIZE);
f.close();
if (bytesRead == 0) {
free(buf);
return;
}
// Find first newline to avoid a partial line at the start
size_t start = 0;
for (size_t i = 0; i < bytesRead; i++) {
if (buf[i] == '\n') {
start = i + 1;
break;
}
}
// Rewrite the file with just the tail
File out = LittleFS.open(LOG_PATH, "w");
if (!out) {
Serial.println("[wake_log] File rewrite failed — skipping truncation");
free(buf);
return;
}
out.write(buf + start, bytesRead - start);
out.close();
free(buf);
Serial.printf("[wake_log] Truncated to %uKB\n",
(unsigned)((bytesRead - start) / 1024));
}
void wakeLogAppend(uint8_t battPct, int wifiRssi, uint32_t wifiMs,
const String& assetId, const String& photoDate,
const String& filter, WakeLogResult result, bool isDeepSleep) {
if (!_logReady) return;
// Read error tracking from NVS (defaults on failure)
uint32_t errorsTotal = 0;
String lastErrorTs = "";
Preferences prefs;
if (prefs.begin(NVS_LOG_NS, false)) {
errorsTotal = prefs.getUInt("err_total", 0);
lastErrorTs = prefs.getString("err_last_ts", "");
// Update error counters if this is a failure
if (result != WakeLogResult::Ok) {
errorsTotal++;
lastErrorTs = hasValidTime() ? isoNow() : "unknown";
prefs.putUInt("err_total", errorsTotal);
prefs.putString("err_last_ts", lastErrorTs);
}
prefs.end();
} else {
Serial.println("[wake_log] NVS read failed — using defaults");
}
// Build CSV line
uint32_t cycleMs = millis();
uint32_t freePsramKb = (uint32_t)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024);
uint32_t freeHeapKb = (uint32_t)(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024);
String timestamp = hasValidTime() ? isoNow() : "no_clock";
char line[512];
snprintf(line, sizeof(line),
"%s,%u,%u,%u,%d,%u,%u,%d,%u,%s,%s,%s,%s,%s,%s,%u\n",
timestamp.c_str(),
(unsigned)battPct,
(unsigned)freePsramKb,
(unsigned)freeHeapKb,
wifiRssi,
(unsigned)wifiMs,
(unsigned)cycleMs,
_rtcValidAtBoot ? 1 : 0,
(unsigned)_bootCount,
filter.c_str(),
assetId.c_str(),
photoDate.c_str(),
resultStr(result),
isDeepSleep ? "sleep" : "normal",
lastErrorTs.c_str(),
(unsigned)errorsTotal);
// Append to log file
File f = LittleFS.open(LOG_PATH, "a");
if (!f) {
Serial.println("[wake_log] File open failed — skipping log entry");
return;
}
f.print(line);
f.close();
Serial.printf("[wake_log] Logged: %s %s (%s)\n",
resultStr(result), assetId.c_str(),
isDeepSleep ? "sleep" : "normal");
// Check if truncation is needed
truncateIfNeeded();
}

21
src/wake_log.h Normal file
View File

@@ -0,0 +1,21 @@
#pragma once
#include <Arduino.h>
enum class WakeLogResult : uint8_t {
Ok,
FailFetch,
FailDownload,
FailProcess
};
// Initialize the wake log subsystem. Call once early in boot.
// Mounts LittleFS, increments boot counter, records RTC validity.
// Fail-safe: if anything fails, logging becomes a no-op.
void wakeLogBegin();
// Append one CSV line to the persistent log.
// Fail-safe: if filesystem or NVS is unavailable, silently returns.
void wakeLogAppend(uint8_t battPct, int wifiRssi, uint32_t wifiMs,
const String& assetId, const String& photoDate,
const String& filter, WakeLogResult result, bool isDeepSleep);

382
src/web_server.cpp Normal file
View File

@@ -0,0 +1,382 @@
#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");
});
// Kick off initial async WiFi scan
WiFi.scanNetworks(true);
// Handle WiFi scan (non-blocking — returns cached results or "scanning" status)
_server.on("/api/wifi/scan", HTTP_GET, [](AsyncWebServerRequest* request) {
int n = WiFi.scanComplete();
if (n == WIFI_SCAN_RUNNING) {
// Scan in progress — tell client to retry
request->send(200, "application/json", "{\"scanning\":true}");
return;
}
if (n == WIFI_SCAN_FAILED) {
// No scan started or failed — kick one off
WiFi.scanNetworks(true);
request->send(200, "application/json", "{\"scanning\":true}");
return;
}
// Scan complete — return results
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);
// Start a fresh scan for next request
WiFi.scanDelete();
WiFi.scanNetworks(true);
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);
Settings s = _settings->get();
_power->enterDeepSleep(s.interval_min);
});
// Test Immich connectivity
_server.on("/api/test-connection", HTTP_POST,
[this](AsyncWebServerRequest* req) {
Settings s = _settings->get();
Serial.printf("[web] Testing connection to: %s (key: %s...)\n",
s.immich_url.c_str(),
s.immich_key.substring(0, 8).c_str());
// Reinitialize immich client with current settings
_immich->begin(s.immich_url, s.immich_key);
auto albums = _immich->fetchAlbums();
JsonDocument doc;
if (!albums.empty()) {
doc["ok"] = true;
doc["msg"] = String("Connected! Found ") + String(albums.size()) + " albums.";
} else {
doc["ok"] = false;
doc["msg"] = "Failed to connect. Check URL and API key.";
}
String response;
serializeJson(doc, response);
req->send(200, "application/json", response);
});
_server.on("/api/action/sync-time", HTTP_POST,
[this](AsyncWebServerRequest* req) {
_wifi->syncNTP();
req->send(200, "application/json", "{\"ok\":true,\"msg\":\"RTC synced\"}");
});
// Wake log endpoints
_server.on("/api/log", HTTP_GET,
[](AsyncWebServerRequest* req) {
if (LittleFS.exists("/wake_log.csv")) {
req->send(LittleFS, "/wake_log.csv", "text/csv");
} else {
req->send(200, "text/csv", "");
}
});
_server.on("/api/log/clear", HTTP_POST,
[](AsyncWebServerRequest* req) {
LittleFS.remove("/wake_log.csv");
req->send(200, "application/json", "{\"ok\":true,\"msg\":\"Log cleared\"}");
});
_server.on("/api/action/reboot", HTTP_POST,
[](AsyncWebServerRequest* req) {
req->send(200, "application/json", "{\"ok\":true,\"msg\":\"Rebooting...\"}");
delay(500);
ESP.restart();
});
}
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());
if (_actionCb) _actionCb("albums_changed");
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["date_fmt"] = s.date_fmt;
doc["time_fmt"] = s.time_fmt;
doc["pipeline_mode"] = s.pipeline_mode;
doc["dither_noise"] = s.dither_noise;
doc["meta_translucent"] = s.meta_translucent;
doc["led_brightness"] = s.led_brightness;
doc["show_battery"] = s.show_battery;
doc["immich_url"] = s.immich_url;
doc["immich_key"] = s.immich_key;
doc["albums_json"] = s.albums_json;
doc["timer_batch_size"] = s.timer_batch_size;
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()) {
uint8_t v = doc["interval_min"];
s.interval_min = constrain(v, 1, 60);
}
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["date_fmt"].isNull()) s.date_fmt = doc["date_fmt"].as<String>();
if (!doc["time_fmt"].isNull()) s.time_fmt = doc["time_fmt"].as<String>();
if (!doc["pipeline_mode"].isNull()) s.pipeline_mode = doc["pipeline_mode"];
if (!doc["dither_noise"].isNull()) s.dither_noise = doc["dither_noise"];
if (!doc["meta_translucent"].isNull()) s.meta_translucent = doc["meta_translucent"];
if (!doc["led_brightness"].isNull()) s.led_brightness = doc["led_brightness"];
if (!doc["show_battery"].isNull()) s.show_battery = doc["show_battery"];
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>();
if (!doc["timer_batch_size"].isNull()) {
uint8_t v = doc["timer_batch_size"];
s.timer_batch_size = constrain(v, 1, 50);
}
_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);
};

138
src/wifi_manager.cpp Normal file
View File

@@ -0,0 +1,138 @@
#include "wifi_manager.h"
#include <WiFi.h>
#include <ESPmDNS.h>
#include <M5Unified.h>
#include <time.h>
#include <sys/time.h>
#include "config.h"
void WiFiManager::begin(SettingsManager& settings, bool minimal) {
_settings = &settings;
if (!_settings->isProvisioned()) {
Serial.println("[wifi] Not provisioned — starting AP");
startAP();
return;
}
startStation(minimal);
}
bool WiFiManager::isConnected() {
return WiFi.status() == WL_CONNECTED;
}
bool WiFiManager::isAPMode() {
return _apMode;
}
String WiFiManager::getIP() {
if (_apMode) {
return WiFi.softAPIP().toString();
}
return WiFi.localIP().toString();
}
int WiFiManager::getRSSI() {
if (_apMode) return 0;
return WiFi.RSSI();
}
void WiFiManager::startAP() {
WiFi.disconnect(true);
WiFi.mode(WIFI_AP);
WiFi.softAP("PaperColor-Setup");
_apMode = true;
Serial.printf("[wifi] AP started: PaperColor-Setup, IP: %s\n",
WiFi.softAPIP().toString().c_str());
}
void WiFiManager::startStation(bool minimal) {
Settings s = _settings->get();
WiFi.disconnect(true);
WiFi.mode(WIFI_STA);
_apMode = false;
Serial.printf("[wifi] Connecting to: %s%s\n", s.wifi_ssid.c_str(),
minimal ? " (minimal)" : "");
for (_retryCount = 0; _retryCount < WIFI_MAX_RETRIES; _retryCount++) {
if (attemptConnection(s.wifi_ssid, s.wifi_pass)) {
Serial.printf("[wifi] Connected! IP: %s, RSSI: %d\n",
WiFi.localIP().toString().c_str(), WiFi.RSSI());
if (minimal) {
// Deep sleep wake path: skip blocking NTP wait and mDNS to
// minimize active time. Still call configTime() to initialize
// the DNS resolver (required for HTTPS hostname resolution).
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
return;
}
WiFi.setSleep(WIFI_PS_MIN_MODEM);
syncNTP();
setupMDNS("papercolor");
return;
}
Serial.printf("[wifi] Attempt %d/%d failed\n", _retryCount + 1, WIFI_MAX_RETRIES);
}
Serial.println("[wifi] All attempts failed — falling back to AP mode");
startAP();
}
void WiFiManager::setupMDNS(const char* hostname) {
if (MDNS.begin(hostname)) {
MDNS.addService("http", "tcp", 80);
Serial.printf("[wifi] mDNS: %s.local\n", hostname);
} else {
Serial.println("[wifi] mDNS failed to start");
}
}
bool WiFiManager::attemptConnection(const String& ssid, const String& pass) {
WiFi.begin(ssid.c_str(), pass.c_str());
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED) {
if (millis() - start > WIFI_CONNECT_TIMEOUT_MS) {
return false;
}
delay(100);
}
return true;
}
void WiFiManager::syncNTP() {
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
// Wait up to 5 seconds for NTP sync
unsigned long start = millis();
time_t now = 0;
while (now < 1600000000 && (millis() - start) < 5000) {
delay(100);
time(&now);
}
if (now < 1600000000) {
Serial.println("[wifi] NTP sync timed out");
return;
}
// Write synced time to hardware RTC
struct tm t;
gmtime_r(&now, &t);
m5::rtc_datetime_t dt;
dt.date.year = t.tm_year + 1900;
dt.date.month = t.tm_mon + 1;
dt.date.date = t.tm_mday;
dt.time.hours = t.tm_hour;
dt.time.minutes = t.tm_min;
dt.time.seconds = t.tm_sec;
M5.Rtc.setDateTime(dt);
Serial.printf("[wifi] NTP synced: %04d-%02d-%02d %02d:%02d:%02d UTC, written to RTC\n",
dt.date.year, dt.date.month, dt.date.date,
dt.time.hours, dt.time.minutes, dt.time.seconds);
}

24
src/wifi_manager.h Normal file
View File

@@ -0,0 +1,24 @@
#pragma once
#include <Arduino.h>
#include "settings.h"
class WiFiManager {
public:
void begin(SettingsManager& settings, bool minimal = false);
bool isConnected();
bool isAPMode();
String getIP();
int getRSSI();
void startAP();
void startStation(bool minimal = false);
void setupMDNS(const char* hostname);
void syncNTP();
private:
SettingsManager* _settings = nullptr;
bool _apMode = false;
int _retryCount = 0;
bool attemptConnection(const String& ssid, const String& pass);
};

44
test/README.md Normal file
View File

@@ -0,0 +1,44 @@
# Testing
## Native Tests (host machine)
Run algorithm tests that don't require hardware:
```bash
pio test -e native
```
Tests:
- `test_photo_queue.cpp` — queue cycling, favorites weighting
- `test_image_pipeline.cpp` — nearest color, dithering correctness
## On-Device Testing
Flash and monitor:
```bash
pio run -e m5stack-papercolor -t upload && pio device monitor
```
### Verification Checklist
- [ ] Device boots and prints version to serial
- [ ] AP mode activates on first boot
- [ ] Captive portal serves setup page at 192.168.4.1
- [ ] WiFi credentials save and device reboots to station mode
- [ ] mDNS resolves at papercolor.local
- [ ] Web UI dashboard shows battery/WiFi/uptime
- [ ] Albums page lists Immich albums
- [ ] Album selection persists across reboots
- [ ] Photos download and display correctly
- [ ] Portrait photos pair side-by-side
- [ ] Slideshow advances at configured interval
- [ ] BTN_UP: next photo (LED flashes white)
- [ ] BTN_TOP: random photo (LED flashes white)
- [ ] BTN_DOWN: pause/play toggle
- [ ] BTN_TOP+BTN_DOWN 3s: deep sleep
- [ ] BTN_UP 5s: factory reset
- [ ] Battery LED: orange at 25%, red at 10%
- [ ] OTA upload succeeds via web UI
- [ ] Device recovers from WiFi disconnect (reconnect on next cycle)

View File

@@ -0,0 +1,294 @@
#include <unity.h>
#include <cstdint>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <climits>
// Calibrated Spectra 6 palette (matches PALETTE_CALIBRATED in image_pipeline.cpp)
struct Color { uint8_t r, g, b; };
static const Color PALETTE[6] = {
{0x1F, 0x22, 0x26}, // Black -> dark gray
{0xB9, 0xC7, 0xC9}, // White -> light gray-blue
{0x62, 0x20, 0x1E}, // Red -> dark red/brown
{0x35, 0x56, 0x3A}, // Green -> dark forest green
{0x23, 0x3F, 0x8E}, // Blue -> dark navy
{0xC1, 0xBB, 0x1E} // Yellow -> olive/mustard
};
// Find nearest palette color (Rec. 709 luminance-weighted RGB distance)
uint8_t findNearestColor(int r, int g, int b) {
uint8_t best = 0;
int32_t bestDist = INT32_MAX;
for (int i = 0; i < 6; i++) {
int dr = r - PALETTE[i].r;
int dg = g - PALETTE[i].g;
int db = b - PALETTE[i].b;
int32_t dist = 2126 * dr * dr + 7152 * dg * dg + 722 * db * db;
if (dist < bestDist) {
bestDist = dist;
best = static_cast<uint8_t>(i);
}
}
return best;
}
// Floyd-Steinberg dithering on a small test buffer
void ditherBuffer(uint8_t* rgb, int width, int height, uint8_t* output) {
// Working buffer with int16_t to handle error diffusion overflow
int16_t* work = (int16_t*)malloc(width * height * 3 * sizeof(int16_t));
for (int i = 0; i < width * height * 3; i++) {
work[i] = rgb[i];
}
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int idx = (y * width + x) * 3;
int r = work[idx];
int g = work[idx + 1];
int b = work[idx + 2];
// Clamp
r = r < 0 ? 0 : (r > 255 ? 255 : r);
g = g < 0 ? 0 : (g > 255 ? 255 : g);
b = b < 0 ? 0 : (b > 255 ? 255 : b);
uint8_t nearest = findNearestColor(r, g, b);
output[y * width + x] = nearest;
// Error
int errR = r - PALETTE[nearest].r;
int errG = g - PALETTE[nearest].g;
int errB = b - PALETTE[nearest].b;
// Distribute error (Floyd-Steinberg weights: 7/16, 3/16, 5/16, 1/16)
if (x + 1 < width) {
int ni = (y * width + (x + 1)) * 3;
work[ni] += errR * 7 / 16;
work[ni + 1] += errG * 7 / 16;
work[ni + 2] += errB * 7 / 16;
}
if (y + 1 < height) {
if (x > 0) {
int ni = ((y + 1) * width + (x - 1)) * 3;
work[ni] += errR * 3 / 16;
work[ni + 1] += errG * 3 / 16;
work[ni + 2] += errB * 3 / 16;
}
{
int ni = ((y + 1) * width + x) * 3;
work[ni] += errR * 5 / 16;
work[ni + 1] += errG * 5 / 16;
work[ni + 2] += errB * 5 / 16;
}
if (x + 1 < width) {
int ni = ((y + 1) * width + (x + 1)) * 3;
work[ni] += errR * 1 / 16;
work[ni + 1] += errG * 1 / 16;
work[ni + 2] += errB * 1 / 16;
}
}
}
}
free(work);
}
void test_nearest_color_black() {
TEST_ASSERT_EQUAL(0, findNearestColor(0, 0, 0));
}
void test_nearest_color_white() {
TEST_ASSERT_EQUAL(1, findNearestColor(255, 255, 255));
}
void test_nearest_color_red() {
TEST_ASSERT_EQUAL(2, findNearestColor(180, 20, 20));
}
void test_nearest_color_green() {
TEST_ASSERT_EQUAL(3, findNearestColor(20, 140, 20));
}
void test_nearest_color_blue() {
TEST_ASSERT_EQUAL(4, findNearestColor(20, 20, 180));
}
void test_nearest_color_yellow() {
TEST_ASSERT_EQUAL(5, findNearestColor(200, 180, 20));
}
void test_dither_solid_black() {
const int W = 4, H = 4;
uint8_t rgb[W * H * 3] = {0}; // All black
uint8_t output[W * H];
ditherBuffer(rgb, W, H, output);
for (int i = 0; i < W * H; i++) {
TEST_ASSERT_EQUAL(0, output[i]); // All should be black
}
}
void test_dither_solid_white() {
const int W = 4, H = 4;
uint8_t rgb[W * H * 3];
memset(rgb, 255, sizeof(rgb)); // All white
uint8_t output[W * H];
ditherBuffer(rgb, W, H, output);
for (int i = 0; i < W * H; i++) {
TEST_ASSERT_EQUAL(1, output[i]); // All should be white
}
}
void test_dither_produces_valid_indices() {
const int W = 8, H = 8;
uint8_t rgb[W * H * 3];
// Fill with mid-gray
for (int i = 0; i < W * H * 3; i++) rgb[i] = 128;
uint8_t output[W * H];
ditherBuffer(rgb, W, H, output);
for (int i = 0; i < W * H; i++) {
TEST_ASSERT_TRUE(output[i] < 6); // Valid palette index
}
}
// Include the actual blue noise texture for testing
#include "../../src/blue_noise.h"
static constexpr float TEST_BLUE_NOISE_STRENGTH = 32.0f;
// Floyd-Steinberg dither with blue noise threshold modulation
void ditherBufferWithBlueNoise(uint8_t* rgb, int width, int height, uint8_t* output) {
int16_t* work = (int16_t*)malloc(width * height * 3 * sizeof(int16_t));
for (int i = 0; i < width * height * 3; i++) {
work[i] = rgb[i];
}
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int idx = (y * width + x) * 3;
int r = work[idx];
int g = work[idx + 1];
int b = work[idx + 2];
r = r < 0 ? 0 : (r > 255 ? 255 : r);
g = g < 0 ? 0 : (g > 255 ? 255 : g);
b = b < 0 ? 0 : (b > 255 ? 255 : b);
// Apply blue noise perturbation
uint8_t noise = pgm_read_byte(&BLUE_NOISE_64[(y & 63) * 64 + (x & 63)]);
float nf = ((float)noise - 128.0f) * (TEST_BLUE_NOISE_STRENGTH / 128.0f);
int rn = r + (int)nf;
int gn = g + (int)nf;
int bn = b + (int)nf;
rn = rn < 0 ? 0 : (rn > 255 ? 255 : rn);
gn = gn < 0 ? 0 : (gn > 255 ? 255 : gn);
bn = bn < 0 ? 0 : (bn > 255 ? 255 : bn);
uint8_t nearest = findNearestColor(rn, gn, bn);
output[y * width + x] = nearest;
// Error uses original r,g,b (energy conservation)
int errR = r - PALETTE[nearest].r;
int errG = g - PALETTE[nearest].g;
int errB = b - PALETTE[nearest].b;
if (x + 1 < width) {
int ni = (y * width + (x + 1)) * 3;
work[ni] += errR * 7 / 16;
work[ni + 1] += errG * 7 / 16;
work[ni + 2] += errB * 7 / 16;
}
if (y + 1 < height) {
if (x > 0) {
int ni = ((y + 1) * width + (x - 1)) * 3;
work[ni] += errR * 3 / 16;
work[ni + 1] += errG * 3 / 16;
work[ni + 2] += errB * 3 / 16;
}
{
int ni = ((y + 1) * width + x) * 3;
work[ni] += errR * 5 / 16;
work[ni + 1] += errG * 5 / 16;
work[ni + 2] += errB * 5 / 16;
}
if (x + 1 < width) {
int ni = ((y + 1) * width + (x + 1)) * 3;
work[ni] += errR * 1 / 16;
work[ni + 1] += errG * 1 / 16;
work[ni + 2] += errB * 1 / 16;
}
}
}
}
free(work);
}
void test_blue_noise_breaks_pattern_regularity() {
// Use a 16x16 dark gray patch — just above the black palette value.
// Standard F-S produces highly regular dot patterns in this region.
const int W = 16, H = 16;
uint8_t rgb[W * H * 3];
memset(rgb, 45, sizeof(rgb)); // Dark gray (45,45,45) — triggers error accumulation
uint8_t output_standard[W * H];
uint8_t output_blue_noise[W * H];
// Copy rgb since ditherBuffer modifies working buffer
uint8_t rgb_copy[W * H * 3];
memcpy(rgb_copy, rgb, sizeof(rgb));
ditherBuffer(rgb, W, H, output_standard);
ditherBufferWithBlueNoise(rgb_copy, W, H, output_blue_noise);
// Both should produce valid palette indices
for (int i = 0; i < W * H; i++) {
TEST_ASSERT_TRUE(output_standard[i] < 6);
TEST_ASSERT_TRUE(output_blue_noise[i] < 6);
}
// Count unique rows in each output to measure pattern regularity.
// Standard F-S in uniform areas tends to produce repeating row patterns.
// Blue noise should produce more unique rows (less periodic).
int unique_standard = 0;
int unique_blue_noise = 0;
for (int y = 0; y < H; y++) {
bool is_duplicate = false;
for (int prev = 0; prev < y; prev++) {
if (memcmp(&output_standard[y * W], &output_standard[prev * W], W) == 0) {
is_duplicate = true;
break;
}
}
if (!is_duplicate) unique_standard++;
}
for (int y = 0; y < H; y++) {
bool is_duplicate = false;
for (int prev = 0; prev < y; prev++) {
if (memcmp(&output_blue_noise[y * W], &output_blue_noise[prev * W], W) == 0) {
is_duplicate = true;
break;
}
}
if (!is_duplicate) unique_blue_noise++;
}
// Blue noise version should have at least as many unique rows as standard.
// In practice it should have more (the whole point of the feature).
TEST_ASSERT_GREATER_OR_EQUAL(unique_standard, unique_blue_noise);
}
void test_blue_noise_dither_valid_indices() {
const int W = 16, H = 16;
uint8_t rgb[W * H * 3];
// Test across different gray levels
for (int level = 0; level < 256; level += 32) {
memset(rgb, level, sizeof(rgb));
uint8_t output[W * H];
ditherBufferWithBlueNoise(rgb, W, H, output);
for (int i = 0; i < W * H; i++) {
TEST_ASSERT_TRUE(output[i] < 6);
}
}
}

View File

@@ -0,0 +1,129 @@
#include <unity.h>
#include <vector>
#include <algorithm>
#include <cstdlib>
#include <cstring>
// Minimal test of queue cycling logic (extracted, platform-independent)
// We test the shuffling and cycling algorithms without hardware dependencies
struct QueueState {
std::vector<std::string> ids;
size_t cursor;
};
// Simulate random cycling: advance through shuffled list
std::string advanceRandom(QueueState& state) {
if (state.ids.empty()) return "";
if (state.cursor >= state.ids.size()) {
state.cursor = 0; // Wrap around (would reshuffle in real impl)
}
return state.ids[state.cursor++];
}
// Simulate chronological: just advance sequentially (assumes pre-sorted)
std::string advanceChrono(QueueState& state) {
if (state.ids.empty()) return "";
if (state.cursor >= state.ids.size()) {
state.cursor = 0;
}
return state.ids[state.cursor++];
}
// Favorites weighting: insert duplicates
std::vector<std::string> applyFavoritesWeight(
const std::vector<std::string>& all,
const std::vector<std::string>& favorites,
int weight) {
std::vector<std::string> result = all;
for (const auto& fav : favorites) {
for (int i = 1; i < weight; i++) {
result.push_back(fav);
}
}
return result;
}
void test_advance_random_no_repeats_until_wrap() {
QueueState state;
state.ids = {"a", "b", "c", "d", "e"};
state.cursor = 0;
std::vector<std::string> seen;
for (size_t i = 0; i < state.ids.size(); i++) {
std::string id = advanceRandom(state);
// Should not have seen this one yet
TEST_ASSERT_TRUE(std::find(seen.begin(), seen.end(), id) == seen.end());
seen.push_back(id);
}
TEST_ASSERT_EQUAL(5, seen.size());
}
void test_advance_wraps_at_end() {
QueueState state;
state.ids = {"a", "b", "c"};
state.cursor = 0;
advanceRandom(state); // a
advanceRandom(state); // b
advanceRandom(state); // c
std::string wrapped = advanceRandom(state); // wraps to a
TEST_ASSERT_EQUAL_STRING("a", wrapped.c_str());
}
void test_empty_queue_returns_empty() {
QueueState state;
state.cursor = 0;
std::string result = advanceRandom(state);
TEST_ASSERT_EQUAL_STRING("", result.c_str());
}
void test_favorites_weighting() {
std::vector<std::string> all = {"a", "b", "c"};
std::vector<std::string> favs = {"b"};
auto weighted = applyFavoritesWeight(all, favs, 3);
// "b" should appear 3 times total (1 original + 2 extra)
int count = 0;
for (const auto& id : weighted) {
if (id == "b") count++;
}
TEST_ASSERT_EQUAL(3, count);
TEST_ASSERT_EQUAL(5, weighted.size()); // 3 original + 2 extra
}
void test_nearest_color_black();
void test_nearest_color_white();
void test_nearest_color_red();
void test_nearest_color_green();
void test_nearest_color_blue();
void test_nearest_color_yellow();
void test_dither_solid_black();
void test_dither_solid_white();
void test_dither_produces_valid_indices();
void test_blue_noise_breaks_pattern_regularity();
void test_blue_noise_dither_valid_indices();
void setUp() {}
void tearDown() {}
int main() {
UNITY_BEGIN();
RUN_TEST(test_advance_random_no_repeats_until_wrap);
RUN_TEST(test_advance_wraps_at_end);
RUN_TEST(test_empty_queue_returns_empty);
RUN_TEST(test_favorites_weighting);
RUN_TEST(test_nearest_color_black);
RUN_TEST(test_nearest_color_white);
RUN_TEST(test_nearest_color_red);
RUN_TEST(test_nearest_color_green);
RUN_TEST(test_nearest_color_blue);
RUN_TEST(test_nearest_color_yellow);
RUN_TEST(test_dither_solid_black);
RUN_TEST(test_dither_solid_white);
RUN_TEST(test_dither_produces_valid_indices);
RUN_TEST(test_blue_noise_breaks_pattern_regularity);
RUN_TEST(test_blue_noise_dither_valid_indices);
UNITY_END();
return 0;
}

506
tools/log-compare.html Normal file
View File

@@ -0,0 +1,506 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PaperColor Battery Comparison</title>
<script src="./vendor/chart.umd.min.js"></script>
<script src="./vendor/chartjs-adapter-date-fns.bundle.min.js"></script>
<style>
:root {
--bg: #f8fafc;
--card: #ffffff;
--text: #1e293b;
--muted: #64748b;
--border: #e2e8f0;
--accent: #2563eb;
--accent2: #d97706;
--success: #16a34a;
--danger: #dc2626;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--card: #1e293b;
--text: #f1f5f9;
--muted: #94a3b8;
--border: #334155;
--accent: #60a5fa;
--accent2: #fbbf24;
--success: #4ade80;
--danger: #f87171;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
padding: 24px;
}
h1 { font-size: 22px; margin-bottom: 4px; }
h2 { font-size: 16px; color: var(--muted); margin-bottom: 20px; }
h3 { font-size: 14px; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
.drop-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 700px) { .drop-row { grid-template-columns: 1fr; } }
.drop-zone {
border: 2px dashed var(--border);
border-radius: 12px;
padding: 32px 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.drop-zone:hover, .drop-zone.dragover {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 5%, transparent);
}
.drop-zone.loaded {
border-color: var(--success);
border-style: solid;
}
.drop-zone input { display: none; }
.drop-zone p { color: var(--muted); font-size: 14px; }
.drop-zone p strong { color: var(--text); }
.drop-zone .label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; margin-bottom: 8px; }
.drop-zone .label-a { color: var(--accent); }
.drop-zone .label-b { color: var(--accent2); }
.drop-zone .meta { font-size: 12px; color: var(--muted); margin-top: 8px; }
.hidden { display: none !important; }
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
}
.stat-card .label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
.stat-card .values { display: flex; gap: 16px; margin-top: 4px; align-items: baseline; }
.stat-card .val-a { font-size: 18px; font-weight: 600; color: var(--accent); }
.stat-card .val-b { font-size: 18px; font-weight: 600; color: var(--accent2); }
.stat-card .val-delta { font-size: 14px; font-weight: 500; }
.stat-card .better { color: var(--success); }
.stat-card .worse { color: var(--danger); }
.controls {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 16px;
}
.controls label { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 6px; cursor: pointer; }
.controls input[type="checkbox"] { width: 16px; height: 16px; }
.chart-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.chart-card canvas { width: 100% !important; height: 280px !important; }
.chart-card.small canvas { height: 180px !important; }
.chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 900px) { .chart-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<h1>PaperColor Battery Comparison</h1>
<h2>Compare battery drain between two wake log captures</h2>
<div class="drop-row">
<div class="drop-zone" id="dropA">
<div class="label label-a">Log A (Baseline)</div>
<p><strong>Drop CSV here</strong> or click to browse</p>
<input type="file" id="fileA" accept=".csv,text/csv">
<div class="meta" id="metaA"></div>
</div>
<div class="drop-zone" id="dropB">
<div class="label label-b">Log B (Comparison)</div>
<p><strong>Drop CSV here</strong> or click to browse</p>
<input type="file" id="fileB" accept=".csv,text/csv">
<div class="meta" id="metaB"></div>
</div>
</div>
<div id="viewer" class="hidden">
<div class="summary" id="summary"></div>
<div class="controls">
<label><input type="checkbox" id="sleepOnly" checked> Sleep mode only (exclude normal/charging)</label>
<label><input type="checkbox" id="showFailures" checked> Show failure events</label>
</div>
<div class="chart-card">
<h3>Battery % over Elapsed Time</h3>
<canvas id="chartBattery"></canvas>
</div>
<div class="chart-grid">
<div class="chart-card small">
<h3>Drain Rate (%/hr, rolling 10-entry window)</h3>
<canvas id="chartDrainRate"></canvas>
</div>
<div class="chart-card small">
<h3>Cumulative Failures</h3>
<canvas id="chartFailures"></canvas>
</div>
</div>
</div>
<script>
let logA = null, logB = null;
let nameA = 'Log A', nameB = 'Log B';
let charts = {};
// --- CSV Parsing ---
function parseCSV(text) {
const lines = text.trim().split('\n').filter(l => l.trim());
return lines.map(line => {
const p = line.split(',');
return {
timestamp: p[0] || '',
battery_pct: parseInt(p[1]) || 0,
free_psram_kb: parseInt(p[2]) || 0,
free_heap_kb: parseInt(p[3]) || 0,
wifi_rssi: parseInt(p[4]) || 0,
wifi_ms: parseInt(p[5]) || 0,
cycle_ms: parseInt(p[6]) || 0,
rtc_valid: p[7] === '1',
boot_count: parseInt(p[8]) || 0,
filter: p[9] || '',
asset_id: p[10] || '',
photo_date: p[11] || '',
result: p[12] || '',
wake_type: p[13] || '',
last_error_ts: p[14] || '',
errors_total: parseInt(p[15]) || 0,
};
}).filter(r => r.timestamp && r.timestamp !== 'no_clock');
}
function elapsedHours(rows) {
if (rows.length === 0) return [];
const t0 = new Date(rows[0].timestamp).getTime();
return rows.map(r => (new Date(r.timestamp).getTime() - t0) / 3600000);
}
function computeStats(rows) {
if (rows.length === 0) return null;
const hours = elapsedHours(rows);
const totalDuration = hours[hours.length - 1];
const startBatt = rows[0].battery_pct;
// Find the minimum battery point (end of actual drain, before any charging tail)
let minBatt = startBatt;
let minIdx = 0;
for (let i = 0; i < rows.length; i++) {
if (rows[i].battery_pct <= minBatt) {
minBatt = rows[i].battery_pct;
minIdx = i;
}
}
const drainDuration = hours[minIdx];
const drainRate = drainDuration > 0 ? (startBatt - minBatt) / drainDuration : 0;
const failures = rows.slice(0, minIdx + 1).filter(r => r.result !== 'ok').length;
const failRate = (minIdx + 1) > 0 ? (failures / (minIdx + 1) * 100) : 0;
const first = rows[0].timestamp;
const last = rows[rows.length - 1].timestamp;
return { totalDuration, drainDuration, startBatt, minBatt, drainRate, failures, failRate, first, last, count: rows.length, drainEntries: minIdx + 1 };
}
// --- File Input ---
function setupDrop(zoneId, inputId, metaId, slot) {
const zone = document.getElementById(zoneId);
const input = document.getElementById(inputId);
const meta = document.getElementById(metaId);
zone.addEventListener('click', () => input.click());
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
if (e.dataTransfer.files.length) loadLog(e.dataTransfer.files[0], slot, zone, meta);
});
input.addEventListener('change', e => {
if (e.target.files.length) loadLog(e.target.files[0], slot, zone, meta);
});
}
function loadLog(file, slot, zone, metaEl) {
const reader = new FileReader();
reader.onload = e => {
const rows = parseCSV(e.target.result);
if (rows.length === 0) return;
if (slot === 'A') { logA = rows; nameA = file.name; }
else { logB = rows; nameB = file.name; }
zone.classList.add('loaded');
const stats = computeStats(rows);
metaEl.textContent = `${file.name} | ${rows.length} entries | ${new Date(stats.first).toLocaleDateString()} - ${new Date(stats.last).toLocaleDateString()}`;
if (logA && logB) {
document.getElementById('viewer').classList.remove('hidden');
render();
}
};
reader.readAsText(file);
}
setupDrop('dropA', 'fileA', 'metaA', 'A');
setupDrop('dropB', 'fileB', 'metaB', 'B');
// --- Render ---
document.getElementById('showFailures').addEventListener('change', render);
document.getElementById('sleepOnly').addEventListener('change', render);
function getFilteredLogs() {
const sleepOnly = document.getElementById('sleepOnly').checked;
const a = sleepOnly ? logA.filter(r => r.wake_type === 'sleep') : logA;
const b = sleepOnly ? logB.filter(r => r.wake_type === 'sleep') : logB;
return { a, b };
}
function render() {
if (!logA || !logB) return;
renderSummary();
renderCharts();
}
function renderSummary() {
const { a: filtA, b: filtB } = getFilteredLogs();
const a = computeStats(filtA);
const b = computeStats(filtB);
if (!a || !b) return;
const drainDelta = b.drainRate - a.drainRate;
const drainBetter = drainDelta < 0;
const failDelta = b.failRate - a.failRate;
const failBetter = failDelta < 0;
document.getElementById('summary').innerHTML = `
<div class="stat-card">
<div class="label">Drain Duration (to min battery)</div>
<div class="values">
<span class="val-a">${a.drainDuration.toFixed(1)}h</span>
<span class="val-b">${b.drainDuration.toFixed(1)}h</span>
</div>
</div>
<div class="stat-card">
<div class="label">Battery (start / min)</div>
<div class="values">
<span class="val-a">${a.startBatt}% / ${a.minBatt}%</span>
<span class="val-b">${b.startBatt}% / ${b.minBatt}%</span>
</div>
</div>
<div class="stat-card">
<div class="label">Drain Rate (to min)</div>
<div class="values">
<span class="val-a">${a.drainRate.toFixed(2)}%/hr</span>
<span class="val-b">${b.drainRate.toFixed(2)}%/hr</span>
<span class="val-delta ${drainBetter ? 'better' : 'worse'}">${drainDelta > 0 ? '+' : ''}${drainDelta.toFixed(2)}</span>
</div>
</div>
<div class="stat-card">
<div class="label">Failure Rate (drain period)</div>
<div class="values">
<span class="val-a">${a.failRate.toFixed(1)}%</span>
<span class="val-b">${b.failRate.toFixed(1)}%</span>
<span class="val-delta ${failBetter ? 'better' : 'worse'}">${failDelta > 0 ? '+' : ''}${failDelta.toFixed(1)}%</span>
</div>
</div>
<div class="stat-card">
<div class="label">Failures (drain period)</div>
<div class="values">
<span class="val-a">${a.failures}</span>
<span class="val-b">${b.failures}</span>
</div>
</div>
<div class="stat-card">
<div class="label">Entries (total / drain)</div>
<div class="values">
<span class="val-a">${a.count} / ${a.drainEntries}</span>
<span class="val-b">${b.count} / ${b.drainEntries}</span>
</div>
</div>
`;
}
function renderCharts() {
Object.values(charts).forEach(c => c.destroy());
charts = {};
const { a: filtA, b: filtB } = getFilteredLogs();
const showFail = document.getElementById('showFailures').checked;
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const gridColor = isDark ? '#334155' : '#e2e8f0';
const textColor = isDark ? '#94a3b8' : '#64748b';
const accentA = isDark ? '#60a5fa' : '#2563eb';
const accentB = isDark ? '#fbbf24' : '#d97706';
const failColorA = isDark ? '#f87171' : '#dc2626';
const failColorB = isDark ? '#fb923c' : '#ea580c';
const hoursA = elapsedHours(filtA);
const hoursB = elapsedHours(filtB);
// Battery chart datasets
const battA = filtA.map((r, i) => ({ x: hoursA[i], y: r.battery_pct }));
const battB = filtB.map((r, i) => ({ x: hoursB[i], y: r.battery_pct }));
const datasets = [
{ label: nameA, data: battA, borderColor: accentA, backgroundColor: accentA + '44', pointRadius: 1.5, showLine: true, tension: 0.3, borderWidth: 2 },
{ label: nameB, data: battB, borderColor: accentB, backgroundColor: accentB + '44', pointRadius: 1.5, showLine: true, tension: 0.3, borderWidth: 2 },
];
if (showFail) {
const failPtsA = filtA.filter(r => r.result !== 'ok').map(r => {
const idx = filtA.indexOf(r);
return { x: hoursA[idx], y: r.battery_pct };
});
const failPtsB = filtB.filter(r => r.result !== 'ok').map(r => {
const idx = filtB.indexOf(r);
return { x: hoursB[idx], y: r.battery_pct };
});
if (failPtsA.length > 0) {
datasets.push({
label: nameA + ' failures',
data: failPtsA,
borderColor: failColorA,
backgroundColor: failColorA + '88',
pointRadius: 4,
pointStyle: 'crossRot',
showLine: false,
});
}
if (failPtsB.length > 0) {
datasets.push({
label: nameB + ' failures',
data: failPtsB,
borderColor: failColorB,
backgroundColor: failColorB + '88',
pointRadius: 4,
pointStyle: 'crossRot',
showLine: false,
});
}
}
charts.battery = new Chart(document.getElementById('chartBattery'), {
type: 'scatter',
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: false },
plugins: { legend: { labels: { color: textColor, font: { size: 12 } } } },
scales: {
x: { type: 'linear', title: { display: true, text: 'Elapsed Hours', color: textColor }, grid: { color: gridColor }, ticks: { color: textColor } },
y: { title: { display: true, text: 'Battery %', color: textColor }, grid: { color: gridColor }, ticks: { color: textColor }, min: 0, max: 105 }
}
}
});
// Truncate rows to drain period only (up to min battery, before charging tail)
function drainOnly(rows) {
if (rows.length === 0) return rows;
let minBatt = rows[0].battery_pct;
let minIdx = 0;
for (let i = 0; i < rows.length; i++) {
if (rows[i].battery_pct <= minBatt) {
minBatt = rows[i].battery_pct;
minIdx = i;
}
}
return rows.slice(0, minIdx + 1);
}
const drainA = drainOnly(filtA);
const drainB = drainOnly(filtB);
const drainHoursA = elapsedHours(drainA);
const drainHoursB = elapsedHours(drainB);
// Drain rate chart (rolling window, drain period only)
const windowSize = 10;
function drainRateSeries(rows, hours) {
const pts = [];
for (let i = windowSize; i < rows.length; i++) {
const dt = hours[i] - hours[i - windowSize];
if (dt <= 0) continue;
const dBatt = rows[i - windowSize].battery_pct - rows[i].battery_pct;
pts.push({ x: hours[i], y: dBatt / dt });
}
return pts;
}
charts.drainRate = new Chart(document.getElementById('chartDrainRate'), {
type: 'scatter',
data: { datasets: [
{ label: nameA, data: drainRateSeries(drainA, drainHoursA), borderColor: accentA, backgroundColor: accentA + '44', pointRadius: 1, showLine: true, tension: 0.4, borderWidth: 1.5 },
{ label: nameB, data: drainRateSeries(drainB, drainHoursB), borderColor: accentB, backgroundColor: accentB + '44', pointRadius: 1, showLine: true, tension: 0.4, borderWidth: 1.5 },
]},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { labels: { color: textColor, font: { size: 11 } } } },
scales: {
x: { type: 'linear', title: { display: true, text: 'Elapsed Hours', color: textColor }, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 10 } } },
y: { title: { display: true, text: '%/hr', color: textColor }, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 10 } } }
}
}
});
// Cumulative failures chart (drain period only)
function cumulFailSeries(rows, hours) {
const pts = [];
let count = 0;
for (let i = 0; i < rows.length; i++) {
if (rows[i].result !== 'ok') count++;
pts.push({ x: hours[i], y: count });
}
return pts;
}
charts.failures = new Chart(document.getElementById('chartFailures'), {
type: 'scatter',
data: { datasets: [
{ label: nameA, data: cumulFailSeries(drainA, drainHoursA), borderColor: accentA, backgroundColor: accentA + '22', pointRadius: 0, showLine: true, stepped: 'before', borderWidth: 2, fill: true },
{ label: nameB, data: cumulFailSeries(drainB, drainHoursB), borderColor: accentB, backgroundColor: accentB + '22', pointRadius: 0, showLine: true, stepped: 'before', borderWidth: 2, fill: true },
]},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { labels: { color: textColor, font: { size: 11 } } } },
scales: {
x: { type: 'linear', title: { display: true, text: 'Elapsed Hours', color: textColor }, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 10 } } },
y: { title: { display: true, text: 'Failures', color: textColor }, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 10 } }, beginAtZero: true }
}
}
});
}
</script>
</body>
</html>

432
tools/log-viewer.html Normal file
View File

@@ -0,0 +1,432 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PaperColor Wake Log Viewer</title>
<script src="./vendor/chart.umd.min.js"></script>
<script src="./vendor/chartjs-adapter-date-fns.bundle.min.js"></script>
<style>
:root {
--bg: #f8fafc;
--card: #ffffff;
--text: #1e293b;
--muted: #64748b;
--border: #e2e8f0;
--accent: #2563eb;
--success: #16a34a;
--danger: #dc2626;
--warn: #d97706;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--card: #1e293b;
--text: #f1f5f9;
--muted: #94a3b8;
--border: #334155;
--accent: #60a5fa;
--success: #4ade80;
--danger: #f87171;
--warn: #fbbf24;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
padding: 24px;
}
h1 { font-size: 22px; margin-bottom: 4px; }
h2 { font-size: 16px; color: var(--muted); margin-bottom: 16px; }
h3 { font-size: 14px; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
.drop-zone {
border: 2px dashed var(--border);
border-radius: 12px;
padding: 48px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
margin-bottom: 24px;
}
.drop-zone:hover, .drop-zone.dragover {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 5%, transparent);
}
.drop-zone input { display: none; }
.drop-zone p { color: var(--muted); font-size: 15px; }
.drop-zone p strong { color: var(--text); }
.hidden { display: none !important; }
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
}
.stat-card .label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
.stat-card .value { font-size: 20px; font-weight: 600; margin-top: 2px; }
.filters {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filters label { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 4px; cursor: pointer; }
.filters input[type="checkbox"] { width: 16px; height: 16px; }
.chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 900px) { .chart-grid { grid-template-columns: 1fr; } }
.chart-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.chart-card canvas { width: 100% !important; height: 200px !important; }
.table-wrap {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
overflow-x: auto;
margin-bottom: 24px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
white-space: nowrap;
}
th, td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border); }
th {
background: var(--bg);
position: sticky;
top: 0;
cursor: pointer;
user-select: none;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
color: var(--muted);
}
th:hover { color: var(--text); }
th .sort-arrow { margin-left: 4px; font-size: 10px; }
.result-ok { color: var(--success); font-weight: 600; }
.result-fail { color: var(--danger); font-weight: 600; }
.wake-sleep { color: var(--warn); }
.wake-normal { color: var(--accent); }
.id-cell { max-width: 100px; overflow: hidden; text-overflow: ellipsis; }
</style>
</head>
<body>
<h1>PaperColor Wake Log Viewer</h1>
<h2>Drop or select a wake_log.csv file to visualize</h2>
<div class="drop-zone" id="dropZone">
<p><strong>Drop wake_log.csv here</strong> or click to browse</p>
<input type="file" id="fileInput" accept=".csv,text/csv">
</div>
<div id="viewer" class="hidden">
<div class="summary" id="summary"></div>
<div class="filters" id="filters">
<label><input type="checkbox" id="filterNormal" checked> Normal</label>
<label><input type="checkbox" id="filterSleep" checked> Sleep</label>
<label><input type="checkbox" id="filterOk" checked> OK</label>
<label><input type="checkbox" id="filterFail" checked> Failures</label>
</div>
<div class="chart-grid" id="charts">
<div class="chart-card"><h3>Battery %</h3><canvas id="chartBattery"></canvas></div>
<div class="chart-card"><h3>WiFi RSSI (dBm)</h3><canvas id="chartRssi"></canvas></div>
<div class="chart-card"><h3>Free Memory (KB)</h3><canvas id="chartMemory"></canvas></div>
<div class="chart-card"><h3>Cycle Duration (ms)</h3><canvas id="chartCycle"></canvas></div>
</div>
<div class="table-wrap">
<table>
<thead id="tableHead"></thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</div>
<script>
const COLUMNS = [
'timestamp','battery_pct','free_psram_kb','free_heap_kb','wifi_rssi',
'wifi_ms','cycle_ms','rtc_valid','boot_count','filter',
'asset_id','photo_date','result','wake_type','last_error_ts','errors_total'
];
let allRows = [];
let sortCol = 0;
let sortAsc = false;
let charts = {};
// --- File input ---
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) loadFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', e => { if (e.target.files.length) loadFile(e.target.files[0]); });
function loadFile(file) {
const reader = new FileReader();
reader.onload = e => {
const text = e.target.result.trim();
if (!text) return;
parseCSV(text);
document.getElementById('viewer').classList.remove('hidden');
dropZone.classList.add('hidden');
render();
};
reader.readAsText(file);
}
function parseCSV(text) {
const lines = text.split('\n').filter(l => l.trim());
allRows = lines.map(line => {
const parts = line.split(',');
return {
timestamp: parts[0] || '',
battery_pct: parseInt(parts[1]) || 0,
free_psram_kb: parseInt(parts[2]) || 0,
free_heap_kb: parseInt(parts[3]) || 0,
wifi_rssi: parseInt(parts[4]) || 0,
wifi_ms: parseInt(parts[5]) || 0,
cycle_ms: parseInt(parts[6]) || 0,
rtc_valid: parts[7] === '1',
boot_count: parseInt(parts[8]) || 0,
filter: parts[9] || '',
asset_id: parts[10] || '',
photo_date: parts[11] || '',
result: parts[12] || '',
wake_type: parts[13] || '',
last_error_ts: parts[14] || '',
errors_total: parseInt(parts[15]) || 0,
};
});
}
function getFiltered() {
const showNormal = document.getElementById('filterNormal').checked;
const showSleep = document.getElementById('filterSleep').checked;
const showOk = document.getElementById('filterOk').checked;
const showFail = document.getElementById('filterFail').checked;
return allRows.filter(r => {
if (r.wake_type === 'normal' && !showNormal) return false;
if (r.wake_type === 'sleep' && !showSleep) return false;
if (r.result === 'ok' && !showOk) return false;
if (r.result !== 'ok' && !showFail) return false;
return true;
});
}
// --- Filters ---
document.querySelectorAll('.filters input').forEach(el => {
el.addEventListener('change', render);
});
// --- Render ---
function render() {
const rows = getFiltered();
renderSummary(rows);
renderCharts(rows);
renderTable(rows);
}
function renderSummary(rows) {
if (rows.length === 0) {
document.getElementById('summary').innerHTML = '<div class="stat-card"><div class="value">No data</div></div>';
return;
}
const first = rows[0].timestamp;
const last = rows[rows.length - 1].timestamp;
const okCount = rows.filter(r => r.result === 'ok').length;
const successRate = ((okCount / rows.length) * 100).toFixed(1);
const avgBatt = (rows.reduce((s, r) => s + r.battery_pct, 0) / rows.length).toFixed(0);
const rssiVals = rows.map(r => r.wifi_rssi).filter(v => v !== 0);
const minRssi = rssiVals.length ? Math.min(...rssiVals) : 'N/A';
const maxRssi = rssiVals.length ? Math.max(...rssiVals) : 'N/A';
const bootMin = Math.min(...rows.map(r => r.boot_count));
const bootMax = Math.max(...rows.map(r => r.boot_count));
const totalErrors = rows.length > 0 ? rows[rows.length - 1].errors_total : 0;
const formatTs = ts => {
if (!ts || ts === 'no_clock') return ts;
try { return new Date(ts).toLocaleString(); } catch { return ts; }
};
document.getElementById('summary').innerHTML = `
<div class="stat-card"><div class="label">Entries</div><div class="value">${rows.length}</div></div>
<div class="stat-card"><div class="label">Success Rate</div><div class="value">${successRate}%</div></div>
<div class="stat-card"><div class="label">Avg Battery</div><div class="value">${avgBatt}%</div></div>
<div class="stat-card"><div class="label">RSSI Range</div><div class="value">${maxRssi} / ${minRssi} dBm</div></div>
<div class="stat-card"><div class="label">Boot Count</div><div class="value">${bootMin}–${bootMax}</div></div>
<div class="stat-card"><div class="label">Total Errors</div><div class="value">${totalErrors}</div></div>
<div class="stat-card"><div class="label">First Entry</div><div class="value" style="font-size:13px">${formatTs(first)}</div></div>
<div class="stat-card"><div class="label">Last Entry</div><div class="value" style="font-size:13px">${formatTs(last)}</div></div>
`;
}
function makeDatasets(rows, key, label, color) {
const normalPts = [];
const sleepPts = [];
const failPts = [];
rows.forEach(r => {
const pt = { x: new Date(r.timestamp), y: r[key] };
if (r.result !== 'ok') failPts.push(pt);
else if (r.wake_type === 'sleep') sleepPts.push(pt);
else normalPts.push(pt);
});
return [
{ label: label + ' (normal)', data: normalPts, borderColor: '#2563eb', backgroundColor: '#2563eb88', pointRadius: 3, showLine: true, tension: 0.3 },
{ label: label + ' (sleep)', data: sleepPts, borderColor: '#d97706', backgroundColor: '#d9770688', pointRadius: 3, showLine: true, tension: 0.3 },
{ label: label + ' (fail)', data: failPts, borderColor: '#dc2626', backgroundColor: '#dc262688', pointRadius: 5, pointStyle: 'crossRot', showLine: false },
];
}
function chartOpts(yLabel) {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const gridColor = isDark ? '#334155' : '#e2e8f0';
const textColor = isDark ? '#94a3b8' : '#64748b';
return {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: false },
plugins: { legend: { display: true, labels: { boxWidth: 10, font: { size: 11 }, color: textColor } } },
scales: {
x: { type: 'time', time: { tooltipFormat: 'MMM d, HH:mm' }, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 10 } } },
y: { title: { display: true, text: yLabel, color: textColor, font: { size: 11 } }, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 10 } } }
}
};
}
function renderCharts(rows) {
Object.values(charts).forEach(c => c.destroy());
charts = {};
const validRows = rows.filter(r => r.timestamp && r.timestamp !== 'no_clock');
charts.battery = new Chart(document.getElementById('chartBattery'), {
type: 'scatter',
data: { datasets: makeDatasets(validRows, 'battery_pct', 'Battery', '#2563eb') },
options: chartOpts('Battery %')
});
charts.rssi = new Chart(document.getElementById('chartRssi'), {
type: 'scatter',
data: { datasets: makeDatasets(validRows, 'wifi_rssi', 'RSSI', '#2563eb') },
options: chartOpts('dBm')
});
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const gridColor = isDark ? '#334155' : '#e2e8f0';
const textColor = isDark ? '#94a3b8' : '#64748b';
const memNormal = [], memSleep = [];
const heapNormal = [], heapSleep = [];
validRows.forEach(r => {
const pt1 = { x: new Date(r.timestamp), y: r.free_psram_kb };
const pt2 = { x: new Date(r.timestamp), y: r.free_heap_kb };
if (r.wake_type === 'sleep') { memSleep.push(pt1); heapSleep.push(pt2); }
else { memNormal.push(pt1); heapNormal.push(pt2); }
});
charts.memory = new Chart(document.getElementById('chartMemory'), {
type: 'scatter',
data: { datasets: [
{ label: 'PSRAM (normal)', data: memNormal, borderColor: '#2563eb', backgroundColor: '#2563eb88', pointRadius: 3, showLine: true, tension: 0.3 },
{ label: 'PSRAM (sleep)', data: memSleep, borderColor: '#d97706', backgroundColor: '#d9770688', pointRadius: 3, showLine: true, tension: 0.3 },
{ label: 'Heap (normal)', data: heapNormal, borderColor: '#16a34a', backgroundColor: '#16a34a88', pointRadius: 3, showLine: true, tension: 0.3 },
{ label: 'Heap (sleep)', data: heapSleep, borderColor: '#9333ea', backgroundColor: '#9333ea88', pointRadius: 3, showLine: true, tension: 0.3 },
]},
options: chartOpts('KB')
});
charts.cycle = new Chart(document.getElementById('chartCycle'), {
type: 'scatter',
data: { datasets: makeDatasets(validRows, 'cycle_ms', 'Cycle', '#2563eb') },
options: chartOpts('ms')
});
}
function renderTable(rows) {
const thead = document.getElementById('tableHead');
const tbody = document.getElementById('tableBody');
thead.innerHTML = '<tr>' + COLUMNS.map((col, i) =>
`<th onclick="sortTable(${i})">${col.replace(/_/g, ' ')}<span class="sort-arrow">${sortCol === i ? (sortAsc ? '▲' : '▼') : ''}</span></th>`
).join('') + '</tr>';
const sorted = [...rows].sort((a, b) => {
const key = COLUMNS[sortCol];
let av = a[key], bv = b[key];
if (typeof av === 'number') return sortAsc ? av - bv : bv - av;
av = String(av); bv = String(bv);
return sortAsc ? av.localeCompare(bv) : bv.localeCompare(av);
});
tbody.innerHTML = sorted.map(r => {
const resultClass = r.result === 'ok' ? 'result-ok' : 'result-fail';
const wakeClass = r.wake_type === 'sleep' ? 'wake-sleep' : 'wake-normal';
const shortTs = r.timestamp && r.timestamp !== 'no_clock'
? new Date(r.timestamp).toLocaleString(undefined, { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' })
: r.timestamp;
const shortId = r.asset_id ? r.asset_id.substring(0, 8) + '...' : '';
const shortPhotoDate = r.photo_date && r.photo_date !== ''
? r.photo_date.substring(0, 10)
: '';
return `<tr>
<td>${shortTs}</td>
<td>${r.battery_pct}%</td>
<td>${r.free_psram_kb}</td>
<td>${r.free_heap_kb}</td>
<td>${r.wifi_rssi}</td>
<td>${r.wifi_ms}</td>
<td>${r.cycle_ms}</td>
<td>${r.rtc_valid ? '✓' : '✗'}</td>
<td>${r.boot_count}</td>
<td>${r.filter}</td>
<td class="id-cell" title="${r.asset_id}">${shortId}</td>
<td>${shortPhotoDate}</td>
<td class="${resultClass}">${r.result}</td>
<td class="${wakeClass}">${r.wake_type}</td>
<td>${r.last_error_ts}</td>
<td>${r.errors_total}</td>
</tr>`;
}).join('');
}
window.sortTable = function(col) {
if (sortCol === col) sortAsc = !sortAsc;
else { sortCol = col; sortAsc = true; }
render();
};
</script>
</body>
</html>

20
tools/vendor/chart.umd.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long