Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
env: [ee02, ee03, ee04, ee05]
env: [ee02]
steps:
- uses: actions/checkout@v4
with:
Expand Down Expand Up @@ -60,21 +60,19 @@ jobs:
fetch-depth: 0 # git rev-list --count needs full history
- uses: actions/download-artifact@v4
with:
path: fw # -> fw/fw-ee02/firmware.bin, ...
path: fw # -> fw/fw-ee02/firmware.bin
- name: Assemble assets + manifest
run: |
set -euo pipefail
BUILD=$(git rev-list --count HEAD)
HASH=$(git rev-parse --short=8 HEAD)
mkdir dist
cp fw/fw-ee02/firmware.bin dist/firmware-ee02.bin
{
echo "# generated by .github/workflows/ci.yml — do not edit"
echo "build=$BUILD"
echo "hash=$HASH"
for e in ee02 ee03 ee04 ee05; do
cp "fw/fw-$e/firmware.bin" "dist/firmware-$e.bin"
echo "md5_$e=$(md5sum "dist/firmware-$e.bin" | cut -d' ' -f1)"
done
echo "md5_ee02=$(md5sum dist/firmware-ee02.bin | cut -d' ' -f1)"
} > dist/manifest.txt
cat dist/manifest.txt
- name: Move firmware-latest release to this commit
Expand Down
154 changes: 65 additions & 89 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,27 @@
# Hello ePaper — Standalone, zero-cloud firmware for the Seeed Studio XIAO ePaper Display Board family (EE02/EE03/EE04/EE05)
# Paperframe — standalone, zero-cloud firmware for the Seeed Studio XIAO ePaper Display Board (EE02)

[![ci](../../actions/workflows/ci.yml/badge.svg)](../../actions/workflows/ci.yml)

One firmware, one codebase, four `pio run -e <env>` targets, all on the
**XIAO ESP32-S3 Plus** — see [Supported hardware](#supported-hardware).
One firmware, one board: the **XIAO EE02** (XIAO ESP32-S3 Plus + the
13.3″ Spectra-6 colour panel, 1200×1600, portrait-native).

Seeed ships these boards pre-flashed with SenseCraft HMI, a cloud-based
Seeed ships this board pre-flashed with SenseCraft HMI, a cloud-based
no-code dashboard tool that requires an account and an internet connection to
reconfigure. This firmware needs neither: every setting — refresh interval,
image source, quiet hours, timezone, orientation — is configured from your
phone talking directly to the device over your own Wi-Fi. Nothing else has
to run, ever.

<!-- PHOTO: frame on a wall showing a photo -->
<img src="docs/img/status-page-mockup.png" alt="Status page: next image refresh, battery, Wi-Fi, a QR code to the settings portal, device details, and a button legend" width="360">

<img src="docs/img/status-page-mockup.png" alt="Status page: wake reason, last/next photo, Wi-Fi, battery, a QR code linking to the settings portal, and a button legend" width="360">
*Design mockup — not a photo of the real panel, which is glossier and
paper-white.*

*Mockup rendered from the actual drawing code — not a photo of the real
panel, which is glossier and paper-white. Real photo pending.*

On a schedule you choose it wakes from deep sleep, fetches a photo from a
URL you choose, dithers it to the panel's palette (6-color, BWRY, 16-gray or
mono depending on the panel), refreshes, and goes back to sleep. Everything
is configured **on the device itself** — no account, no cloud, no companion
server. This is a hobby project: support is best-effort, MIT-licensed, no
warranty.
On a schedule you choose it wakes from deep sleep, fetches an image from a
URL you choose, dithers it to the panel's six-colour palette, refreshes, and
goes back to sleep. Everything is configured **on the device itself** — no
account, no cloud, no companion server. This is a hobby project: support is
best-effort, MIT-licensed, no warranty.

## How it works

Expand All @@ -33,76 +30,62 @@ The firmware is one loop: sleep, wake for a reason, act, sleep again.
```mermaid
flowchart TD
sleep([deep sleep]) -->|"timer: scheduled refresh"| fetch
sleep -->|"KEY2: new picture"| fetch
sleep -->|"KEY2: refresh image"| fetch
sleep -->|"KEY1: status page"| portal
sleep -->|"KEY3: pin / unpin"| pin
fetch["fetch photo → dither → refresh panel (~30 s)"] --> sleep
fetch["fetch image → dither → refresh panel (~30 s)"] --> sleep
portal["status page on panel,<br>settings portal live on Wi-Fi"] -->|"save · KEY1 · 10 min idle"| fetch
pin["blink LED, toggle pin"] --> sleep
```

