# Immich Photo Frame — M5Stack PaperColor Firmware Design **Date**: 2026-08-03 **Status**: Approved **Platform**: M5Stack PaperColor (ESP32-S3R8, 4" E Ink Spectra 6) ## Overview Custom firmware that turns the M5Stack PaperColor into a self-contained photo frame connected to a self-hosted Immich instance. The device fetches photos over WiFi, dithers them to the 6-color e-ink palette on-device, and displays them in a configurable slideshow. A web-based management UI served from the device allows album selection, slideshow settings, and OTA firmware updates. ## Hardware Summary | Component | Detail | |-----------|--------| | SoC | ESP32-S3R8 (dual-core LX7 @ 240MHz) | | Flash | 16MB | | PSRAM | 8MB | | Display | 4" E Ink Spectra 6, 600×400 (landscape orientation), 6 colors | | Battery | 1250mAh | | Power IC | M5PM1 (I2C 0x6E) | | Buttons | BTN_TOP (G1), BTN_UP (G9), BTN_DOWN (G10), Power (side) | | LEDs | 2× RGB NeoPixel (G21) | | WiFi | 2.4GHz | | Storage | microSD slot (unused in v1), NVS, LittleFS | | Audio | ES8311 + ES7210 + speaker — **permanently disabled** | ## Architecture ### FreeRTOS Task Layout ``` ┌─────────────────────────────────────────────────────┐ │ FreeRTOS Tasks │ ├──────────────┬──────────────┬───────────────────────┤ │ Display Task │ Web Server │ Battery Monitor Task │ │ (Core 1) │ Task (Core 0)│ (Core 0, low priority)│ ├──────────────┴──────────────┴───────────────────────┤ │ Shared State (mutex-protected) │ │ - Settings (NVS) │ │ - Photo queue / cursor │ │ - Battery level │ │ - Slideshow state (playing/paused) │ ├──────────────────────────────────────────────────────┤ │ Services Layer │ │ - ImmichClient (HTTPS + API key) │ │ - ImagePipeline (decode → dither → framebuffer) │ │ - PowerManager (M5PM1 interface, sleep control) │ │ - WiFiManager (AP mode ↔ Station mode) │ ├──────────────────────────────────────────────────────┤ │ Hardware Abstraction │ │ - M5Unified / M5GFX (display, buttons, LEDs) │ │ - M5PM1 (power management IC) │ │ - NVS / LittleFS (persistent storage) │ └─────────────────────────────────────────────────────┘ ``` - **Core 1 (app core)**: Display task handles JPEG decode + dithering (CPU-intensive). - **Core 0 (protocol core)**: Web server, WiFi stack, battery monitor (I/O-bound). - **Automatic light sleep**: `esp_pm_configure()` with `light_sleep_enable = true`. CPU sleeps at ~5-15mA between task activity. WiFi stays associated via DTIM wake intervals. ### Power States | State | What's Active | Current | When | |-------|---------------|---------|------| | Active (refresh) | CPU full, WiFi, PSRAM, e-ink | ~200mA | 15-25s per photo | | Idle (light sleep) | WiFi associated, web server listening | ~5-15mA | Between refreshes | | Deep sleep | RTC + power button wake only | ~93µA | User-triggered off mode | ## Connectivity ### WiFi - Station mode for normal operation - AP mode (`PaperColor-Setup`) for initial provisioning or when station fails 3× on boot - mDNS: device advertises as `papercolor.local` - WiFi power save: `WIFI_PS_MIN_MODEM` ### Immich Integration **Base URL**: Configurable (default `https://photos.example.com`) **Auth**: API key in `x-api-key` header, stored in NVS **API Endpoints Used:** | Purpose | Endpoint | |---------|----------| | List albums | `GET /api/albums` | | Album assets | `GET /api/albums/{id}` | | Timeline (all photos) | `GET /api/timeline/buckets` + `GET /api/timeline/bucket` | | Asset metadata | `GET /api/assets/{id}` | | Download photo | `GET /api/assets/{id}/thumbnail?size=preview` or `GET /api/assets/{id}/original` | | Favorites | `GET /api/assets?isFavorite=true` | **Photo Queue:** 1. Build shuffled/sorted list of asset IDs from selected albums 2. Maintain cursor position (persisted in NVS) 3. Re-sync queue every 24 hours (or on manual refresh from web UI) to pick up new photos added to Immich 4. Portrait detection via EXIF orientation or aspect ratio check (height > width = portrait) **Cycling Modes:** - Random: shuffled queue, no repeats until all shown - Chronological: sorted by `dateTimeOriginal` ascending - Reverse-chronological: descending - Favorites-weighted: favorites appear 3× more often in shuffle ## Image Pipeline ``` JPEG Download → JPEG Decode → Resize/Crop → 6-Color Dither → Framebuffer → E-ink Refresh ``` ### Stages 1. **Download**: HTTP(S) stream into PSRAM buffer. Preview ~200-400KB, original up to ~15MB. 2. **Decode**: TJPGD (ESP32 built-in) or JPEGDecoder library → RGB888. For originals larger than needed, decode at reduced scale (1/2 or 1/4). 3. **Resize/Crop**: - Landscape photos: scale to fill 600×400, center-crop overflow - Portrait photos: look ahead up to 5 items in the queue for another portrait to pair with. If found, scale each to fill ~267×400, place side-by-side with 8px gap. If no pair found within lookahead, display single portrait centered with equal margins on each side. 4. **Dither**: Floyd-Steinberg error-diffusion against the Spectra 6 palette. Initial RGB values based on typical Spectra 6 measurements (will be fine-tuned with test images on the actual panel): - Black (0, 0, 0) - White (255, 255, 255) - Red (200, 30, 30) - Green (30, 160, 30) - Blue (30, 30, 200) - Yellow (220, 200, 30) 5. **Display**: Write dithered buffer via M5GFX, trigger EPD refresh (10-20s). ### Memory Budget (PSRAM) | Buffer | Size | |--------|------| | JPEG download | ~400KB (preview) / 15MB (original) | | Decoded RGB888 | 720KB (600×400×3) | | Dither working | In-place (reuses decode buffer) | | Framebuffer | ~120KB | | **Total typical** | **~1.5-2MB** | ### Image Quality Setting Configurable via web UI: - **Preview** (default): Immich's preview thumbnail (~1080p). Fast download, adequate for 600×400. - **Original**: Full-resolution source. Better dithering input but larger download and decode time. ## Display Behavior - **Orientation**: Landscape (600×400) — device physically rotated - **Landscape photos**: Fill-crop to 600×400 - **Portrait photos**: Paired side-by-side (two ~267×400 panels within the 600×400 frame) - **Metadata overlay** (off by default, each toggleable): - Date taken - Location / city - People names (Immich face detection) - Album name - Camera / lens info - **Metadata position**: Bottom overlay or top overlay (configurable) ## Slideshow Settings | Setting | Options | Default | |---------|---------|---------| | Interval | 1m, 5m, 15m, 30m, 60m | 5m | | Cycling mode | Random, Chrono, Reverse-chrono, Favorites-weighted | Random | | Image quality | Preview, Original | Preview | | Metadata | Bitmask of fields | All off (0x00) | ## Button Mapping | Button | GPIO | Action | |--------|------|--------| | BTN_TOP | G1 | Random photo (jump to random position in queue) | | BTN_UP | G9 | Next photo (advance queue) | | BTN_DOWN | G10 | Play/Pause toggle | **Combos:** - BTN_TOP + BTN_DOWN held 3s: Enter deep sleep - BTN_UP held 5s: Factory reset (clear WiFi credentials, reboot to AP mode) **Feedback:** - Brief white RGB LED flash (100ms) on any button press to confirm input - Buttons configured as GPIO interrupts with 50ms debounce - Interrupts wake CPU from light sleep - Events queued via FreeRTOS queue, consumed by display task ## Power Management ### Audio (Permanently Disabled) - GPIO 45 (AUDIO_PWR_EN): held LOW at boot — audio codecs never powered - GPIO 46 (SPK_EN): held LOW at boot — speaker amp never enabled ### M5PM1 Power Rail Control | Rail | Control | State | |------|---------|-------| | E-paper (PY_EPD_EN) | Enable during refresh only | Off between refreshes | | RGB LED power | Enable for battery warnings | Off otherwise | | Grove port (BOOST5V_EN_PP) | Disabled | Not used | | Audio codec (CODEC_3V3_L3B) | Disabled | Never powered | ### Battery Monitoring - Read battery level from M5PM1 via I2C every 60 seconds - LED behavior (2× RGB NeoPixel on G21): - \> 25%: LEDs off - 10-25%: Orange pulse every 5 minutes - ≤ 10%: Red pulse every 2 minutes - Charging detected: Green pulse every 5 minutes - < 5%: Auto-enter deep sleep to protect battery ### Deep Sleep - **Trigger**: Web UI toggle, or BTN_TOP + BTN_DOWN held 3s - **Wake source**: Power button only - **On wake**: Full reboot, restore state from NVS ## Web Management UI ### Provisioning (AP Mode) 1. First boot (no WiFi creds) → create AP `PaperColor-Setup` 2. Captive portal serves setup wizard: - Scan and select WiFi network + enter password - Immich URL (pre-filled `https://photos.example.com`) - Immich API key 3. Save to NVS → reboot to station mode 4. Station connection fails 3× on boot → fallback to AP mode ### LAN Web UI Served by ESPAsyncWebServer on port 80 at `http://papercolor.local` **Pages:** | Page | Content | |------|---------| | Dashboard | Current photo (low-res preview), battery %, WiFi RSSI, uptime, next refresh timer | | Albums | Immich album list with checkboxes, "All photos" toggle, asset counts | | Slideshow | Interval presets, cycling mode, play/pause | | Display | Image quality, metadata toggles, metadata position | | Device | WiFi settings, Immich URL/key, deep sleep toggle, LED brightness, reboot | | Firmware | Current version display, OTA binary upload form | **Tech stack:** - Vanilla JS or Preact (tiny bundle), compiled/minified at build time - Static files served from LittleFS partition - Device exposes REST API for all settings and actions ### REST API ``` GET /api/status → { battery, wifi_rssi, uptime, current_photo, state } GET /api/albums → cached Immich album list POST /api/albums/select → { album_ids: [...] } GET /api/settings → all device settings POST /api/settings → update settings POST /api/action/next → trigger next photo POST /api/action/random → trigger random photo POST /api/action/pause → pause slideshow POST /api/action/play → resume slideshow POST /api/action/sleep → enter deep sleep POST /api/firmware → OTA binary upload ``` ## OTA Updates - Dual app partitions (app0 / app1) for safe rollback - Upload via web UI (`POST /api/firmware`) - Write to inactive partition → verify checksum → switch boot partition → reboot - If new firmware fails to boot (watchdog), ESP32 rolls back automatically ## Storage ### Flash Partition Table (16MB) | Partition | Type | Size | |-----------|------|------| | nvs | data/nvs | 24KB | | otadata | data/ota | 8KB | | app0 | app/ota_0 | 6.5MB | | app1 | app/ota_1 | 6.5MB | | littlefs | data/littlefs | 512KB | | coredump | data/coredump | 64KB | ### NVS Keys | Key | Type | Default | Description | |-----|------|---------|-------------| | `wifi_ssid` | string | — | WiFi SSID | | `wifi_pass` | string | — | WiFi password | | `immich_url` | string | `https://photos.example.com` | Immich base URL | | `immich_key` | string | — | API key | | `img_quality` | u8 | 0 | 0=preview, 1=original | | `interval_m` | u8 | 5 | Slideshow interval (minutes) | | `cycle_mode` | u8 | 0 | 0=random, 1=chrono, 2=reverse, 3=favorites | | `meta_flags` | u8 | 0x00 | Bitmask: date/location/people/album/camera | | `meta_pos` | u8 | 0 | 0=bottom, 1=top | | `queue_cursor` | u32 | 0 | Position in photo queue | | `albums_json` | string | `[]` | Selected album IDs (JSON array) | | `led_bright` | u8 | 50 | LED brightness (0-255) | ### LittleFS Web UI static assets (HTML/JS/CSS), built and uploaded as part of firmware build process. ## Development Setup ### PlatformIO Configuration ```ini [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 build_flags = -DESP32S3 -DBOARD_HAS_PSRAM -DCORE_DEBUG_LEVEL=3 -DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=1 lib_deps = m5stack/M5Unified m5stack/M5GFX m5stack/M5PM1 me-no-dev/ESPAsyncWebServer bblanchon/ArduinoJson ``` ### Dependencies | Library | Purpose | |---------|---------| | M5Unified | Hardware abstraction (buttons, display init) | | M5GFX | Graphics/display driver for E Ink Spectra 6 | | M5PM1 | Power management IC control | | ESPAsyncWebServer | Non-blocking web server | | ArduinoJson | JSON parsing for Immich API responses | | LittleFS | Filesystem for web UI assets | | WiFi / HTTPClient | Networking (built into ESP32 Arduino core) | ## Out of Scope (v1) - Tailscale/WireGuard networking - Audio playback/voice interaction - microSD card usage (photos fetched over WiFi only) - Temperature/humidity sensor integration - RTC alarm-based scheduling - IR remote control - Multi-device sync