Skip to content

Add Waveshare ESP32-S3-ePaper-1.54 board support#53

Open
pritom-kun wants to merge 8 commits into
HermannBjorgvin:mainfrom
pritom-kun:eink
Open

Add Waveshare ESP32-S3-ePaper-1.54 board support#53
pritom-kun wants to merge 8 commits into
HermannBjorgvin:mainfrom
pritom-kun:eink

Conversation

@pritom-kun

Copy link
Copy Markdown

Add Waveshare ESP32-S3-ePaper-1.54 board support

This adds support for the Waveshare ESP32-S3-ePaper-1.54 — a 200×200 monochrome
SSD1681 e-paper panel with no touch, no PMU, and no IMU. It's the first
non-AMOLED, non-color display in the project, so it bumps into a few assumptions
the HAL was built around. I've kept the shared footprint as small as I could and
flagged every place I had to step outside the "just add a board folder" model.

What lives in the board folder (the bulk of the change)

Everything specific to this panel is in firmware/src/boards/waveshare_epaper_154/,
as the porting guide intends:

  • A small vendored SSD1681 driver (epd.h/epd.cpp) plus a display.cpp that
    bridges the HAL's RGB565 framebuffer down to the panel's 1-bit-per-pixel format.
  • The shared UI uses a dark theme (light text on a black background), which would
    look wrong on e-paper, so the driver inverts as it packs pixels: light UI pixels
    become black ink, dark pixels become white paper. The result is black-on-white
    without the UI code knowing anything about it.
  • The light/dark threshold is tuned (64 rather than a naive 128) so that every
    foreground color in the palette — including the red used for the "disconnected"
    state, which sits at the low end — still renders as visible ink, while the
    background and card fills stay white. This is what let me avoid scattering
    e-paper-specific color overrides through the shared UI.
  • Refresh is handled entirely here too: a full refresh at boot, fast partial
    refreshes during use, and a periodic full refresh (every 10 minutes) to clear
    ghosting. A rate-limit keeps the panel to at most one partial per second, so the
    UI's normal animation speed doesn't thrash a display that physically needs
    ~300 ms per update.

The touch/IMU/power stubs are trivial, and there's a new PlatformIO env. None of
this touches shared code.

Files outside the board folder, and why they were necessary

The porting guide says you shouldn't need to edit main.cpp, ui.cpp, or
anything under hal/, and to open an issue if you do because it points at a HAL
gap. E-paper surfaced a couple of real gaps. Here's each one so you can decide
whether you'd prefer a different shape.

hal/board_caps.h and main.cpp — leaving the splash screen without a touchscreen.
On the AMOLED boards you dismiss the boot splash by tapping it. This board has no
touch panel, and its only inputs are the BOOT button (a HID key) and the PWR
button (which, on the splash, cycles through animations). With zero shared edits a
touchless board boots to the splash and can never reach the usage or Bluetooth
screens — it's genuinely stuck. There was no runtime way to express "this board
has no touch," so I added a single has_touch boolean to BoardCaps and one
branch in the PWR handler: touch boards keep cycling animations (you leave via
tap), touchless boards use PWR to advance off the splash. That's the whole change
— one struct field and one if.

ui.cpp — a layout tier for 200×200.
The smallest existing layout targets 368×448 and overflows badly on 200×200.
Step 7 of the porting guide explicitly anticipates this and says to add a
breakpoint to compute_layout() for a new screen size, which is what I did: a
self-contained "tiny" tier alongside the existing two. The screen-builder
functions pull their fonts and sizes from the active tier, so the AMOLED layouts
are unchanged. (One related touch in this file: the "Current"/"Weekly" pill is
inverted on this tier — light fill, dark text — so that after the 1-bit inversion
it shows as a solid black pill with white text instead of disappearing into the
white background.)

The other boards' caps.cpp / board.h, and capability-flags.md.
Adding the has_touch field meant the three existing boards and the template had
to set it; otherwise the designated-initializer default would leave them at
false and silently change their splash behavior. So each gets a one-line
has_touch = true. The capability-flags doc gets a matching row, in keeping with
the existing convention of holding that table in sync with the struct.

Testing

The new waveshare_epaper_154 env builds and links cleanly, and both ESP32-S3
AMOLED envs still build unchanged as a regression check. The ESP32-C6 env's
RISC-V toolchain isn't installed locally, so its has_touch wiring was verified
statically rather than with a full build. On-device navigation (PWR moving off the
splash into usage/Bluetooth), black-on-white rendering, and the inverted pill have
been checked on the physical panel.

🤖 Generated with Claude Code

IMG_1310 - Copy

Pritom Saha and others added 4 commits May 31, 2026 05:47
200x200 SSD1681 e-paper; no touch, PMU, or IMU. Follows
docs/porting/adding-a-board.md: a new boards/waveshare_epaper_154/
folder + a PlatformIO env, with a deliberately minimal shared footprint.

Board-local (boards/waveshare_epaper_154/):
- Vendored SSD1681 driver (epd.{h,cpp}) + display.cpp bridging the
  RGB565 HAL framebuffer to 1bpp. Boot full-refresh -> partial
  steady-state -> 10-min ghost-clear full timer.
- 1bpp legibility: luminance threshold 64 (not 128) so every dark-theme
  foreground colour renders as panel-ink (even RED, lum 94) while
  backgrounds/tracks stay white. Replaces the need for any ui.cpp colour
  overrides.