Timer wakes that have nothing to do — the photo is pinned, or the wake
Timer wakes that have nothing to do — the image is pinned, or the wake
lands inside quiet hours — roll straight back to sleep without touching
the panel or the radio.

## Supported hardware

One firmware, four PlatformIO envs — all XIAO ESP32-S3 Plus + a XIAO ePaper
driver board, differing only in `BOARD_SCREEN_COMBO` and a few string
defaults (see `platformio.ini`):

| Env | Board | Default panel | Combo |
|---|---|---|---|
| `ee02` | EE02 | 13.3″ Spectra-6 colorful, 1200×1600 | 510 |
| `ee03` | EE03 | 10.3″ ED103TC2, 16-level grayscale, 1872×1404 | 511 |
| `ee04` | EE04 (24-pin + 50-pin adapter universal driver) | 7.5″ mono UC8179, 800×480 | 502 |
| `ee05` | EE05 (24-pin-only universal driver) | 7.5″ mono UC8179, 800×480 | 502 |

`ee02` is hardware-verified (the unit this repo was developed against);
`ee03`/`ee04`/`ee05` are compile-verified only — see
`docs/hardware-checklist.md` for the specific untested risk areas. EE04 and
EE05 are universal driver boards that support roughly ten interchangeable
panels each — swap `BOARD_SCREEN_COMBO` in your env, no code changes needed;
see `docs/panel-combos.md`.
## Hardware

| Part | Source |
|---|---|
| XIAO ePaper Display Board (EE02/EE03/EE04/EE05), XIAO ESP32-S3 Plus | Seeed Studio |
| XIAO ePaper Display Board **EE02** + XIAO ESP32-S3 Plus | Seeed Studio |
| 3.7 V Li-ion battery, JST PH 2.0mm | any (see battery notes) |

The panel is a 13.3″ Spectra-6 (E Ink Gallery) colour e-paper, 1200×1600,
driven at `BOARD_SCREEN_COMBO=510`. Full-panel refresh only (no partial
mode), ~25–30 s per update.

## Buttons

| Silkscreen | GPIO | Function |
|---|---|---|
| KEY1 | 2 | Toggle the full-screen **status page**. While it shows, the **settings portal** is live on your Wi-Fi. |
| KEY2 | 3 | Fetch a **new picture** now. |
| KEY3 | 5 | **Pin/freeze** the current photo — scheduled refreshes pause (and barely sip battery) until pressed again. |
| KEY2 | 3 | **Refresh the image** now. |
| KEY3 | 5 | **Pin/freeze** the current image — scheduled refreshes pause (and barely sip battery) until pressed again. |
| RESET | — | Hardware reboot (clears boot counter and battery-delta memory). With BOOT held: flashing bootloader. |

Every press is acknowledged by the LED within ~0.5 s (1 blink = new
picture, 2 = status page, 3 = pin), because the panel itself takes much
longer to change — this firmware always does a **full-panel refresh** (no
partial-refresh mode), ~25–30 s on the EE02's Spectra-6 panel; other panels'
refresh times are untested.
Every press is acknowledged by the LED within ~0.5 s (1 blink = refresh
image, 2 = status page, 3 = pin), because the panel itself takes much
longer to change.

## First-time setup

