Files
tfaour c0fefc19f1
Firmware build check / build-check (push) Successful in 2m44s
Fix ee02 button-wakeup build: ext1 fallback for ESP32-S3
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown() only exists on
ESP32-C6 (SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP), so the ee02
(ESP32-S3) build failed with implicit-declaration errors in
{back,next,combo}_button.c once the epd13in3e driver's #error stopped
masking it.

Each button file now branches on that capability macro: the C6 path
(devkit/xiao) is untouched, and ESP32-S3 uses
esp_sleep_enable_ext1_wakeup_io() instead. The earlier ext1 attempt was
rejected on C6 hardware because its pull resistor didn't hold across
RTC_PERIPH power-down -- tracing the same path in ESP-IDF source shows
gpio_config()'s pull_up_en already delegates to rtc_gpio_pullup_en()
for RTC-capable pins on every non-original-ESP32 target, so the pull-up
should already survive the same power-down on S3. The _io() variant is
additive, so the three button files don't need cross-file mask
coordination. Also widens the button GPIO Kconfig range for
IDF_TARGET_ESP32S3 (0-21, matching its RTC-IO set) instead of the
C6-shaped 0-7.

Verified: ee02, devkit, and xiao all build clean end-to-end locally
(native ESP-IDF v6.0, no Docker in this sandbox). NOT verified: whether
this actually avoids the spurious-instant-wakeup bug on real EE02
hardware -- that failure mode was only ever confirmed empirically, not
root-caused in a way a compile can check. continue-on-error stays on
in CI's ee02 build step until that's confirmed.
2026-08-04 22:46:13 +00:00

275 lines
15 KiB
Markdown