- MIN_PARTIAL_MS panel rate-limit: LVGL animates into RAM at full speed;
  the panel commits at most one partial/sec (early-return without
  clearing dirty, so no update is lost). Keeps the slow panel off the
  main loop's critical path without threading cadence through shared code.

Sanctioned shared edits (the only ones required):
- BoardCaps gains has_touch. On touchless boards main.cpp lets PWR
  dismiss the splash, since AMOLED leaves the splash via a screen tap
  that e-paper has no equivalent for.
- ui.cpp compute_layout() gains a 200x200 tier (adding-a-board.md
  step 7). The large/compact AMOLED tiers are behaviourally unchanged.

No slow_refresh capability; splash.cpp untouched; no #ifdef BOARD_<name>
in shared code. Builds clean: waveshare_epaper_154 (gating) plus the
amoled_216 / amoled_18 regression envs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The board-local 1bpp HAL maps dark LVGL pixels to white paper, so the
pill's dark COL_BAR_BG fill vanished into the background and only the
(now-black) label text remained. Invert the pill on the tiny tier
(light fill / dark text in LVGL) so it renders as a solid black pill
with white "Current"/"Weekly" text on the e-paper panel.

AMOLED tiers keep the original dim COL_BAR_BG chip. Keyed on the
existing responsive signal (L.scr_h < 250), not a capability field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Waveshare ESP32-S3-ePaper-1.54 uses a momentary-button soft-power
latch: the PWR side-button only MOMENTARILY applies the battery rail.
The MCU must immediately drive VBAT_PWR (GPIO 17) HIGH to hold the rail
on, exactly as the reference 07_BATT_PWR_Test does in user_app_init()
before display init. Our port never touched GPIO 17 (board_init was an
empty stub, BOARD_HAS_BATTERY=0 -- written assuming USB-only).

Symptom: on battery, press-and-hold PWR flashed the panel and left a
still splash with no animation. The MCU booted, wrote one full-refresh
frame to the bistable e-paper, then power collapsed on button release
because the rail was never latched; the e-paper retained that frozen
frame. On USB the board is powered by VBUS regardless, so it worked.

Fix is board-local: define VBAT_PWR_GPIO=17 / VBAT_PWR_ON_LEVEL=HIGH
(active-HIGH, the opposite of the active-LOW EPD_PWR -- confirmed from
the reference board_power_bsp.cpp) and drive it HIGH first thing in
board_init(), before the SSD1681 receives any byte. Also logs
esp_reset_reason() at boot so a brownout loop is distinguishable from a
clean boot when validating on battery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ePaper-1.54 has no PMU/fuel gauge, so power.cpp reported
battery_pct=-1 and the shared 5-state battery icon was stuck on
"empty" even with a charged pack connected.

The board does expose a battery-voltage sense: Waveshare's own
01_ADC_Test reads ADC1_CH3 = GPIO4 at 12 dB attenuation and
reconstructs the pack voltage as (pin_mV * 2), i.e. a 1:2 divider.
GPIO4 is in the ADC1 bank (radio-safe with NimBLE active) and is a
separate net from the VBAT_PWR=GPIO17 power-enable latch.

power.cpp now samples GPIO4 (median-of-5 calibrated analogReadMilliVolts,
*2 for the divider), smooths it with an EMA, and maps mV -> percent via
a single-cell Li-ion OCV curve. Per-icon-bucket hysteresis keeps a
reading hovering on a boundary from flapping and queuing an e-paper
partial refresh every 2 s. caps.has_battery / BOARD_HAS_BATTERY flip to
true. No UI change needed: ui_update_battery already maps percent to the
icon and main.cpp's change-detector feeds it; the icon now tracks the
real level.

is_charging stays false (no charge-status pin is broken out on this
board -- not fabricated). vbus_in stays true so the idle state machine
never auto-sleeps and freezes the bistable panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@HermannBjorgvin

Copy link
Copy Markdown
Owner

Looks very nice, I assume the * honking animations aren't able to refresh at a high enough rate to look nice on ePaper displays, how did you solve that?

@pritom-kun

Copy link
Copy Markdown
Author

Thanks for taking a look. The panel supports both partial and full refresh capabilities. The spinner updates its RAM-side frame every 130–260 ms, but the panel commits only one frame per second using partial refresh. It always displays the latest RAM state when each 1-second window opens, so intermediate spinner frames are silently skipped. While the display's partial refresh rate is around 300 ms, I've set the spinner refresh rate to 1 second to minimize battery consumption and screen wear. You can easily adjust this using the MIN_PARTIAL_MS constant in the board's display.cpp file.

Pritom Saha and others added 3 commits June 3, 2026 04:31
- main's 2b4a080 removed SCREEN_BLUETOOTH and ui_cycle_screen; eink follows.
- PWR on usage now calls brightness_cycle() (no more screen cycling).
- Spinner adopts main's connection-state status line (Connected / Disconnected
  / Pairing) instead of the always-whimsical messages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
power_hal.h gained these two edge signals when the hold-to-pair gesture
was added in main. The epaper board has no hold-to-pair use yet; add
no-op stubs to satisfy the linker without adding unused state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Touch boards keep splash_next(); touchless boards (has_touch=false, e.g.
ePaper-1.54) call ui_show_screen(SCREEN_USAGE) instead — the only way off
the boot splash with no tap available.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants