Files
immich-frame/docs/superpowers/plans/2026-08-03-immich-photo-frame.md

3867 lines
121 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Immich Photo Frame Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build firmware for the M5Stack PaperColor that displays photos from a self-hosted Immich instance as a configurable slideshow, with a web-based management UI.
**Architecture:** FreeRTOS multi-task firmware on ESP32-S3. Display task (Core 1) handles JPEG decode and 6-color dithering. Web server and battery monitor tasks (Core 0) run alongside WiFi. Automatic light sleep between refreshes keeps idle power at ~5-15mA while the web UI remains accessible.
**Tech Stack:** Arduino framework via PlatformIO, M5Unified/M5GFX/M5PM1 libraries, ESPAsyncWebServer, ArduinoJson, TJpgDec for JPEG decode, LittleFS for web assets, NVS for settings.
## Global Constraints
- Platform: `espressif32 @ 6.12.0`
- Board target: `esp32s3box` (closest match for PaperColor in PlatformIO)
- Arduino framework with ESP-IDF components accessible
- 16MB flash, 8MB PSRAM (`qio_opi` memory type)
- Display: 600×400 landscape (device rotated), E Ink Spectra 6 (6 colors)
- USB CDC on boot enabled (`ARDUINO_USB_CDC_ON_BOOT=1`)
- All audio permanently disabled (GPIO 45 LOW, GPIO 46 LOW)
- Buttons: BTN_TOP=G1, BTN_UP=G9, BTN_DOWN=G10
- RGB LEDs: G21 (NeoPixel, 2 LEDs)
- M5PM1 power IC at I2C 0x6E on SYS_SDA=G3, SYS_SCL=G2
---
## File Structure
```
immich-frame/
├── platformio.ini # Build configuration
├── partitions_custom.csv # 16MB partition table
├── src/
│ ├── main.cpp # Entry point, FreeRTOS task creation
│ ├── config.h # Pin definitions, constants, defaults
│ ├── settings.h # Settings manager header
│ ├── settings.cpp # NVS read/write for all config
│ ├── wifi_manager.h # WiFi manager header
│ ├── wifi_manager.cpp # Station + AP + mDNS
│ ├── power_manager.h # Power manager header
│ ├── power_manager.cpp # M5PM1, battery, LEDs, sleep
│ ├── button_handler.h # Button handler header
│ ├── button_handler.cpp # GPIO interrupts, debounce, combos
│ ├── immich_client.h # Immich API client header
│ ├── immich_client.cpp # HTTPS calls to Immich REST API
│ ├── photo_queue.h # Photo queue/selection header
│ ├── photo_queue.cpp # Queue management, cycling modes
│ ├── image_pipeline.h # Image processing header
│ ├── image_pipeline.cpp # JPEG decode, resize, dither
│ ├── display_manager.h # Display manager header
│ ├── display_manager.cpp # E-ink framebuffer + refresh
│ ├── web_server.h # Web server header
│ └── web_server.cpp # REST API + static file serving
├── data/ # LittleFS partition (web UI)
│ ├── index.html # Single-page app shell
│ ├── setup.html # AP mode captive portal
│ ├── app.js # Web UI logic
│ └── style.css # Styles
├── test/
│ ├── test_native/
│ │ ├── test_settings.cpp # Settings serialization tests
│ │ ├── test_photo_queue.cpp # Queue cycling logic tests
│ │ └── test_image_pipeline.cpp # Dithering algorithm tests
│ └── README.md # How to run tests
└── docs/
└── superpowers/
├── specs/
└── plans/
```
---
### Task 1: Project Scaffolding
**Files:**
- Create: `platformio.ini`
- Create: `partitions_custom.csv`
- Create: `src/config.h`
- Create: `src/main.cpp`
**Interfaces:**
- Consumes: Nothing (first task)
- Produces: Build system that compiles an empty firmware that boots, prints to serial, and can be flashed to the PaperColor
- [ ] **Step 1: Create `platformio.ini`**
```ini
; PlatformIO configuration for M5Stack PaperColor Immich Frame
[env:m5stack-papercolor]
platform = espressif32 @ 6.12.0
board = esp32s3box
framework = arduino
board_build.partitions = partitions_custom.csv
board_upload.flash_size = 16MB
board_upload.maximum_size = 16777216
board_build.arduino.memory_type = qio_opi
board_build.filesystem = littlefs
monitor_speed = 115200
upload_speed = 921600
build_flags =
-DESP32S3
-DBOARD_HAS_PSRAM
-DCORE_DEBUG_LEVEL=3
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
lib_deps =
m5stack/M5Unified @ ^0.2.2
m5stack/M5GFX @ ^0.2.5
https://github.com/m5stack/M5PM1.git
me-no-dev/ESP Async WebServer @ ^1.2.4
bblanchon/ArduinoJson @ ^7.0.0
[env:native]
platform = native
build_flags = -std=c++17
test_framework = unity
lib_deps =
bblanchon/ArduinoJson @ ^7.0.0
```
- [ ] **Step 2: Create `partitions_custom.csv`**
```csv
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
otadata, data, ota, 0xf000, 0x2000,
app0, app, ota_0, 0x10000, 0x680000,
app1, app, ota_1, 0x690000, 0x680000,
littlefs, data, spiffs, 0xD10000, 0x80000,
coredump, data, coredump, 0xD90000, 0x10000,
```
- [ ] **Step 3: Create `src/config.h`**
```cpp
#pragma once
// --- 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
// RGB LEDs (NeoPixel)
#define PIN_RGB_LED 21
#define NUM_RGB_LEDS 2
// Audio (permanently disabled)
#define PIN_AUDIO_PWR_EN 45
#define PIN_SPK_EN 46
// E-Paper SPI
#define PIN_EPD_CLK 15
#define PIN_EPD_MOSI 13
#define PIN_EPD_CS 44
#define PIN_EPD_DC 43
#define PIN_EPD_BUSY 11
#define PIN_EPD_RST 12
// I2C System Bus (M5PM1, SHT40, RTC)
#define PIN_SYS_SDA 3
#define PIN_SYS_SCL 2
// --- Display ---
#define DISPLAY_WIDTH 600
#define DISPLAY_HEIGHT 400
#define DISPLAY_COLORS 6
// --- Defaults ---
#define DEFAULT_INTERVAL_MIN 5
#define DEFAULT_CYCLE_MODE 0 // 0=random
#define DEFAULT_IMG_QUALITY 0 // 0=preview
#define DEFAULT_META_FLAGS 0x00
#define DEFAULT_META_POS 0 // 0=bottom
#define DEFAULT_LED_BRIGHTNESS 50
#define DEFAULT_IMMICH_URL "https://photos.example.com"
// --- Timing ---
#define BUTTON_DEBOUNCE_MS 50
#define COMBO_HOLD_MS 3000
#define FACTORY_RESET_HOLD_MS 5000
#define BATTERY_CHECK_INTERVAL_MS 60000
#define LED_PULSE_DURATION_MS 100
#define QUEUE_RESYNC_HOURS 24
#define WIFI_CONNECT_TIMEOUT_MS 15000
#define WIFI_MAX_RETRIES 3
// --- Battery Thresholds ---
#define BATTERY_LOW_PCT 25
#define BATTERY_CRITICAL_PCT 10
#define BATTERY_SHUTDOWN_PCT 5
#define BATTERY_WARN_INTERVAL_MS 300000 // 5 min
#define BATTERY_CRIT_INTERVAL_MS 120000 // 2 min
// --- Portrait Pairing ---
#define PORTRAIT_LOOKAHEAD 5
#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
```
- [ ] **Step 4: Create `src/main.cpp` (minimal boot skeleton)**
```cpp
#include <Arduino.h>
#include <M5Unified.h>
#include "config.h"
void setup() {
auto cfg = M5.config();
M5.begin(cfg);
Serial.begin(115200);
Serial.println("[main] Immich Frame booting...");
// 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);
Serial.printf("[main] Free heap: %d, PSRAM: %d\n",
ESP.getFreeHeap(), ESP.getFreePsram());
Serial.println("[main] Boot complete (skeleton)");
}
void loop() {
M5.update();
delay(1000);
}
```
- [ ] **Step 5: Verify build compiles**
Run: `pio run -e m5stack-papercolor`
Expected: BUILD SUCCESS with no errors
- [ ] **Step 6: Flash to device and verify serial output**
Run: `pio run -e m5stack-papercolor -t upload && pio device monitor`
Expected: Serial prints "Immich Frame booting...", free heap/PSRAM values, "Boot complete (skeleton)"
- [ ] **Step 7: Commit**
```bash
git add platformio.ini partitions_custom.csv src/config.h src/main.cpp
git commit -m "feat: project scaffolding with PlatformIO config and boot skeleton"
```
---
### Task 2: Settings Manager
**Files:**
- Create: `src/settings.h`
- Create: `src/settings.cpp`
- Create: `test/test_native/test_settings.cpp`
**Interfaces:**
- Consumes: `config.h` (default values)
- Produces:
- `Settings` struct with all config fields
- `SettingsManager` class:
- `void begin()` — initialize NVS
- `Settings get()` — load all settings from NVS
- `void save(const Settings& s)` — persist all settings to NVS
- `void saveField(const char* key, uint8_t value)` — save single numeric field
- `void saveField(const char* key, const char* value)` — save single string field
- `void factoryReset()` — clear all NVS and reboot
- `bool isProvisioned()` — returns true if wifi_ssid is set
- [ ] **Step 1: Create `src/settings.h`**
```cpp
#pragma once
#include <Arduino.h>
#include "config.h"
enum class CycleMode : uint8_t {
Random = 0,
Chronological = 1,
ReverseChronological = 2,
FavoritesWeighted = 3
};
enum class ImageQuality : uint8_t {
Preview = 0,
Original = 1
};
enum class MetaPosition : uint8_t {
Bottom = 0,
Top = 1
};
// Metadata flags bitmask
constexpr uint8_t META_DATE = 0x01;
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;
// Device
uint8_t led_brightness;
uint32_t queue_cursor;
String albums_json;
};
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, const char* value);
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);
};
```
- [ ] **Step 2: Create `src/settings.cpp`**
```cpp
#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.led_brightness = readU8("led_bright", DEFAULT_LED_BRIGHTNESS);
s.queue_cursor = readU32("queue_cursor", 0);
s.albums_json = readString("albums_json", "[]");
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));
writeU8("led_bright", s.led_brightness);
writeU32("queue_cursor", s.queue_cursor);
writeString("albums_json", s.albums_json);
Serial.println("[settings] All settings saved");
}
void SettingsManager::saveField(const char* key, uint8_t value) {
writeU8(key, value);
}
void SettingsManager::saveField(const char* key, const char* value) {
writeString(key, String(value));
}
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);
}
```
- [ ] **Step 3: Verify build compiles with settings module**
Update `src/main.cpp` to include and use settings:
```cpp
#include <Arduino.h>
#include <M5Unified.h>
#include "config.h"
#include "settings.h"
SettingsManager settingsManager;
void setup() {
auto cfg = M5.config();
M5.begin(cfg);
Serial.begin(115200);
Serial.println("[main] Immich Frame booting...");
// 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);
settingsManager.begin();
Settings settings = settingsManager.get();
Serial.printf("[main] Provisioned: %s\n", settingsManager.isProvisioned() ? "yes" : "no");
Serial.printf("[main] Immich URL: %s\n", settings.immich_url.c_str());
Serial.printf("[main] Interval: %d min\n", settings.interval_min);
Serial.printf("[main] Free heap: %d, PSRAM: %d\n",
ESP.getFreeHeap(), ESP.getFreePsram());
Serial.println("[main] Boot complete");
}
void loop() {
M5.update();
delay(1000);
}
```
Run: `pio run -e m5stack-papercolor`
Expected: BUILD SUCCESS
- [ ] **Step 4: Flash and verify settings load from NVS**
Run: `pio run -e m5stack-papercolor -t upload && pio device monitor`
Expected: Serial shows "Provisioned: no", default Immich URL, interval 5 min
- [ ] **Step 5: Commit**
```bash
git add src/settings.h src/settings.cpp src/main.cpp
git commit -m "feat: settings manager with NVS persistence"
```
---
### Task 3: WiFi Manager
**Files:**
- Create: `src/wifi_manager.h`
- Create: `src/wifi_manager.cpp`
**Interfaces:**
- Consumes: `SettingsManager::get()` for WiFi credentials, `SettingsManager::isProvisioned()`
- Produces:
- `WiFiManager` class:
- `void begin(SettingsManager& settings)` — connect or start AP based on provisioning state
- `bool isConnected()` — station mode connected?
- `bool isAPMode()` — currently in AP mode?
- `String getIP()` — current IP address
- `int getRSSI()` — WiFi signal strength
- `void startAP()` — force AP mode
- `void startStation()` — attempt station connection
- `void setupMDNS(const char* hostname)` — register mDNS
- [ ] **Step 1: Create `src/wifi_manager.h`**
```cpp
#pragma once
#include <Arduino.h>
#include "settings.h"
class WiFiManager {
public:
void begin(SettingsManager& settings);
bool isConnected();
bool isAPMode();
String getIP();
int getRSSI();
void startAP();
void startStation();
void setupMDNS(const char* hostname);
private:
SettingsManager* _settings = nullptr;
bool _apMode = false;
int _retryCount = 0;
bool attemptConnection(const String& ssid, const String& pass);
};
```
- [ ] **Step 2: Create `src/wifi_manager.cpp`**
```cpp
#include "wifi_manager.h"
#include <WiFi.h>
#include <ESPmDNS.h>
#include "config.h"
void WiFiManager::begin(SettingsManager& settings) {
_settings = &settings;
if (!_settings->isProvisioned()) {
Serial.println("[wifi] Not provisioned — starting AP");
startAP();
return;
}
startStation();
}
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() {
Settings s = _settings->get();
WiFi.disconnect(true);
WiFi.mode(WIFI_STA);
_apMode = false;
Serial.printf("[wifi] Connecting to: %s\n", s.wifi_ssid.c_str());
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());
WiFi.setSleep(WIFI_PS_MIN_MODEM);
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;
}
```
- [ ] **Step 3: Integrate into `main.cpp`**
```cpp
#include <Arduino.h>
#include <M5Unified.h>
#include "config.h"
#include "settings.h"
#include "wifi_manager.h"
SettingsManager settingsManager;
WiFiManager wifiManager;
void setup() {
auto cfg = M5.config();
M5.begin(cfg);
Serial.begin(115200);
Serial.println("[main] Immich Frame booting...");
pinMode(PIN_AUDIO_PWR_EN, OUTPUT);
digitalWrite(PIN_AUDIO_PWR_EN, LOW);
pinMode(PIN_SPK_EN, OUTPUT);
digitalWrite(PIN_SPK_EN, LOW);
settingsManager.begin();
wifiManager.begin(settingsManager);
Serial.printf("[main] WiFi mode: %s, IP: %s\n",
wifiManager.isAPMode() ? "AP" : "Station",
wifiManager.getIP().c_str());
Serial.println("[main] Boot complete");
}
void loop() {
M5.update();
delay(1000);
}
```
- [ ] **Step 4: Build and flash**
Run: `pio run -e m5stack-papercolor -t upload && pio device monitor`
Expected: On first boot (no credentials), serial shows "Not provisioned — starting AP", AP name "PaperColor-Setup", IP "192.168.4.1"
- [ ] **Step 5: Commit**
```bash
git add src/wifi_manager.h src/wifi_manager.cpp src/main.cpp
git commit -m "feat: WiFi manager with station/AP mode and mDNS"
```
---
### Task 4: Power Manager
**Files:**
- Create: `src/power_manager.h`
- Create: `src/power_manager.cpp`
**Interfaces:**
- Consumes: `config.h` (pin definitions, thresholds), M5PM1 library
- Produces:
- `PowerManager` class:
- `void begin()` — init M5PM1, disable unused rails, configure LED strip
- `uint8_t getBatteryPercent()` — read battery level (0-100)
- `bool isCharging()` — is USB power connected?
- `void updateBatteryLED()` — pulse LED based on level (call periodically)
- `void flashLED(uint8_t r, uint8_t g, uint8_t b, uint16_t duration_ms)` — one-shot LED flash
- `void enterDeepSleep()` — power down everything, only power button wakes
- `void enableLightSleep()` — configure auto light sleep with WiFi keepalive
- `void disableEPDPower()` — turn off e-paper power rail
- `void enableEPDPower()` — turn on e-paper power rail
- [ ] **Step 1: Create `src/power_manager.h`**
```cpp
#pragma once
#include <Arduino.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();
void enableLightSleep();
void disableEPDPower();
void enableEPDPower();
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();
};
```
- [ ] **Step 2: Create `src/power_manager.cpp`**
```cpp
#include "power_manager.h"
#include <M5Unified.h>
#include <esp_pm.h>
#include <esp_sleep.h>
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() {
Serial.println("[power] Entering deep sleep — power button to wake");
Serial.flush();
delay(100);
clearLEDs();
// On PaperColor, deep sleep is managed via M5PM1 shutdown
// The power button is hardwired to M5PM1 and always wakes the device
M5.Power.deepSleep(0); // 0 = indefinite, wake via power button
}
void PowerManager::enableLightSleep() {
esp_pm_config_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);
}
```
- [ ] **Step 3: Integrate into `main.cpp`**
Add to the existing `main.cpp` setup:
```cpp
#include <Arduino.h>
#include <M5Unified.h>
#include "config.h"
#include "settings.h"
#include "wifi_manager.h"
#include "power_manager.h"
SettingsManager settingsManager;
WiFiManager wifiManager;
PowerManager powerManager;
void setup() {
auto cfg = M5.config();
M5.begin(cfg);
Serial.begin(115200);
Serial.println("[main] Immich Frame booting...");
powerManager.begin();
settingsManager.begin();
wifiManager.begin(settingsManager);
Serial.printf("[main] Battery: %d%%, Charging: %s\n",
powerManager.getBatteryPercent(),
powerManager.isCharging() ? "yes" : "no");
Serial.println("[main] Boot complete");
}
void loop() {
M5.update();
powerManager.updateBatteryLED();
delay(1000);
}
```
- [ ] **Step 4: Build and flash**
Run: `pio run -e m5stack-papercolor -t upload && pio device monitor`
Expected: Serial shows battery percentage, charging status, light sleep enabled message
- [ ] **Step 5: Commit**
```bash
git add src/power_manager.h src/power_manager.cpp src/main.cpp
git commit -m "feat: power manager with battery monitoring, LED warnings, and sleep modes"
```
---
### Task 5: Button Handler
**Files:**
- Create: `src/button_handler.h`
- Create: `src/button_handler.cpp`
**Interfaces:**
- Consumes: `config.h` (pin definitions, timing), `PowerManager::flashLED()`, `PowerManager::enterDeepSleep()`, `SettingsManager::factoryReset()`
- Produces:
- `enum class ButtonEvent` — `None, NextPhoto, RandomPhoto, PlayPause, DeepSleep, FactoryReset`
- `ButtonHandler` class:
- `void begin(PowerManager& power, SettingsManager& settings)` — configure GPIO interrupts
- `ButtonEvent poll()` — check for pending events (non-blocking)
- [ ] **Step 1: Create `src/button_handler.h`**
```cpp
#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;
unsigned long _lastDebounce = 0;
bool debounced(unsigned long now);
void checkCombos(unsigned long now);
};
```
- [ ] **Step 2: Create `src/button_handler.cpp`**
```cpp
#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, G9=next, G10=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);
_power->enterDeepSleep();
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)
if (!topNow && _btnTopPressTime > 0 &&
(now - _btnTopPressTime < COMBO_HOLD_MS) &&
(now - _btnTopPressTime > BUTTON_DEBOUNCE_MS)) {
_btnTopPressTime = 0;
_power->flashLED(255, 255, 255);
Serial.println("[buttons] BTN_TOP: random photo");
_lastDebounce = now;
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;
}
```
- [ ] **Step 3: Integrate into `main.cpp`**
```cpp
#include <Arduino.h>
#include <M5Unified.h>
#include "config.h"
#include "settings.h"
#include "wifi_manager.h"
#include "power_manager.h"
#include "button_handler.h"
SettingsManager settingsManager;
WiFiManager wifiManager;
PowerManager powerManager;
ButtonHandler buttonHandler;
void setup() {
auto cfg = M5.config();
M5.begin(cfg);
Serial.begin(115200);
Serial.println("[main] Immich Frame booting...");
powerManager.begin();
settingsManager.begin();
wifiManager.begin(settingsManager);
buttonHandler.begin(powerManager, settingsManager);
Serial.println("[main] Boot complete");
}
void loop() {
M5.update();
powerManager.updateBatteryLED();
ButtonEvent event = buttonHandler.poll();
switch (event) {
case ButtonEvent::NextPhoto:
Serial.println("[main] → Next photo requested");
break;
case ButtonEvent::RandomPhoto:
Serial.println("[main] → Random photo requested");
break;
case ButtonEvent::PlayPause:
Serial.println("[main] → Play/Pause toggled");
break;
case ButtonEvent::DeepSleep:
case ButtonEvent::FactoryReset:
break; // Handled internally
case ButtonEvent::None:
break;
default: {
// Exhaustive check — compile error if new variant added
ButtonEvent unreachable = event;
(void)unreachable;
break;
}
}
delay(10); // 100Hz poll rate for responsive buttons
}
```
- [ ] **Step 4: Build and flash**
Run: `pio run -e m5stack-papercolor -t upload && pio device monitor`
Expected: Serial shows button initialization. Pressing buttons prints corresponding events.
- [ ] **Step 5: Commit**
```bash
git add src/button_handler.h src/button_handler.cpp src/main.cpp
git commit -m "feat: button handler with press detection and combo support"
```
---
### Task 6: Immich API Client
**Files:**
- Create: `src/immich_client.h`
- Create: `src/immich_client.cpp`
**Interfaces:**
- Consumes: `Settings` (immich_url, immich_key, img_quality)
- Produces:
- `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; }`
- `ImmichClient` class:
- `void begin(const String& baseUrl, const String& apiKey)` — set connection info
- `std::vector<AlbumInfo> fetchAlbums()` — get album list
- `std::vector<String> fetchAlbumAssetIds(const String& albumId)` — get asset IDs for album
- `std::vector<String> fetchFavoriteAssetIds()` — get favorited asset IDs
- `AssetInfo fetchAssetInfo(const String& assetId)` — get metadata for one asset
- `bool downloadAsset(const String& assetId, ImageQuality quality, uint8_t** outBuffer, size_t* outSize)` — download JPEG into PSRAM buffer (caller frees)
- [ ] **Step 1: Create `src/immich_client.h`**
```cpp
#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;
};
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> fetchFavoriteAssetIds();
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);
bool httpGetBinary(const String& url, uint8_t** outBuffer, size_t* outSize);
};
```
- [ ] **Step 2: Create `src/immich_client.cpp`**
```cpp
#include "immich_client.h"
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.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;
Serial.printf("[immich] Configured: %s\n", _baseUrl.c_str());
}
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);
String response = httpGet(url);
if (response.isEmpty()) return ids;
JsonDocument doc;
DeserializationError err = deserializeJson(doc, response);
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) {
ids.push_back(asset["id"].as<String>());
}
Serial.printf("[immich] Album %s: %d assets\n", albumId.c_str(), ids.size());
return ids;
}
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;
}
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) {
String url;
if (quality == ImageQuality::Original) {
url = buildUrl("/api/assets/" + assetId + "/original");
} else {
url = buildUrl("/api/assets/" + assetId + "/thumbnail?size=preview");
}
return httpGetBinary(url, outBuffer, outSize);
}
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;
}
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;
}
```
- [ ] **Step 3: Build to verify compilation**
Run: `pio run -e m5stack-papercolor`
Expected: BUILD SUCCESS
- [ ] **Step 4: Integration test on device**
Add temporary test code to `main.cpp` setup (after WiFi connects) to verify API connectivity:
```cpp
// Temporary test — remove after verifying
if (wifiManager.isConnected()) {
Settings s = settingsManager.get();
ImmichClient immich;
immich.begin(s.immich_url, s.immich_key);
auto albums = immich.fetchAlbums();
for (auto& a : albums) {
Serial.printf("[test] Album: %s (%d photos)\n", a.title.c_str(), a.assetCount);
}
}
```
Run: `pio run -e m5stack-papercolor -t upload && pio device monitor`
Expected: After WiFi connects, album list prints to serial (requires valid API key in NVS — set via serial or AP setup later)
- [ ] **Step 5: Commit**
```bash
git add src/immich_client.h src/immich_client.cpp
git commit -m "feat: Immich API client with album, asset, and download support"
```
---
### Task 7: Photo Queue Manager
**Files:**
- Create: `src/photo_queue.h`
- Create: `src/photo_queue.cpp`
- Create: `test/test_native/test_photo_queue.cpp`
**Interfaces:**
- Consumes: `ImmichClient::fetchAlbumAssetIds()`, `ImmichClient::fetchFavoriteAssetIds()`, `Settings` (cycle_mode, albums_json, queue_cursor), `SettingsManager::saveField()`
- Produces:
- `PhotoQueue` class:
- `void begin(ImmichClient& client, SettingsManager& settings)` — store references
- `bool sync()` — rebuild queue from Immich (returns true if queue changed)
- `String next()` — get next asset ID based on cycling mode, advances cursor
- `String random()` — get a random asset ID (does not advance cursor sequentially)
- `String current()` — get current asset ID without advancing
- `size_t size()` — number of assets in queue
- `bool needsResync()` — true if 24+ hours since last sync
- `bool isPortrait(size_t index)` — check if asset at index is portrait (from cached info)
- `String findPortraitPair(size_t startIndex)` — look ahead up to 5 items for another portrait
- [ ] **Step 1: Write native test for queue cycling logic**
Create `test/test_native/test_photo_queue.cpp`:
```cpp
#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 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);
UNITY_END();
return 0;
}
```
- [ ] **Step 2: Run native test to verify it passes**
Run: `pio test -e native`
Expected: All 4 tests PASS
- [ ] **Step 3: Create `src/photo_queue.h`**
```cpp
#pragma once
#include <Arduino.h>
#include <vector>
#include "settings.h"
class ImmichClient;
class PhotoQueue {
public:
void begin(ImmichClient& client, SettingsManager& settings);
bool sync();
String next();
String random();
String current();
size_t size();
bool needsResync();
String findPortraitPair(size_t startIndex);
private:
ImmichClient* _client = nullptr;
SettingsManager* _settings = nullptr;
std::vector<String> _queue;
size_t _cursor = 0;
unsigned long _lastSyncTime = 0;
void shuffle();
void sortChronological(bool reverse);
void applyFavoritesWeighting();
std::vector<String> getSelectedAlbumIds();
};
```
- [ ] **Step 4: Create `src/photo_queue.cpp`**
```cpp
#include "photo_queue.h"
#include "immich_client.h"
#include <ArduinoJson.h>
#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;
Settings s = _settings->get();
std::vector<String> newIds;
// Get selected album IDs
std::vector<String> albumIds = getSelectedAlbumIds();
if (albumIds.empty()) {
// "All photos" mode — fetch from all albums
auto albums = _client->fetchAlbums();
for (auto& album : albums) {
auto ids = _client->fetchAlbumAssetIds(album.id);
for (auto& id : ids) {
newIds.push_back(id);
}
}
} else {
// Fetch from selected albums only
for (auto& albumId : albumIds) {
auto ids = _client->fetchAlbumAssetIds(albumId);
for (auto& id : ids) {
newIds.push_back(id);
}
}
}
// Deduplicate
std::sort(newIds.begin(), newIds.end());
newIds.erase(std::unique(newIds.begin(), newIds.end()), newIds.end());
if (newIds.empty()) {
Serial.println("[queue] No assets found");
return false;
}
_queue = newIds;
// Apply cycling mode
switch (s.cycle_mode) {
case CycleMode::Random:
applyFavoritesWeighting(); // No-op for plain random
shuffle();
break;
case CycleMode::Chronological:
sortChronological(false);
break;
case CycleMode::ReverseChronological:
sortChronological(true);
break;
case CycleMode::FavoritesWeighted:
applyFavoritesWeighting();
shuffle();
break;
default: {
// Exhaustive — compile error on new variant
CycleMode unreachable = s.cycle_mode;
(void)unreachable;
shuffle();
break;
}
}
// Clamp cursor
if (_cursor >= _queue.size()) {
_cursor = 0;
}
_lastSyncTime = millis();
Serial.printf("[queue] Synced: %d assets, cursor at %d\n", _queue.size(), _cursor);
return true;
}
String PhotoQueue::next() {
if (_queue.empty()) return "";
if (_cursor >= _queue.size()) {
_cursor = 0;
shuffle(); // Reshuffle on wrap for random mode
}
String id = _queue[_cursor++];
// Persist cursor
_settings->saveField("queue_cursor", static_cast<uint8_t>(_cursor & 0xFF));
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 (_lastSyncTime == 0) return true;
unsigned long elapsed = millis() - _lastSyncTime;
return elapsed >= (QUEUE_RESYNC_HOURS * 3600000UL);
}
String PhotoQueue::findPortraitPair(size_t startIndex) {
// Look ahead up to PORTRAIT_LOOKAHEAD items for another portrait
for (size_t i = 1; i <= PORTRAIT_LOOKAHEAD && (startIndex + i) < _queue.size(); i++) {
// We'd need asset info to determine portrait status
// This will be called by the display task which fetches AssetInfo
// Return the ID — caller checks isPortrait from AssetInfo
return _queue[startIndex + i];
}
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(bool reverse) {
// For chronological sort, we'd need timestamps which we don't store in the queue
// The IDs from Immich are UUIDs, not sortable by time
// For now, keep the order returned by Immich (which is chronological within albums)
if (reverse) {
std::reverse(_queue.begin(), _queue.end());
}
}
void PhotoQueue::applyFavoritesWeighting() {
if (_client == nullptr) return;
Settings s = _settings->get();
if (s.cycle_mode != CycleMode::FavoritesWeighted) return;
auto favorites = _client->fetchFavoriteAssetIds();
// Add favorites 2 more times (total 3× appearance)
for (auto& fav : favorites) {
// Only add if already in queue
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;
}
```
- [ ] **Step 5: Build to verify**
Run: `pio run -e m5stack-papercolor`
Expected: BUILD SUCCESS
- [ ] **Step 6: Commit**
```bash
git add src/photo_queue.h src/photo_queue.cpp test/test_native/test_photo_queue.cpp
git commit -m "feat: photo queue manager with cycling modes and favorites weighting"
```
---
### Task 8: Image Pipeline (JPEG Decode + Dither)
**Files:**
- Create: `src/image_pipeline.h`
- Create: `src/image_pipeline.cpp`
- Create: `test/test_native/test_image_pipeline.cpp`
**Interfaces:**
- Consumes: JPEG buffer (from `ImmichClient::downloadAsset()`), `config.h` (palette values, display dimensions)
- Produces:
- `struct ProcessedImage` — `{ uint8_t* framebuffer; uint16_t width; uint16_t height; bool valid; }`
- `ImagePipeline` class:
- `ProcessedImage process(uint8_t* jpegData, size_t jpegSize, bool isPortraitPair, uint8_t* jpeg2Data, size_t jpeg2Size)` — full pipeline: decode → resize → dither. For portrait pairs, pass both JPEGs.
- `void freeImage(ProcessedImage& img)` — free the framebuffer memory
- [ ] **Step 1: Write native test for Floyd-Steinberg dithering logic**
Create `test/test_native/test_image_pipeline.cpp`:
```cpp
#include <unity.h>
#include <cstdint>
#include <cmath>
#include <cstdlib>
// Palette definition (same as config.h)
struct Color { uint8_t r, g, b; };
static const Color PALETTE[6] = {
{0, 0, 0}, // Black
{255, 255, 255}, // White
{200, 30, 30}, // Red
{30, 160, 30}, // Green
{30, 30, 200}, // Blue
{220, 200, 30} // Yellow
};
// Find nearest palette color (Euclidean distance in RGB)
uint8_t findNearestColor(int r, int g, int b) {
uint8_t best = 0;
int 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;
int dist = dr*dr + dg*dg + db*db;
if (dist < bestDist) {
bestDist = dist;
best = 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
}
}
void setUp() {}
void tearDown() {}
int main() {
UNITY_BEGIN();
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);
UNITY_END();
return 0;
}
```
- [ ] **Step 2: Run native test**
Run: `pio test -e native`
Expected: All 9 tests PASS
- [ ] **Step 3: Create `src/image_pipeline.h`**
```cpp
#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;
};
class ImagePipeline {
public:
// Process single landscape photo
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);
private:
// Decode JPEG into RGB888 buffer in PSRAM
uint8_t* decodeJpeg(uint8_t* data, size_t size, uint16_t* outWidth, uint16_t* outHeight);
// Resize RGB buffer to target dimensions (bilinear)
uint8_t* resize(uint8_t* rgb, uint16_t srcW, uint16_t srcH,
uint16_t dstW, uint16_t dstH);
// Center-crop to target aspect ratio
void centerCrop(uint8_t* rgb, uint16_t srcW, uint16_t srcH,
uint16_t targetW, uint16_t targetH,
uint16_t* cropX, uint16_t* cropY,
uint16_t* cropW, uint16_t* cropH);
// Floyd-Steinberg dither RGB888 to 6-color palette indices
uint8_t* dither(uint8_t* rgb, uint16_t width, uint16_t height);
// Find nearest palette color
uint8_t findNearest(int r, int g, int b);
};
```
- [ ] **Step 4: Create `src/image_pipeline.cpp`**
```cpp
#include "image_pipeline.h"
#include <M5GFX.h>
// Spectra 6 palette
static const uint8_t PALETTE_RGB[6][3] = {
{PALETTE_BLACK_R, PALETTE_BLACK_G, PALETTE_BLACK_B},
{PALETTE_WHITE_R, PALETTE_WHITE_G, PALETTE_WHITE_B},
{PALETTE_RED_R, PALETTE_RED_G, PALETTE_RED_B},
{PALETTE_GREEN_R, PALETTE_GREEN_G, PALETTE_GREEN_B},
{PALETTE_BLUE_R, PALETTE_BLUE_G, PALETTE_BLUE_B},
{PALETTE_YELLOW_R, PALETTE_YELLOW_G, PALETTE_YELLOW_B}
};
ProcessedImage ImagePipeline::process(uint8_t* jpegData, size_t jpegSize) {
ProcessedImage result = {nullptr, DISPLAY_WIDTH, DISPLAY_HEIGHT, false};
// Decode JPEG
uint16_t srcW, srcH;
uint8_t* rgb = decodeJpeg(jpegData, jpegSize, &srcW, &srcH);
if (rgb == nullptr) {
Serial.println("[pipeline] JPEG decode failed");
return result;
}
Serial.printf("[pipeline] Decoded: %dx%d\n", srcW, srcH);
// Calculate crop region (fill-crop to display aspect ratio)
uint16_t cropX, cropY, cropW, cropH;
centerCrop(rgb, srcW, srcH, DISPLAY_WIDTH, DISPLAY_HEIGHT,
&cropX, &cropY, &cropW, &cropH);
// Resize cropped region to display dimensions
uint8_t* cropped = (uint8_t*)ps_malloc(cropW * cropH * 3);
if (cropped == nullptr) {
free(rgb);
return result;
}
// Extract crop region
for (uint16_t y = 0; y < cropH; y++) {
memcpy(cropped + y * cropW * 3,
rgb + ((cropY + y) * srcW + cropX) * 3,
cropW * 3);
}
free(rgb);
// Resize to display dimensions
uint8_t* resized = resize(cropped, cropW, cropH, DISPLAY_WIDTH, DISPLAY_HEIGHT);
free(cropped);
if (resized == nullptr) {
return result;
}
// Dither to 6-color palette
uint8_t* dithered = dither(resized, DISPLAY_WIDTH, DISPLAY_HEIGHT);
free(resized);
if (dithered == nullptr) {
return result;
}
result.framebuffer = dithered;
result.valid = true;
Serial.println("[pipeline] Processing complete");
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};
// Each portrait gets half the width minus gap
uint16_t portraitW = (DISPLAY_WIDTH - PORTRAIT_GAP_PX) / 2;
uint16_t portraitH = DISPLAY_HEIGHT;
// Allocate combined RGB buffer
uint8_t* combined = (uint8_t*)ps_calloc(DISPLAY_WIDTH * DISPLAY_HEIGHT * 3, 1);
if (combined == nullptr) return result;
// Process first portrait
uint16_t src1W, src1H;
uint8_t* rgb1 = decodeJpeg(jpeg1Data, jpeg1Size, &src1W, &src1H);
if (rgb1 != nullptr) {
uint16_t cropX, cropY, cropW, cropH;
centerCrop(rgb1, src1W, src1H, portraitW, portraitH,
&cropX, &cropY, &cropW, &cropH);
uint8_t* cropped1 = (uint8_t*)ps_malloc(cropW * cropH * 3);
if (cropped1) {
for (uint16_t y = 0; y < cropH; y++) {
memcpy(cropped1 + y * cropW * 3,
rgb1 + ((cropY + y) * src1W + cropX) * 3, cropW * 3);
}
uint8_t* resized1 = resize(cropped1, cropW, cropH, portraitW, portraitH);
free(cropped1);
if (resized1) {
// Copy into left side of combined buffer
for (uint16_t y = 0; y < portraitH; y++) {
memcpy(combined + y * DISPLAY_WIDTH * 3,
resized1 + y * portraitW * 3, portraitW * 3);
}
free(resized1);
}
}
free(rgb1);
}
// Process second portrait
uint16_t src2W, src2H;
uint8_t* rgb2 = decodeJpeg(jpeg2Data, jpeg2Size, &src2W, &src2H);
if (rgb2 != nullptr) {
uint16_t cropX, cropY, cropW, cropH;
centerCrop(rgb2, src2W, src2H, portraitW, portraitH,
&cropX, &cropY, &cropW, &cropH);
uint8_t* cropped2 = (uint8_t*)ps_malloc(cropW * cropH * 3);
if (cropped2) {
for (uint16_t y = 0; y < cropH; y++) {
memcpy(cropped2 + y * cropW * 3,
rgb2 + ((cropY + y) * src2W + cropX) * 3, cropW * 3);
}
uint8_t* resized2 = resize(cropped2, cropW, cropH, portraitW, portraitH);
free(cropped2);
if (resized2) {
// Copy into right side of combined buffer
uint16_t offsetX = portraitW + PORTRAIT_GAP_PX;
for (uint16_t y = 0; y < portraitH; y++) {
memcpy(combined + (y * DISPLAY_WIDTH + offsetX) * 3,
resized2 + y * portraitW * 3, portraitW * 3);
}
free(resized2);
}
}
free(rgb2);
}
// Dither combined buffer
uint8_t* dithered = dither(combined, DISPLAY_WIDTH, DISPLAY_HEIGHT);
free(combined);
if (dithered == nullptr) return result;
result.framebuffer = dithered;
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::decodeJpeg(uint8_t* data, size_t size,
uint16_t* outWidth, uint16_t* outHeight) {
// Use M5GFX's built-in JPEG decoder (lgfx::LGFX_Sprite as decode target)
// Alternative: TJpgDec library
lgfx::LGFX_Sprite sprite;
sprite.setPsram(true);
sprite.setColorDepth(24);
// Draw JPEG into sprite to get decoded RGB data
if (!sprite.createFromBmpMem(data, size)) {
// Try JPEG-specific decode
sprite.createSprite(1, 1); // Minimal sprite for size query
// Use M5GFX drawJpg to decode
}
// Fallback: manual JPEG decode with TJpgDec
// For initial implementation, use M5GFX's JPEG support
// This will be refined during hardware bring-up
*outWidth = sprite.width();
*outHeight = sprite.height();
if (*outWidth == 0 || *outHeight == 0) {
return nullptr;
}
size_t bufSize = (*outWidth) * (*outHeight) * 3;
uint8_t* rgb = (uint8_t*)ps_malloc(bufSize);
if (rgb == nullptr) return nullptr;
// Read pixels from sprite
for (uint16_t y = 0; y < *outHeight; y++) {
for (uint16_t x = 0; x < *outWidth; x++) {
uint32_t color = sprite.readPixel(x, y);
size_t idx = (y * (*outWidth) + x) * 3;
rgb[idx] = (color >> 16) & 0xFF; // R
rgb[idx + 1] = (color >> 8) & 0xFF; // G
rgb[idx + 2] = color & 0xFF; // B
}
}
sprite.deleteSprite();
return rgb;
}
uint8_t* ImagePipeline::resize(uint8_t* rgb, uint16_t srcW, uint16_t srcH,
uint16_t dstW, uint16_t dstH) {
size_t bufSize = dstW * dstH * 3;
uint8_t* dst = (uint8_t*)ps_malloc(bufSize);
if (dst == nullptr) return nullptr;
// Bilinear interpolation
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 = rgb[(y0 * srcW + x0) * 3 + c] * (1 - xFrac) +
rgb[(y0 * srcW + x1) * 3 + c] * xFrac;
float bot = rgb[(y1 * srcW + x0) * 3 + c] * (1 - xFrac) +
rgb[(y1 * srcW + x1) * 3 + c] * xFrac;
float val = top * (1 - yFrac) + bot * yFrac;
dst[(y * dstW + x) * 3 + c] = (uint8_t)(val + 0.5f);
}
}
}
return dst;
}
void ImagePipeline::centerCrop(uint8_t* rgb, uint16_t srcW, uint16_t srcH,
uint16_t targetW, uint16_t targetH,
uint16_t* cropX, uint16_t* cropY,
uint16_t* cropW, uint16_t* cropH) {
float targetAspect = (float)targetW / (float)targetH;
float srcAspect = (float)srcW / (float)srcH;
if (srcAspect > targetAspect) {
// Source is wider — crop sides
*cropH = srcH;
*cropW = (uint16_t)(srcH * targetAspect);
*cropX = (srcW - *cropW) / 2;
*cropY = 0;
} else {
// Source is taller — crop top/bottom
*cropW = srcW;
*cropH = (uint16_t)(srcW / targetAspect);
*cropX = 0;
*cropY = (srcH - *cropH) / 2;
}
}
uint8_t* ImagePipeline::dither(uint8_t* rgb, uint16_t width, uint16_t height) {
size_t pixelCount = width * height;
uint8_t* output = (uint8_t*)ps_malloc(pixelCount);
if (output == nullptr) return nullptr;
// Work buffer with int16 to handle error overflow
int16_t* work = (int16_t*)ps_malloc(pixelCount * 3 * sizeof(int16_t));
if (work == nullptr) {
free(output);
return nullptr;
}
// Copy to work buffer
for (size_t i = 0; i < pixelCount * 3; i++) {
work[i] = rgb[i];
}
// Floyd-Steinberg dithering
for (uint16_t y = 0; y < height; y++) {
for (uint16_t x = 0; x < width; x++) {
size_t idx = (y * width + x) * 3;
int r = constrain(work[idx], 0, 255);
int g = constrain(work[idx + 1], 0, 255);
int b = constrain(work[idx + 2], 0, 255);
uint8_t nearest = findNearest(r, g, b);
output[y * width + x] = nearest;
int errR = r - PALETTE_RGB[nearest][0];
int errG = g - PALETTE_RGB[nearest][1];
int errB = b - PALETTE_RGB[nearest][2];
// Distribute error
if (x + 1 < width) {
size_t 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) {
size_t ni = ((y + 1) * width + (x - 1)) * 3;
work[ni] += errR * 3 / 16;
work[ni + 1] += errG * 3 / 16;
work[ni + 2] += errB * 3 / 16;
}
{
size_t 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) {
size_t 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);
return output;
}
uint8_t ImagePipeline::findNearest(int r, int g, int b) {
uint8_t best = 0;
int bestDist = INT32_MAX;
for (int i = 0; i < DISPLAY_COLORS; i++) {
int dr = r - PALETTE_RGB[i][0];
int dg = g - PALETTE_RGB[i][1];
int db = b - PALETTE_RGB[i][2];
int dist = dr * dr + dg * dg + db * db;
if (dist < bestDist) {
bestDist = dist;
best = i;
}
}
return best;
}
```
- [ ] **Step 5: Build to verify**
Run: `pio run -e m5stack-papercolor`
Expected: BUILD SUCCESS
- [ ] **Step 6: Commit**
```bash
git add src/image_pipeline.h src/image_pipeline.cpp test/test_native/test_image_pipeline.cpp
git commit -m "feat: image pipeline with JPEG decode, resize, and Floyd-Steinberg dithering"
```
---
### Task 9: Display Manager
**Files:**
- Create: `src/display_manager.h`
- Create: `src/display_manager.cpp`
**Interfaces:**
- Consumes: `ProcessedImage` (from `ImagePipeline::process()`), `config.h` (display dims), `PowerManager::enableEPDPower()`, `PowerManager::disableEPDPower()`
- Produces:
- `DisplayManager` class:
- `void begin()` — initialize e-ink display in landscape orientation
- `void showImage(const ProcessedImage& img)` — write framebuffer to display and trigger refresh
- `void showMessage(const char* title, const char* body)` — display text (for status/error messages)
- `void showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos)` — overlay metadata text
- [ ] **Step 1: Create `src/display_manager.h`**
```cpp
#pragma once
#include <Arduino.h>
#include <M5GFX.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 showImage(const ProcessedImage& img);
void showMessage(const char* title, const char* body);
void showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos);
private:
PowerManager* _power = nullptr;
M5GFX* _display = nullptr;
void triggerRefresh();
uint16_t paletteToColor565(uint8_t index);
};
```
- [ ] **Step 2: Create `src/display_manager.cpp`**
```cpp
#include "display_manager.h"
#include "power_manager.h"
#include <M5Unified.h>
// Map 6-color palette indices to RGB565 for M5GFX
static const uint16_t PALETTE_565[6] = {
0x0000, // Black
0xFFFF, // White
0xF800, // Red (approximate)
0x07E0, // Green (approximate)
0x001F, // Blue (approximate)
0xFFE0 // Yellow (approximate)
};
void DisplayManager::begin(PowerManager& power) {
_power = &power;
_display = &M5.Display;
// Set rotation for landscape (device physically rotated)
// Rotation value depends on how the display is mounted — likely 1 or 3
_display->setRotation(1);
Serial.printf("[display] Initialized: %dx%d, rotation=%d\n",
_display->width(), _display->height(), _display->getRotation());
showMessage("Immich Frame", "Starting up...");
}
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...");
unsigned long start = millis();
// Write pixel by pixel using palette-mapped colors
_display->startWrite();
for (uint16_t y = 0; y < img.height; y++) {
for (uint16_t x = 0; x < img.width; x++) {
uint8_t colorIdx = img.framebuffer[y * img.width + x];
_display->writePixel(x, y, PALETTE_565[colorIdx]);
}
}
_display->endWrite();
triggerRefresh();
unsigned long elapsed = millis() - start;
Serial.printf("[display] Refresh complete in %lu ms\n", elapsed);
_power->disableEPDPower();
}
void DisplayManager::showMessage(const char* title, const char* body) {
_power->enableEPDPower();
_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);
triggerRefresh();
_power->disableEPDPower();
}
void DisplayManager::showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos) {
if (metaFlags == 0) return; // No metadata to show
// Build metadata string
String metaText = "";
if ((metaFlags & META_DATE) && info.dateTime.length() > 0) {
// Extract just the date portion (YYYY-MM-DD)
metaText += info.dateTime.substring(0, 10);
}
if ((metaFlags & META_LOCATION) && info.city.length() > 0) {
if (metaText.length() > 0) metaText += " | ";
metaText += info.city;
}
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 — skip for now
}
if ((metaFlags & META_CAMERA) && info.camera.length() > 0) {
if (metaText.length() > 0) metaText += " | ";
metaText += info.camera;
}
if (metaText.length() == 0) return;
// Draw semi-transparent bar with text
uint16_t barY = (pos == MetaPosition::Top) ? 0 : (DISPLAY_HEIGHT - 30);
_display->fillRect(0, barY, DISPLAY_WIDTH, 30, TFT_BLACK);
_display->setTextColor(TFT_WHITE);
_display->setTextDatum(middle_center);
_display->setTextSize(1);
_display->drawString(metaText.c_str(), DISPLAY_WIDTH / 2, barY + 15);
}
void DisplayManager::triggerRefresh() {
// M5GFX handles e-ink refresh internally when using the EPD panel driver
// The display() call triggers the actual e-ink refresh cycle (10-20s)
_display->display();
}
uint16_t DisplayManager::paletteToColor565(uint8_t index) {
if (index >= DISPLAY_COLORS) return 0;
return PALETTE_565[index];
}
```
- [ ] **Step 3: Build to verify**
Run: `pio run -e m5stack-papercolor`
Expected: BUILD SUCCESS
- [ ] **Step 4: Commit**
```bash
git add src/display_manager.h src/display_manager.cpp
git commit -m "feat: display manager for e-ink framebuffer output and text messages"
```
---
### Task 10: Web Server & REST API
**Files:**
- Create: `src/web_server.h`
- Create: `src/web_server.cpp`
- Create: `data/setup.html`
- Create: `data/index.html`
- Create: `data/style.css`
- Create: `data/app.js`
**Interfaces:**
- Consumes: `SettingsManager`, `ImmichClient`, `PhotoQueue`, `PowerManager`, `WiFiManager`
- Produces:
- `WebServer` class (note: not `WebServer` from Arduino core — we use `AppWebServer` to avoid conflict):
- `void begin(SettingsManager& settings, ImmichClient& immich, PowerManager& power, WiFiManager& wifi)` — start AsyncWebServer
- `void setActionCallback(std::function<void(const String& action)> cb)` — register callback for slideshow control actions
- `bool isOTAInProgress()` — true during firmware upload
- [ ] **Step 1: Create `src/web_server.h`**
```cpp
#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);
};
```
- [ ] **Step 2: Create `src/web_server.cpp`**
```cpp
#include "web_server.h"
#include <ArduinoJson.h>
#include <LittleFS.h>
#include <Update.h>
#include "immich_client.h"
#include "power_manager.h"
#include "wifi_manager.h"
#include "config.h"
void AppWebServer::begin(SettingsManager& settings, ImmichClient& immich,
PowerManager& power, WiFiManager& wifi) {
_settings = &settings;
_immich = &immich;
_power = &power;
_wifi = &wifi;
if (!LittleFS.begin(true)) {
Serial.println("[web] LittleFS mount failed");
}
if (_wifi->isAPMode()) {
setupCaptivePortal();
} else {
setupAPIRoutes();
setupStaticFiles();
setupOTA();
}
_server.begin();
Serial.printf("[web] Server started (mode: %s)\n",
_wifi->isAPMode() ? "AP/captive" : "LAN");
}
void AppWebServer::setActionCallback(std::function<void(const String& action)> cb) {
_actionCb = cb;
}
bool AppWebServer::isOTAInProgress() {
return _otaInProgress;
}
void AppWebServer::setupCaptivePortal() {
// Serve setup page for all requests (captive portal behavior)
_server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send(LittleFS, "/setup.html", "text/html");
});
// Handle WiFi scan
_server.on("/api/wifi/scan", HTTP_GET, [](AsyncWebServerRequest* request) {
int n = WiFi.scanNetworks();
JsonDocument doc;
JsonArray arr = doc.to<JsonArray>();
for (int i = 0; i < n; i++) {
JsonObject net = arr.add<JsonObject>();
net["ssid"] = WiFi.SSID(i);
net["rssi"] = WiFi.RSSI(i);
net["secure"] = WiFi.encryptionType(i) != WIFI_AUTH_OPEN;
}
String response;
serializeJson(doc, response);
request->send(200, "application/json", response);
});
// Handle setup submission
_server.on("/api/setup", HTTP_POST, [](AsyncWebServerRequest* request) {
request->send(200);
}, nullptr, [this](AsyncWebServerRequest* request, uint8_t* data, size_t len,
size_t index, size_t total) {
if (index + len == total) {
handlePostSetup(request, data, len);
}
});
// Captive portal redirect for all other paths
_server.onNotFound([](AsyncWebServerRequest* request) {
request->redirect("/");
});
}
void AppWebServer::setupAPIRoutes() {
_server.on("/api/status", HTTP_GET,
[this](AsyncWebServerRequest* req) { handleGetStatus(req); });
_server.on("/api/albums", HTTP_GET,
[this](AsyncWebServerRequest* req) { handleGetAlbums(req); });
_server.on("/api/albums/select", HTTP_POST,
[](AsyncWebServerRequest* req) { req->send(200); },
nullptr,
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len,
size_t index, size_t total) {
if (index + len == total) handlePostAlbumsSelect(req, data, len);
});
_server.on("/api/settings", HTTP_GET,
[this](AsyncWebServerRequest* req) { handleGetSettings(req); });
_server.on("/api/settings", HTTP_POST,
[](AsyncWebServerRequest* req) { req->send(200); },
nullptr,
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len,
size_t index, size_t total) {
if (index + len == total) handlePostSettings(req, data, len);
});
// Action endpoints
_server.on("/api/action/next", HTTP_POST,
[this](AsyncWebServerRequest* req) {
if (_actionCb) _actionCb("next");
req->send(200, "application/json", "{\"ok\":true}");
});
_server.on("/api/action/random", HTTP_POST,
[this](AsyncWebServerRequest* req) {
if (_actionCb) _actionCb("random");
req->send(200, "application/json", "{\"ok\":true}");
});
_server.on("/api/action/pause", HTTP_POST,
[this](AsyncWebServerRequest* req) {
if (_actionCb) _actionCb("pause");
req->send(200, "application/json", "{\"ok\":true}");
});
_server.on("/api/action/play", HTTP_POST,
[this](AsyncWebServerRequest* req) {
if (_actionCb) _actionCb("play");
req->send(200, "application/json", "{\"ok\":true}");
});
_server.on("/api/action/sleep", HTTP_POST,
[this](AsyncWebServerRequest* req) {
req->send(200, "application/json", "{\"ok\":true}");
delay(100);
_power->enterDeepSleep();
});
}
void AppWebServer::setupStaticFiles() {
_server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");
}
void AppWebServer::setupOTA() {
_server.on("/api/firmware", HTTP_POST,
[this](AsyncWebServerRequest* request) {
_otaInProgress = false;
bool success = !Update.hasError();
request->send(200, "application/json",
success ? "{\"ok\":true,\"msg\":\"Rebooting...\"}"
: "{\"ok\":false,\"msg\":\"Update failed\"}");
if (success) {
delay(500);
ESP.restart();
}
},
[this](AsyncWebServerRequest* request, const String& filename,
size_t index, uint8_t* data, size_t len, bool final) {
if (index == 0) {
_otaInProgress = true;
Serial.printf("[web] OTA start: %s\n", filename.c_str());
if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH)) {
Serial.println("[web] OTA begin failed");
}
}
if (Update.isRunning()) {
Update.write(data, len);
}
if (final) {
if (Update.end(true)) {
Serial.printf("[web] OTA complete: %u bytes\n", index + len);
} else {
Serial.println("[web] OTA finalize failed");
}
}
});
}
void AppWebServer::handleGetStatus(AsyncWebServerRequest* request) {
JsonDocument doc;
doc["battery"] = _power->getBatteryPercent();
doc["charging"] = _power->isCharging();
doc["wifi_rssi"] = _wifi->getRSSI();
doc["ip"] = _wifi->getIP();
doc["uptime"] = millis() / 1000;
doc["free_heap"] = ESP.getFreeHeap();
doc["free_psram"] = ESP.getFreePsram();
String response;
serializeJson(doc, response);
request->send(200, "application/json", response);
}
void AppWebServer::handleGetAlbums(AsyncWebServerRequest* request) {
auto albums = _immich->fetchAlbums();
JsonDocument doc;
JsonArray arr = doc.to<JsonArray>();
for (auto& album : albums) {
JsonObject obj = arr.add<JsonObject>();
obj["id"] = album.id;
obj["title"] = album.title;
obj["assetCount"] = album.assetCount;
}
String response;
serializeJson(doc, response);
request->send(200, "application/json", response);
}
void AppWebServer::handlePostAlbumsSelect(AsyncWebServerRequest* request,
uint8_t* data, size_t len) {
String body = String((char*)data).substring(0, len);
JsonDocument doc;
if (deserializeJson(doc, body)) {
request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}");
return;
}
String albumsJson;
serializeJson(doc["album_ids"], albumsJson);
_settings->saveField("albums_json", albumsJson.c_str());
request->send(200, "application/json", "{\"ok\":true}");
}
void AppWebServer::handleGetSettings(AsyncWebServerRequest* request) {
Settings s = _settings->get();
JsonDocument doc;
doc["interval_min"] = s.interval_min;
doc["cycle_mode"] = static_cast<uint8_t>(s.cycle_mode);
doc["img_quality"] = static_cast<uint8_t>(s.img_quality);
doc["meta_flags"] = s.meta_flags;
doc["meta_pos"] = static_cast<uint8_t>(s.meta_pos);
doc["led_brightness"] = s.led_brightness;
doc["immich_url"] = s.immich_url;
doc["albums_json"] = s.albums_json;
String response;
serializeJson(doc, response);
request->send(200, "application/json", response);
}
void AppWebServer::handlePostSettings(AsyncWebServerRequest* request,
uint8_t* data, size_t len) {
String body = String((char*)data).substring(0, len);
JsonDocument doc;
if (deserializeJson(doc, body)) {
request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}");
return;
}
Settings s = _settings->get();
if (doc.containsKey("interval_min")) s.interval_min = doc["interval_min"];
if (doc.containsKey("cycle_mode")) s.cycle_mode = static_cast<CycleMode>((uint8_t)doc["cycle_mode"]);
if (doc.containsKey("img_quality")) s.img_quality = static_cast<ImageQuality>((uint8_t)doc["img_quality"]);
if (doc.containsKey("meta_flags")) s.meta_flags = doc["meta_flags"];
if (doc.containsKey("meta_pos")) s.meta_pos = static_cast<MetaPosition>((uint8_t)doc["meta_pos"]);
if (doc.containsKey("led_brightness")) s.led_brightness = doc["led_brightness"];
if (doc.containsKey("immich_url")) s.immich_url = doc["immich_url"].as<String>();
if (doc.containsKey("immich_key")) s.immich_key = doc["immich_key"].as<String>();
_settings->save(s);
request->send(200, "application/json", "{\"ok\":true}");
}
void AppWebServer::handlePostSetup(AsyncWebServerRequest* request,
uint8_t* data, size_t len) {
String body = String((char*)data).substring(0, len);
JsonDocument doc;
if (deserializeJson(doc, body)) {
request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}");
return;
}
Settings s = _settings->get();
s.wifi_ssid = doc["wifi_ssid"].as<String>();
s.wifi_pass = doc["wifi_pass"].as<String>();
s.immich_url = doc["immich_url"] | DEFAULT_IMMICH_URL;
s.immich_key = doc["immich_key"].as<String>();
_settings->save(s);
request->send(200, "application/json", "{\"ok\":true,\"msg\":\"Rebooting...\"}");
delay(1000);
ESP.restart();
}
```
- [ ] **Step 3: Create `data/setup.html` (AP captive portal)**
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PaperColor Setup</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, sans-serif; max-width: 400px; margin: 40px auto; padding: 20px; background: #f5f5f5; }
h1 { margin-bottom: 20px; color: #333; }
.field { margin-bottom: 16px; }
label { display: block; font-weight: 600; margin-bottom: 4px; color: #555; }
input, select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 16px; }
button { width: 100%; padding: 12px; background: #2563eb; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; margin-top: 10px; }
button:hover { background: #1d4ed8; }
.status { margin-top: 16px; padding: 10px; border-radius: 6px; display: none; }
.status.error { display: block; background: #fee2e2; color: #991b1b; }
.status.success { display: block; background: #d1fae5; color: #065f46; }
#networks { margin-bottom: 16px; }
</style>
</head>
<body>
<h1>PaperColor Setup</h1>
<form id="setupForm">
<div class="field">
<label>WiFi Network</label>
<select id="wifiSsid"><option value="">Scanning...</option></select>
</div>
<div class="field">
<label>WiFi Password</label>
<input type="password" id="wifiPass" required>
</div>
<div class="field">
<label>Immich URL</label>
<input type="url" id="immichUrl" value="https://photos.example.com">
</div>
<div class="field">
<label>Immich API Key</label>
<input type="text" id="immichKey" required placeholder="Your API key">
</div>
<button type="submit">Save & Connect</button>
</form>
<div id="status" class="status"></div>
<script>
async function scanNetworks() {
try {
const res = await fetch('/api/wifi/scan');
const networks = await res.json();
const select = document.getElementById('wifiSsid');
select.innerHTML = networks.map(n =>
`<option value="${n.ssid}">${n.ssid} (${n.rssi}dBm)</option>`
).join('');
} catch (e) {
console.error('Scan failed:', e);
}
}
document.getElementById('setupForm').addEventListener('submit', async (e) => {
e.preventDefault();
const status = document.getElementById('status');
try {
const res = await fetch('/api/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
wifi_ssid: document.getElementById('wifiSsid').value,
wifi_pass: document.getElementById('wifiPass').value,
immich_url: document.getElementById('immichUrl').value,
immich_key: document.getElementById('immichKey').value
})
});
const data = await res.json();
if (data.ok) {
status.className = 'status success';
status.textContent = 'Saved! Device is rebooting...';
} else {
throw new Error(data.error || 'Unknown error');
}
} catch (e) {
status.className = 'status error';
status.textContent = 'Error: ' + e.message;
}
});
scanNetworks();
</script>
</body>
</html>
```
- [ ] **Step 4: Create `data/index.html` (main management UI shell)**
```html
<!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>
```
- [ ] **Step 5: Create `data/style.css`**
```css
* { 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; }
```
- [ ] **Step 6: Create `data/app.js`**
```javascript
const API = '';
let currentPage = 'dashboard';
async function api(path, opts = {}) {
const res = await fetch(API + path, {
headers: { 'Content-Type': 'application/json' },
...opts
});
return res.json();
}
function toast(msg) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3000);
}
function navigate(page) {
currentPage = page;
document.querySelectorAll('#nav-links a').forEach(a => {
a.classList.toggle('active', a.dataset.page === page);
});
renderPage(page);
}
async function renderPage(page) {
const el = document.getElementById('content');
switch (page) {
case 'dashboard': return renderDashboard(el);
case 'albums': return renderAlbums(el);
case 'slideshow': return renderSlideshow(el);
case 'display': return renderDisplay(el);
case 'device': return renderDevice(el);
case 'firmware': return renderFirmware(el);
}
}
async function renderDashboard(el) {
const status = await api('/api/status');
el.innerHTML = `
<div class="card">
<h2>Status</h2>
<div class="stat"><span>Battery</span><span>${status.battery}%${status.charging ? ' ⚡' : ''}</span></div>
<div class="stat"><span>WiFi Signal</span><span>${status.wifi_rssi} dBm</span></div>
<div class="stat"><span>IP Address</span><span>${status.ip}</span></div>
<div class="stat"><span>Uptime</span><span>${Math.floor(status.uptime/60)}m</span></div>
<div class="stat"><span>Free RAM</span><span>${Math.floor(status.free_heap/1024)}KB</span></div>
<div class="stat"><span>Free PSRAM</span><span>${Math.floor(status.free_psram/1024)}KB</span></div>
</div>
<div class="card">
<h2>Controls</h2>
<div class="btn-group">
<button class="btn btn-primary" onclick="api('/api/action/next',{method:'POST'}).then(()=>toast('Next photo'))">Next</button>
<button class="btn btn-primary" onclick="api('/api/action/random',{method:'POST'}).then(()=>toast('Random photo'))">Random</button>
<button class="btn btn-primary" onclick="api('/api/action/pause',{method:'POST'}).then(()=>toast('Paused'))">Pause</button>
<button class="btn btn-primary" onclick="api('/api/action/play',{method:'POST'}).then(()=>toast('Playing'))">Play</button>
</div>
</div>`;
}
async function renderAlbums(el) {
const [albums, settings] = await Promise.all([api('/api/albums'), api('/api/settings')]);
const selected = JSON.parse(settings.albums_json || '[]');
el.innerHTML = `
<div class="card">
<h2>Albums</h2>
<ul class="checkbox-list">
${albums.map(a => `
<li>
<input type="checkbox" value="${a.id}" ${selected.includes(a.id) ? 'checked' : ''}>
<span>${a.title} (${a.assetCount})</span>
</li>`).join('')}
</ul>
<div class="btn-group">
<button class="btn btn-primary" id="saveAlbums">Save Selection</button>
</div>
</div>`;
document.getElementById('saveAlbums').onclick = async () => {
const ids = [...el.querySelectorAll('input[type=checkbox]:checked')].map(c => c.value);
await api('/api/albums/select', { method: 'POST', body: JSON.stringify({ album_ids: ids }) });
toast('Albums saved');
};
}
async function renderSlideshow(el) {
const settings = await api('/api/settings');
const intervals = [1, 5, 15, 30, 60];
const modes = ['Random', 'Chronological', 'Reverse Chrono', 'Favorites Weighted'];
el.innerHTML = `
<div class="card">
<h2>Interval</h2>
<div class="preset-btns">
${intervals.map(i => `<button class="${settings.interval_min===i?'active':''}" data-interval="${i}">${i}m</button>`).join('')}
</div>
</div>
<div class="card">
<h2>Cycling Mode</h2>
<div class="field">
<select id="cycleMode">
${modes.map((m, i) => `<option value="${i}" ${settings.cycle_mode===i?'selected':''}>${m}</option>`).join('')}
</select>
</div>
</div>
<div class="btn-group"><button class="btn btn-primary" id="saveSlideshow">Save</button></div>`;
el.querySelectorAll('[data-interval]').forEach(btn => {
btn.onclick = () => {
el.querySelectorAll('[data-interval]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
};
});
document.getElementById('saveSlideshow').onclick = async () => {
const interval = parseInt(el.querySelector('[data-interval].active')?.dataset.interval || '5');
const mode = parseInt(document.getElementById('cycleMode').value);
await api('/api/settings', { method: 'POST', body: JSON.stringify({ interval_min: interval, cycle_mode: mode }) });
toast('Slideshow settings saved');
};
}
async function renderDisplay(el) {
const settings = await api('/api/settings');
const flags = settings.meta_flags;
el.innerHTML = `
<div class="card">
<h2>Image Quality</h2>
<div class="field">
<select id="imgQuality">
<option value="0" ${settings.img_quality===0?'selected':''}>Preview (faster)</option>
<option value="1" ${settings.img_quality===1?'selected':''}>Original (higher quality)</option>
</select>
</div>
</div>
<div class="card">
<h2>Metadata Overlay</h2>
<ul class="checkbox-list">
<li><input type="checkbox" data-flag="1" ${flags&1?'checked':''}><span>Date</span></li>
<li><input type="checkbox" data-flag="2" ${flags&2?'checked':''}><span>Location</span></li>
<li><input type="checkbox" data-flag="4" ${flags&4?'checked':''}><span>People</span></li>
<li><input type="checkbox" data-flag="8" ${flags&8?'checked':''}><span>Album</span></li>
<li><input type="checkbox" data-flag="16" ${flags&16?'checked':''}><span>Camera</span></li>
</ul>
<div class="field">
<label>Position</label>
<select id="metaPos">
<option value="0" ${settings.meta_pos===0?'selected':''}>Bottom</option>
<option value="1" ${settings.meta_pos===1?'selected':''}>Top</option>
</select>
</div>
</div>
<div class="btn-group"><button class="btn btn-primary" id="saveDisplay">Save</button></div>`;
document.getElementById('saveDisplay').onclick = async () => {
let flags = 0;
el.querySelectorAll('[data-flag]').forEach(cb => { if (cb.checked) flags |= parseInt(cb.dataset.flag); });
await api('/api/settings', { method: 'POST', body: JSON.stringify({
img_quality: parseInt(document.getElementById('imgQuality').value),
meta_flags: flags,
meta_pos: parseInt(document.getElementById('metaPos').value)
})});
toast('Display settings saved');
};
}
async function renderDevice(el) {
el.innerHTML = `
<div class="card">
<h2>Device</h2>
<div class="btn-group">
<button class="btn btn-danger" onclick="if(confirm('Enter deep sleep?'))api('/api/action/sleep',{method:'POST'})">Deep Sleep</button>
<button class="btn btn-danger" onclick="if(confirm('Reboot?'))fetch('/api/action/reboot',{method:'POST'})">Reboot</button>
</div>
</div>`;
}
async function renderFirmware(el) {
el.innerHTML = `
<div class="card">
<h2>Firmware Update</h2>
<div class="field">
<label>Upload .bin file</label>
<input type="file" id="fwFile" accept=".bin">
</div>
<button class="btn btn-primary" id="uploadFw">Upload & Flash</button>
<div id="fwStatus" style="margin-top:12px"></div>
</div>`;
document.getElementById('uploadFw').onclick = async () => {
const file = document.getElementById('fwFile').files[0];
if (!file) return toast('Select a file first');
const formData = new FormData();
formData.append('firmware', file);
document.getElementById('fwStatus').textContent = 'Uploading...';
const res = await fetch('/api/firmware', { method: 'POST', body: formData });
const data = await res.json();
document.getElementById('fwStatus').textContent = data.msg || (data.ok ? 'Success!' : 'Failed');
};
}
// Navigation
document.querySelectorAll('#nav-links a').forEach(a => {
a.addEventListener('click', (e) => { e.preventDefault(); navigate(a.dataset.page); });
});
// Initial render
navigate('dashboard');
```
- [ ] **Step 7: Build and upload filesystem**
Run: `pio run -e m5stack-papercolor && pio run -e m5stack-papercolor -t uploadfs`
Expected: BUILD SUCCESS, filesystem upload succeeds
- [ ] **Step 8: Flash firmware and verify web UI**
Run: `pio run -e m5stack-papercolor -t upload`
Then connect to `PaperColor-Setup` WiFi and open `192.168.4.1` in a browser (AP mode), or navigate to `http://papercolor.local` (station mode).
Expected: Setup page renders in AP mode. After provisioning, management UI renders with all pages.
- [ ] **Step 9: Commit**
```bash
git add src/web_server.h src/web_server.cpp data/
git commit -m "feat: web server with REST API, captive portal, and management UI"
```
---
### Task 11: Main Integration (Slideshow State Machine)
**Files:**
- Modify: `src/main.cpp` — full rewrite with FreeRTOS tasks and slideshow logic
**Interfaces:**
- Consumes: All modules (SettingsManager, WiFiManager, PowerManager, ButtonHandler, ImmichClient, PhotoQueue, ImagePipeline, DisplayManager, AppWebServer)
- Produces: Complete working firmware with:
- Display task on Core 1 (timer-driven photo refresh)
- Web server on Core 0 (always listening)
- Battery monitor integrated into main loop
- Button events dispatched to slideshow state machine
- [ ] **Step 1: Rewrite `src/main.cpp` with full integration**
```cpp
#include <Arduino.h>
#include <M5Unified.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"
// Global instances
SettingsManager settingsManager;
WiFiManager wifiManager;
PowerManager powerManager;
ButtonHandler buttonHandler;
ImmichClient immichClient;
PhotoQueue photoQueue;
ImagePipeline imagePipeline;
DisplayManager displayManager;
AppWebServer webServer;
// Shared state
SemaphoreHandle_t stateMutex;
volatile bool slideshowPlaying = true;
volatile bool refreshRequested = false;
volatile bool randomRequested = false;
volatile unsigned long lastRefreshTime = 0;
// Task handles
TaskHandle_t displayTaskHandle = nullptr;
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());
} else {
displayManager.showMessage("No Photos", "Select albums in web UI");
}
}
while (true) {
unsigned long now = millis();
Settings s = settingsManager.get();
unsigned long intervalMs = s.interval_min * 60000UL;
bool shouldRefresh = false;
bool wantsRandom = false;
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
if (refreshRequested) {
shouldRefresh = true;
refreshRequested = false;
} else if (randomRequested) {
shouldRefresh = true;
wantsRandom = true;
randomRequested = false;
} else if (slideshowPlaying && (now - lastRefreshTime >= intervalMs)) {
shouldRefresh = true;
}
xSemaphoreGive(stateMutex);
}
// Periodic re-sync
if (photoQueue.needsResync() && wifiManager.isConnected()) {
photoQueue.sync();
}
if (shouldRefresh && photoQueue.size() > 0 && wifiManager.isConnected()) {
// Get next photo ID
String assetId;
if (wantsRandom) {
assetId = photoQueue.random();
} else {
assetId = photoQueue.next();
}
if (assetId.length() > 0) {
Serial.printf("[display_task] Loading asset: %s\n", assetId.c_str());
// Fetch asset info for portrait detection and metadata
AssetInfo info = immichClient.fetchAssetInfo(assetId);
// Download photo
uint8_t* jpegBuf = nullptr;
size_t jpegSize = 0;
bool downloaded = immichClient.downloadAsset(
assetId, s.img_quality, &jpegBuf, &jpegSize);
if (downloaded && jpegBuf != nullptr) {
ProcessedImage img;
if (info.isPortrait) {
// Try to find a portrait pair
String pairId = photoQueue.findPortraitPair(
settingsManager.get().queue_cursor);
if (pairId.length() > 0) {
AssetInfo pairInfo = immichClient.fetchAssetInfo(pairId);
if (pairInfo.isPortrait) {
uint8_t* jpeg2Buf = nullptr;
size_t jpeg2Size = 0;
if (immichClient.downloadAsset(pairId, s.img_quality,
&jpeg2Buf, &jpeg2Size)) {
img = imagePipeline.processPortraitPair(
jpegBuf, jpegSize, jpeg2Buf, jpeg2Size);
free(jpeg2Buf);
} else {
img = imagePipeline.process(jpegBuf, jpegSize);
}
} else {
img = imagePipeline.process(jpegBuf, jpegSize);
}
} else {
img = imagePipeline.process(jpegBuf, jpegSize);
}
} else {
img = imagePipeline.process(jpegBuf, jpegSize);
}
free(jpegBuf);
if (img.valid) {
displayManager.showImage(img);
// Show metadata overlay if enabled
if (s.meta_flags != 0) {
displayManager.showMetadata(info, s.meta_flags, s.meta_pos);
}
imagePipeline.freeImage(img);
} else {
Serial.println("[display_task] Image processing failed");
}
}
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
lastRefreshTime = millis();
xSemaphoreGive(stateMutex);
}
}
}
// 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;
}
xSemaphoreGive(stateMutex);
}
}
void setup() {
auto cfg = M5.config();
M5.begin(cfg);
Serial.begin(115200);
Serial.println("[main] Immich Frame v1.0 booting...");
// Create state mutex
stateMutex = xSemaphoreCreateMutex();
// Initialize subsystems
powerManager.begin();
settingsManager.begin();
wifiManager.begin(settingsManager);
buttonHandler.begin(powerManager, settingsManager);
displayManager.begin(powerManager);
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);
if (wifiManager.isAPMode()) {
displayManager.showMessage("Setup Required",
"Connect to PaperColor-Setup WiFi");
} else {
displayManager.showMessage("Immich Frame", "Loading photos...");
// Create display task on Core 1
xTaskCreatePinnedToCore(displayTask, "display", 32768, nullptr, 1,
&displayTaskHandle, 1);
}
// Enable light sleep for power saving
powerManager.enableLightSleep();
Serial.println("[main] Boot complete");
}
void loop() {
M5.update();
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:
case ButtonEvent::FactoryReset:
break;
case ButtonEvent::None:
break;
default: {
ButtonEvent unreachable = event;
(void)unreachable;
break;
}
}
delay(10);
}
```
- [ ] **Step 2: Build full firmware**
Run: `pio run -e m5stack-papercolor`
Expected: BUILD SUCCESS with all modules linked
- [ ] **Step 3: Upload filesystem and firmware**
Run: `pio run -e m5stack-papercolor -t uploadfs && pio run -e m5stack-papercolor -t upload`
Expected: Both upload successfully
- [ ] **Step 4: End-to-end test**
1. Device boots into AP mode (first time)
2. Connect to `PaperColor-Setup`, configure WiFi + Immich API key
3. Device reboots, connects to WiFi
4. Navigate to `http://papercolor.local` — dashboard loads
5. Select albums in Albums page
6. Device begins displaying photos from slideshow
7. Press buttons: BTN_UP advances photo, BTN_TOP shows random, BTN_DOWN pauses
Run: `pio device monitor`
Expected: Serial shows full lifecycle: boot → WiFi → Immich connect → queue sync → photo fetch → decode → dither → display
- [ ] **Step 5: Commit**
```bash
git add src/main.cpp
git commit -m "feat: full integration with FreeRTOS tasks and slideshow state machine"
```
---
### Task 12: Testing & Polish
**Files:**
- Create: `test/README.md`
- Modify: Various files for bug fixes discovered during testing
**Interfaces:**
- Consumes: Full firmware
- Produces: Working, tested firmware ready for daily use
- [ ] **Step 1: Create test documentation**
Create `test/README.md`:
```markdown
# 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)
```
- [ ] **Step 2: Run native tests**
Run: `pio test -e native`
Expected: All tests PASS
- [ ] **Step 3: Run full device verification checklist**
Flash firmware and LittleFS, then work through the checklist above item by item. Fix any issues discovered.
- [ ] **Step 4: Commit any fixes**
```bash
git add -A
git commit -m "fix: testing corrections and polish"
```
- [ ] **Step 5: Tag release**
```bash
git tag v1.0.0
git log --oneline -10
```
Expected: Clean commit history showing incremental feature additions.
---
## Summary
| Task | What It Builds | Key Files |
|------|---------------|-----------|
| 1 | Project scaffolding | `platformio.ini`, `config.h`, `main.cpp` |
| 2 | Settings persistence | `settings.h/cpp` |
| 3 | WiFi (station + AP) | `wifi_manager.h/cpp` |
| 4 | Power/battery/sleep | `power_manager.h/cpp` |
| 5 | Button input | `button_handler.h/cpp` |
| 6 | Immich API client | `immich_client.h/cpp` |
| 7 | Photo queue/cycling | `photo_queue.h/cpp` |
| 8 | Image decode+dither | `image_pipeline.h/cpp` |
| 9 | E-ink display output | `display_manager.h/cpp` |
| 10 | Web UI + REST API | `web_server.h/cpp`, `data/*` |
| 11 | Full integration | `main.cpp` (FreeRTOS tasks) |
| 12 | Test & polish | `test/README.md`, bug fixes |