# Hardware
## Parts
- An ESP32-C6 dev board (e.g. ESP32-C6-DevKitC-1). Needs 8MB flash --
see [`firmware/sdkconfig.defaults`](../firmware/sdkconfig.defaults) and
[`firmware/partitions.csv`](../firmware/partitions.csv) if yours differs.
- [Waveshare 7.3" E Ink Spectra 6 (E6) panel](https://www.waveshare.com/7.3inch-e-paper-hat-e.htm) --
800x480, 6-color, SPI.
- A USB cable for flashing/power.
## Wiring
The panel connects over SPI plus three control lines (data/command, reset,
busy). Defaults below match the reference build and are set in
[`firmware/components/epd7in3e/Kconfig`](../firmware/components/epd7in3e/Kconfig) --
override via `idf.py menuconfig` under **E-Paper Display (epd7in3e)
Configuration** if your wiring differs.
| Panel pin | ESP32-C6 GPIO | Kconfig option |
| --------- | ------------- | ----------------- |
| CLK | 20 | `EPD_PIN_CLK` |
| DIN | 19 | `EPD_PIN_MOSI` |
| CS | 18 | `EPD_PIN_CS` |
| DC | 9 | `EPD_PIN_DC` |
| RST | 10 | `EPD_PIN_RST` |
| BUSY | 11 | `EPD_PIN_BUSY` |
| VCC | 3.3V | -- |
| GND | GND | -- |
Optionally, three buttons, all wired the same way -- momentary push button
between the GPIO and GND, no external resistor needed (the firmware
enables each pin's internal pull-up, so it idles high and reads low when
pressed):
- **Next photo (GPIO2)**: a normal press skips immediately to the next
photo; see
[`firmware/README.md`](../firmware/README.md#skipping-to-the-next-photo).
- **Back photo (GPIO0)**: a normal press returns to the previously-shown
photo; see
[`firmware/README.md`](../firmware/README.md#going-back-to-the-previous-photo).
- **Menu / reset (GPIO1)**: one button, three actions by hold duration --
a quick press soft-resets the device (config kept); holding ~3s then
releasing overlays a "scan to manage" QR code on the current photo for
30 seconds; holding ~15s factory-resets it (clears WiFi/server config,
reprovisions); see
[`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting).
All three pins were picked because they're within GPIO 0-7 -- the only
pins the ESP32-C6 can wake from deep sleep on -- aren't strapping pins,
and aren't already used by the panel wiring above. Also, not
incidentally, GPIO 0-7 is *all* the deep-sleep-wakeup-capable pins this
chip has (`SOC_RTCIO_PIN_COUNT` is 8) -- worth knowing if you're
targeting a compact board like the Seeed XIAO ESP32-C6, which only
breaks out 3 of them (GPIO0/1/2). These three buttons were deliberately
designed to fit exactly that budget: two dedicated pins for the
actions where instant, unambiguous response matters most (next, back),
and everything else folded onto the third pin via hold duration instead
of needing its own pin.
### Wiring on the Seeed XIAO ESP32-C6
The XIAO only breaks out 11 GPIOs (0, 1, 2, 16, 17, 18, 19, 20, 21, 22,
23), so the dev-board defaults above don't fit -- DC/RST/BUSY (GPIO 9,
10, 11) aren't exposed on this board at all. Built with
[`build_for_board.sh xiao`](../firmware/README.md#building-for-the-seeed-xiao-esp32-c6-production-board),
which layers [`firmware/sdkconfig.xiao`](../firmware/sdkconfig.xiao) on
top of the dev-board defaults, remapping DC/RST/BUSY onto three of the
remaining free pins:
| Panel pin | XIAO GPIO | XIAO silkscreen label | Kconfig option |
| --------- | --------- | ---------------------- | --------------- |
| CLK | 20 | D9 | `EPD_PIN_CLK` (unchanged) |
| DIN | 19 | D8 | `EPD_PIN_MOSI` (unchanged) |
| CS | 18 | D10 | `EPD_PIN_CS` (unchanged) |
| DC | 16 | D6 | `EPD_PIN_DC` |
| RST | 17 | D7 | `EPD_PIN_RST` |
| BUSY | 21 | D3 | `EPD_PIN_BUSY` |
| VCC | 3.3V | 3V3 | -- |
| GND | GND | GND | -- |
The XIAO's silkscreen labels its pins D0-D10, not raw GPIO numbers --
the table above gives both.
Buttons (unchanged from the table above -- the XIAO's only three
ADC/deep-sleep-capable pins, GPIO 0/1/2, are exactly the ones already
used): next=GPIO2/**D2**, back=GPIO0/**D0**, menu/reset=GPIO1/**D1**.
That leaves GPIO22/**D4** and GPIO23/**D5** free, e.g. for the optional
VBUS mains-sense divider described under
[Battery](#battery-optional-xiao-esp32-c6) below.
`sdkconfig.xiao` also sets `FRAME_XIAO_ANTENNA_INIT=y`, which powers
the XIAO's onboard RF switch and selects its ceramic antenna at boot
(GPIO3/14, internal to the board -- not part of the wiring above).
Without it, WiFi doesn't reliably work on this board at all: the radio
comes up and logs look normal (e.g. the softAP starts and prints its
SSID/password) but the switch's control pins are left floating, so
nothing actually reaches the antenna -- the AP never becomes visible to
a scan, and a station connection would fail to associate the same way.
Seeed's own Arduino board package does this automatically; plain
ESP-IDF (what this firmware uses) doesn't, hence the explicit init.
A couple of things worth knowing if you pick different pins:
- **Avoid the ESP32-C6's strapping pins** (GPIO 4, 5, 8, 9, 15) and the
USB-JTAG pins (12, 13) where possible -- strapping pins are sampled at
reset to select boot mode. The reference wiring above already uses
GPIO9 for DC, which *is* a strapping pin; it's only sampled during
power-on/reset, so it's safe once the app is running, but if
flashing/boot ever misbehaves on your board, check whether the panel is
pulling that line low during reset.
- The panel's SPI interface is rated well above the firmware's default
4MHz clock (`EPD_SPI_CLOCK_HZ`), but breadboard/dupont-wire connections
are often unreliable much past a few MHz. Raise it once your physical
wiring is confirmed solid.
## Battery (optional, XIAO ESP32-C6)
For a battery-powered build on the Seeed XIAO ESP32-C6 (which has
BAT+/BAT- charge pads on its underside and charges over USB-C):
- A 1S 3.7V LiPo **with an integrated protection circuit** (the board
does no low-voltage cutoff of its own), soldered or JST-PH-pigtailed
to the BAT pads. JST-PH polarity is not standardized -- verify with a
multimeter before connecting.
- Battery level sense: two equal-value resistors in series from BAT+ to
GND (200k is the reference value, but any matched pair works -- it's
a ratio divider, so what matters is the two resistors matching each
other, not the absolute value; higher values just draw less constant
current from the battery, e.g. ~10uA for 200k+200k vs ~2uA for
1M+1M, at the cost of a slightly noisier ADC reading from the higher
source impedance -- not noticeable in practice up to around 1M given
how coarse the percent curve already is). Midpoint to GPIO0/A0
(shared with the back button -- deliberate, see
[`firmware/README.md`](../firmware/README.md#battery-xiao-esp32-c6)).
- Optional mains detection: 2x100k divider from the 5V pin to GND,
midpoint to a spare GPIO (e.g. 22).
Both features are off by default in firmware
(`FRAME_BATTERY_ADC_GPIO`/`FRAME_VBUS_SENSE_GPIO` = -1) -- except
`FRAME_BATTERY_ADC_GPIO`, which [`firmware/sdkconfig.xiao`](../firmware/sdkconfig.xiao)
turns on (GPIO0) by default for XIAO builds, since that's the one
board this feature was designed for. Mains detection stays off by
default even there; set `FRAME_VBUS_SENSE_GPIO` yourself if you wire
that divider too.
## Power
The device spends nearly all its time in deep sleep, waking briefly once
an hour (configurable, see the server's web UI) to fetch and display a
photo. A full-color refresh on this panel takes 15-30+ seconds and draws
more current than deep sleep by a wide margin -- expect battery life (if
not running from USB power) to be dominated by refresh frequency, not
sleep current.
## Board identifiers
Each board reports a name to the server (`X-Frame-Board`,
`CONFIG_FRAME_BOARD_NAME`) that's chip-qualified rather than the plain
`devkit`/`xiao` older firmware used -- `devkit_esp32c6`, `xiao_esp32c6`,
`ee02` (see below). This changed once a second XIAO-based board (EE02,
an ESP32-S3) existed and "xiao" alone stopped disambiguating hardware.
The server keeps accepting the old bare names indefinitely, since
already-flashed devices can't be retroactively renamed.
## 13.3" Spectra 6 panel on Seeed's EE02 board (panel driver ported, `ee02` builds end-to-end; unverified on real hardware)
A second panel size is supported server-side (the web UI shows a
read-only "Panel: 13.3\" Spectra 6" once a frame's device reports
itself as `ee02`), and **the panel driver itself is now real and
compiles clean** -- `firmware/components/epd13in3e`'s init/LUT/refresh
register sequence is a line-for-line port of Waveshare's own reference
drivers for this exact panel+controller, confirmed identically across
three independent vendor sources (Waveshare's RaspberryPi/c and ESP32
drivers for this panel, plus Waveshare's own ESP-IDF example for their
ESP32-S3-ePaper-13.3E6 driver board -- a different carrier than EE02,
but the same panel/controller, hence the same command bytes). See that
component's own top comment for details, and
`server/app/image_pipeline.py`'s `PANEL_WIRE_TRANSPOSE` for a load-bearing
correction that came with it: the panel's SPI wire raster is a *native
1200x1600 (portrait)* raster, rotated 90 degrees from the panel's
1600x1200 landscape mount/marketing size -- getting that backwards
doesn't just rotate the image, it shreds it (1600x1200 and 1200x1600
don't share a row stride).
**A full `ee02` build now succeeds** (verified locally with a native,
non-Docker ESP-IDF v6.0 install -- see
`.claude/skills/build-firmware/SKILL.md`); `firmware/main/{back,next,combo}_button.c`
used to call `esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown()`, an
ESP32-C6-only deep-sleep GPIO-wakeup API (gated by
`SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP`, which ESP32-S3's
`soc_caps.h` doesn't define) with no ESP32-S3 fallback path. Each of the
three button files now branches on that same capability macro: the
ESP32-C6 path (devkit/xiao) is untouched, and a new ESP32-S3 path uses
`esp_sleep_enable_ext1_wakeup_io()` (not the non-`_io()`
`esp_sleep_enable_ext1_wakeup()`, which resets any previously-registered
mask -- the `_io()` variant is additive, confirmed by reading
`esp_hw_support/sleep_modes.c`, so the three button files can each keep
registering their own GPIO independently, no combined-mask coordination
needed) plus `esp_sleep_get_ext1_wakeup_status()` for the wake-cause
check. The original C6 EXT1 attempt was rejected on hardware because
its pull resistor didn't hold across the RTC_PERIPH power-down (see
`firmware/main/next_button.c`'s `next_button_init()` comment) -- tracing
the same code path for ESP32-S3 shows `gpio_config()`'s `pull_up_en`
(already used by all three button files) delegates to
`rtc_gpio_pullup_en()` for RTC-capable pins on every non-original-ESP32
target (confirmed in `esp_driver_gpio/gpio.c`: `GPIO_RTCIO_ARE_INDEPENDENT`
is 1 for both C6 and S3, meaning the digital and RTC pull registers are
independent hardware and `gpio_config()` already sets the RTC one), so
the pull-up should already survive the same power-down on ESP32-S3
without any extra `rtc_gpio_*` calls. That reasoning is verified against
IDF source, **not against real EE02 hardware** -- a clean compile
confirms the code builds and links, not that it's actually
spurious-wakeup-free on a real board. CI's
`firmware-build-check.yml`/`firmware-release-build.yml`
`continue-on-error` on this board's step is intentionally still in place
until that hardware verification happens.
Confirmed so far:
- Panel: [Waveshare 13.3" e-Paper (E) Spectra 6](https://www.waveshare.com/13.3inch-e-paper-hat-plus-e.htm) --
1600x1200 mount size, 270.40x202.80mm, same 6-ink Spectra family as
the 7.3" panel (and, now vendor-confirmed, the identical 4-bit nibble
color codes). Full refresh ~19s. SPI wire raster is 1200x1600 (see
above).
- Board: [Seeed's EE02](https://www.seeedstudio.com/XIAO-ePaper-DIY-Kit-EE02-for-13-3-Spectratm-6-E-Ink.html) --
a XIAO ESP32-S3 Plus (16MB flash, 8MB PSRAM) socketed into a dedicated
driver PCB, one reset + three user buttons, JST 2.0mm battery
connector with built-in charging IC.
- Wiring (source: [github.com/rkaramandi/esphome-seeed-ee02](https://github.com/rkaramandi/esphome-seeed-ee02), a community integration, not Seeed's own schematic --
treat as a starting point, confirm before relying on it; Waveshare's
own ESP32-S3-ePaper-13.3E6 example uses different GPIO numbers, but
that's for Waveshare's own driver board, a different carrier than
EE02, so it doesn't apply here). Unlike epd7in3e's single chip-select,
this panel is driven as two halves sharing one CLK/MOSI/DC/RST/BUSY bus
with independent chip-selects -- now confirmed by the real driver code
too (master = left half, slave = right half of each row).
| Signal | GPIO | Kconfig option |
| --- | --- | --- |
| CLK | 7 | `EPD_PIN_CLK` |
| MOSI | 9 | `EPD_PIN_MOSI` |
| CS (master half) | 44 | `EPD_PIN_CS_MASTER` |
| CS (slave half) | 41 | `EPD_PIN_CS_SLAVE` |
| DC | 10 | `EPD_PIN_DC` |
| RST | 38 | `EPD_PIN_RST` |
| BUSY | 4 | `EPD_PIN_BUSY` |
| Panel power-enable | 43 | `EPD_PIN_POWER_EN` |
User buttons are reportedly at GPIO 2/3/5, but which physical button
maps to which logical role (next/back/menu) still isn't confirmed. The
firmware's button Kconfig options (`FRAME_NEXT_BUTTON_GPIO` etc.,
`firmware/main/Kconfig.projbuild`) now range to GPIO -1 to 21 under
`IDF_TARGET_ESP32S3` (the ESP32-S3's own ext1-wakeup-capable RTC-IO
range) instead of the ESP32-C6-shaped -1 to 7, so GPIO 2/3/5 fit
regardless -- but `firmware/sdkconfig.ee02` still deliberately doesn't
override the defaults inherited from the C6 boards (GPIO 2/0/1) until
the role mapping above is confirmed.
SPI clock is reportedly reliable only up to 2MHz on this
panel/board per the community ESPHome integration (vs. epd7in3e's 4MHz
default) -- see `firmware/sdkconfig.ee02`. Waveshare's own
ESP32-S3-ePaper-13.3E6 example defaults to 10MHz, but that's a
different carrier board, so it's a data point to try once real EE02
hardware exists, not a reason to bump the current conservative default
blind.
Remaining unknowns before trusting this on real hardware: whether the
ESP32-S3 button-wakeup path above actually avoids a spurious-instant-wakeup
on a real board (not just compiles), the button-to-role mapping, the
wiring table (community-sourced, not official), and
`PANEL_WIRE_TRANSPOSE`'s rotation *direction* (`ROTATE_90` vs
`ROTATE_270` -- a physical-assembly fact no vendor driver encodes, see
that dict's own comment in `image_pipeline.py`).