1. Connect the panel's FPC cable, flip the power switch on, plug in USB-C.
2. The panel shows two QR codes: scan the first to join the frame's hotspot (`<Board>-Setup`, e.g. `EE02-Setup`), and the setup page opens by itself (second QR / http://192.168.4.1 as fallback). Pick your 2.4 GHz network.
2. The panel shows the onboarding screen: scan the QR to join the device's
hotspot (`Paperframe-Setup`), and the setup page opens by itself
(http://192.168.4.1 as fallback). Pick your 2.4 GHz network.
3. Credentials persist on-device (NVS). Nothing secret ever enters this repo.

## Settings

Press **KEY1**. The panel shows the status page (wake reason, last/next
refresh, Wi-Fi, battery) plus a URL and QR code — open it from any device
Press **KEY1**. The panel shows the status page (next refresh, Wi-Fi,
battery, device details) plus a URL and QR code — open it from any device
on the same Wi-Fi:

```mermaid
flowchart LR
a["press KEY1"] --> b["panel draws status page (~30 s)"]
b --> c["portal live at http://&lt;name&gt;.local"]
c --> d["phone: scan QR,<br>change settings, Save"]
d --> e["fresh photo,<br>back to sleep"]
c --> d["phone: scan QR,<br>change settings"]
d --> e["fresh image,<br>back to sleep"]
```

| Setting | Choices | Default |
Expand All @@ -112,31 +95,30 @@ flowchart LR
| Paused | pin/freeze (same as KEY3) | off |
| Quiet hours | skip refreshes in a nightly window | off |
| Timezone | auto (IP geolocation) or manual offset | auto |
| Device name | mDNS hostname (`<name>.local`) | `ee02`/`ee03`/`ee04`/`ee05` (per env) |
| Device name | mDNS hostname (`<name>.local`) | `paperframe` |
| Orientation | portrait / landscape, each flippable | portrait |

The page also has **Fetch new picture now** and **Forget Wi-Fi** buttons.
The portal stops when you leave the status page (press KEY1 again, save,
or 10 minutes idle) — the device then fetches a picture and sleeps.
Transient failures on scheduled refreshes never touch the panel — the
current photo stays up and the next wake retries.
If saved Wi-Fi stops working (new router, moved house), the frame
reopens its `<Board>-Setup` hotspot by itself.
Settings auto-save per field. The portal stops when you leave the status
page (press KEY1 again or 10 minutes idle) — the device then fetches an
image and sleeps. Transient failures on scheduled refreshes never touch
the panel — the current image stays up and the next wake retries. If saved
Wi-Fi stops working (new router, moved house), the device reopens its
`Paperframe-Setup` hotspot by itself.

### Image source contract

The URL must return a **baseline (non-progressive) JPEG** at the panel
size. Tokens are substituted per fetch:

- `{width}` / `{height}` — panel size after orientation (e.g. on EE02,
1200×1600 portrait or 1600×1200 landscape; varies by board/panel)
- `{width}` / `{height}` — panel size after orientation (1200×1600
portrait or 1600×1200 landscape)
- `{seed}` — random number (cache-buster)

The default goes through images.weserv.nl because picsum serves
progressive JPEGs, which the on-device decoder can't parse; weserv
re-encodes to baseline at exact size. Pointing at your own server or a
static file works — just honor the contract. Please be a good citizen of
free services: one frame is nothing, a fleet is not.
free services: one device is nothing, a fleet is not.

## Privacy & security notes

Expand All @@ -147,19 +129,16 @@ free services: one frame is nothing, a fleet is not.
**non-commercial** tier) and reveals your public IP to them. Set a
manual timezone in the portal to disable it entirely.
- The settings portal is HTTP on your LAN, unauthenticated — anyone on
your Wi-Fi can change your photo frame's settings. Threat model: roommates.
your Wi-Fi can change the device's settings. Threat model: roommates.

## Building & flashing

PlatformIO CLI. The display driver and per-board defaults are selected
entirely by `build_flags` in `platformio.ini` — never edit library files.
PlatformIO CLI. The display driver is selected entirely by `build_flags`
in `platformio.ini` — never edit library files.

```bash
pio run -e ee02 # build for EE02 (13.3" Spectra-6 colorful)
pio run -e ee03 # build for EE03 (10.3" 16-gray)
pio run -e ee04 # build for EE04 (7.5" mono default; see docs/panel-combos.md)
pio run -e ee05 # build for EE05 (7.5" mono default; see docs/panel-combos.md)
pio run -t upload # flash the default env (ee02) over USB-C; add -e <env> for others
pio run -e ee02 # build the firmware
pio run -t upload # flash over USB-C
pio device monitor # serial at 115200 (USB-CDC)
pio test -e native # host-side unit tests (no hardware needed)
```
Expand All @@ -177,7 +156,7 @@ http://<name>.local the whole time — no KEY1 needed.

While the portal's up (dev mode, or the 10-minute KEY1 status window),
`http://<name>.local/debug` is a full remote-debugging page: the last
fetched photo, a live capture of whatever's actually on the panel right
fetched image, a live capture of whatever's actually on the panel right
now, a capture of what was on it just before that, and a rolling copy of
the last ~8KB of Serial output — handy when `pio device monitor` isn't
available (e.g. no interactive TTY). Same unauthenticated-on-your-LAN
Expand All @@ -187,10 +166,10 @@ its own, and the board can be driven remotely too:
| Route | Returns |
|---|---|
| `GET /log` | Plain-text Serial log — first line includes the firmware's git hash (see `docs/versioning.md`) |
| `GET /last.jpg` | The last successfully fetched photo (JPEG) |
| `GET /last.jpg` | The last successfully fetched image (JPEG) |
| `GET /current` | Live capture of the panel right now, full resolution (BMP) |
| `GET /previous` | Capture of the panel just before the most recent redraw, downscaled to a ~400px thumbnail (BMP) |
| `POST /debug/key1` `/key2` `/key3` | Simulate a KEY1/KEY2/KEY3 press (status view, new photo, pin/freeze) — e.g. `curl -X POST http://<name>.local/debug/key2` |
| `POST /debug/key1` `/key2` `/key3` | Simulate a KEY1/KEY2/KEY3 press (status view, refresh image, pin/freeze) — e.g. `curl -X POST http://<name>.local/debug/key2` |

Plugging in the USB cable is *not* itself a wake source — only
`esp_sleep_enable_timer_wakeup` (the scheduled refresh) and
Expand All @@ -208,12 +187,12 @@ get a live port, not a bug.
## Source layout

```
src/config.h pins, buttons, defaults, constants
src/config.h pins, buttons, defaults, panel constants
src/settings.* runtime configuration (NVS-backed)
src/logic/ pure decision logic — host-testable, no Arduino deps
src/display.* EPaper object, palette dither, JPEG decode
src/layout.* panel-size-aware screen layout (wraps logic/layout_math.h)
src/net.* Wi-Fi provisioning (WiFiManager), photo fetch, timezone+NTP
src/logic/layout_math.h fixed 12-column status-screen grid
src/net.* Wi-Fi provisioning (WiFiManager), image fetch, timezone+NTP
src/portal.* settings web portal (served while the status page shows)
src/portal_html.h embedded HTML for the settings portal
src/power.* battery ADC + percent curve, LED, deep-sleep entry
Expand All @@ -225,22 +204,19 @@ test/ native unit tests (pio test -e native)

## Hardware notes (hard-won)

- **Color-mode panels (4 bpp) are palette-indexed.** On EE02/EE03 and other
`USE_COLORFULL_EPAPER`/`USE_BWRY_EPAPER`/gray panels, `TFT_*` color macros
are panel nibbles (e.g. EE02: WHITE=0x0, GREEN=0x2, RED=0x6, YELLOW=0xB,
- **The Spectra-6 panel is palette-indexed (4 bpp).** `TFT_*` colour
macros are panel nibbles (WHITE=0x0, GREEN=0x2, RED=0x6, YELLOW=0xB,
BLUE=0xD, BLACK=0xF) and `drawPixel` stores `color & 0x0F`. Raw RGB565
pushed via `pushImage` renders garbage — photos must be dithered to the
panel's palette (`src/display.cpp` picks the right one per
`BOARD_SCREEN_COMBO` at compile time).
pushed via `pushImage` renders garbage — images must be dithered to the
panel's palette (`src/display.cpp`).
- **Reading pixels back has the same pitfall, mirrored.** `EPaper::readPixel()`
(from the vendored `TFT_eSPI`) decodes 4bpp/1bpp storage through a
generic `_colorMap` this project never populates — it returns
self-consistent but meaningless colors. Use `display.h`'s
`truePixelColor()` instead: it reads the raw stored value via
`readPixelValue()` and reverse-matches it against `display.cpp`'s own
`PALETTE` table, the same one `ditherToPanel()` writes through.
Confirmed the hard way — `/current` (see above) was pure noise until
this fix.
(from the vendored `TFT_eSPI`) decodes 4bpp storage through a generic
`_colorMap` this project never populates — it returns self-consistent
but meaningless colours. Use `display.h`'s `truePixelColor()` instead:
it reads the raw stored value via `readPixelValue()` and reverse-matches
it against `display.cpp`'s own `PALETTE` table, the same one
`ditherToPanel()` writes through. Confirmed the hard way — `/current`
was pure noise until this fix.
- **Deep sleep floats digital-only pads** — the panel/battery enable lines
(GPIO43/6) are latched with `gpio_hold_en` before sleeping, released at
boot. The held/quiet fast path (`quickSleep`) deliberately leaves them
Expand Down
Loading
Loading