Port the firmware to a second Waveshare AMOLED board variant alongside
the original 2.16" / 480×480. Selection is at build time via a board
macro; both envs continue to build and flash independently.
Hardware delta vs AMOLED-2.16:
* Display: SH8601 QSPI (vs CO5300), 368×448 portrait (vs 480² square),
SCLK on GPIO 11 (vs 38), RST routed through an XCA9554 I/O expander
(vs direct GPIO).
* Touch: FT3168 @ 0x38 (vs CST9220 @ 0x5A). Driven by a ~30-line
inline FocalTech-protocol reader in main.cpp — Waveshare's
Arduino_DriveBus library is GPLv3 and would force the project's
license, which the README explicitly avoids.
* I/O expander: new XCA9554/PCA9554 @ I2C 0x20 gates LCD_RST, TP_RST,
audio amp enable, and the PWR button. Wrapped in io_expander.{h,cpp}.
Must initialize BEFORE display/touch or both stay in reset.
* PMU + IMU: same AXP2101 + QMI8658 as 2.16, so power.cpp/imu.cpp
largely reuse. IMU is initialized for I2C bus health but rotation
is fixed at 0° (the portrait panel doesn't benefit from auto-rotate
and the strip-rotation CPU path is excluded entirely).
* Buttons: only BOOT (GPIO 0) is a direct GPIO; PWR is read via
XCA9554 EXIO4 polling. No third button — the AMOLED-2.16's
Shift+Tab key (GPIO 18) is dropped for this board.
* Flash: 16MB (vs 8MB), partition table set to default_16MB.csv.
Code structure:
* display_cfg.h hosts a #ifdef BOARD_AMOLED_18 / #else / #endif split
with a PlatformDisplay typedef so call sites elsewhere stay clean.
* main.cpp, power.cpp, ui.cpp, splash.cpp gate board-specific bits on
the same macro. Layout constants (panel heights, content Y, font
choices on the Bluetooth screen) compress for the 368×448 portrait
footprint so two usage panels + bottom animation label fit without
horizontal overflow.
* Splash scales the 20×20 pixel-art grid to 360×360 (CELL=18 vs 24)
and centers in the portrait canvas.
* Old AMOLED-2.16 env is unchanged at the source level (gated under
#ifndef BOARD_AMOLED_18) and continues to compile + flash on the
original hardware.
CLAUDE.md updated with the two-board pin maps, build/flash commands
for both envs (macOS + Linux device paths), and the per-board gotchas.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 KiB
Project context
ESP32-S3 firmware for a desk-side Claude Code usage monitor. Two board variants are supported via a build-flag macro:
-DBOARD_AMOLED_216→ original Waveshare ESP32-S3-Touch-AMOLED-2.16 (CO5300, 480×480 square, CST9220 touch). Build env:waveshare_amoled_216.-DBOARD_AMOLED_18→ Waveshare ESP32-S3-Touch-AMOLED-1.8 (SH8601, 368×448 portrait, FT3168 touch). Build env:waveshare_amoled_18.
display_cfg.h selects pins / typedefs / extern decls based on the macro. Per-board layout deltas (panel heights, fonts) are scoped with #ifdef in ui.cpp and splash.cpp.
Connects to a host daemon over BLE; daemon polls Anthropic API for usage data. This file is for future Claude Code sessions to bootstrap quickly. Read this first.
Hardware (critical pins)
AMOLED-2.16 (original)
- Display: CO5300 AMOLED via QSPI (CS=12, SCLK=38, SDIO0..3=4..7, RST=2)
- Touch: CST9220 via I2C (SDA=15, SCL=14, INT=11, addr=0x5A)
- PMU: AXP2101 on same I2C bus (addr=0x34) — battery, USB VBUS, PWR button IRQ
- IMU: QMI8658 on same I2C bus (addr=0x6B) — accelerometer for auto-rotation
- Buttons: GPIO 0 (left → Space/voice-mode), GPIO 18 (right → Shift+Tab/mode-toggle), AXP PKEY (middle → cycle screens; on splash → cycle animations)
AMOLED-1.8 (newer port)
- Display: SH8601 AMOLED via QSPI (CS=12, SCLK=11 ← different!, SDIO0..3=4..7, RST routed via XCA9554 EXIO1)
- Touch: FT3168 via I2C (SDA=15, SCL=14, INT=21, addr=0x38). Driven by minimal inline reader in
main.cpp(FocalTech standard register layout — avoids vendoring the GPLv3Arduino_DriveBuslibrary). - PMU: AXP2101 @ 0x34 (same chip as 2.16 —
XPowersLibreused; battery is an optional kit add-on but PMU + charging circuitry are populated) - IMU: QMI8658 @ 0x6B (same chip — initialized for I2C bus health, rotation logic disabled)
- IO expander: XCA9554 / PCA9554 @ I2C 0x20. Gates LCD_RST, TP_RST, audio amp enable, and reads the PWR button.
io_expander_init()MUST run beforegfx->begin()orft3168_init()— otherwise display/touch stay in reset and silently fail. PWR button is on EXIO4, active HIGH (verified empirically with the deletedioxserial debug command). - Orientation: fixed at 0°. IMU auto-rotation is disabled;
rotate_strip()/handle_rotation_change()are excluded via#ifndef BOARD_AMOLED_18. - Buttons: GPIO 0 (BOOT → Space/voice-mode), XCA9554 EXIO4 (PWR → cycle screens; on splash → cycle animations). No third button (GPIO 18 button doesn't exist on this board).
Architecture
main.cpp — setup(), loop(), button polling, FT3168 minimal reader (AMOLED-1.8), rotation flash (2.16 only)
display_cfg.h — board-conditional pin defines, typedefs (PlatformDisplay = CO5300|SH8601), extern object decls
io_expander.{h,cpp} — XCA9554/PCA9554 wrapper (AMOLED-1.8 only): LCD/TP reset release + PWR button read
ui.{h,cpp} — 3-screen UI (splash, usage, bluetooth); splash is touch-toggled, usage↔bluetooth via PWR button
splash.{h,cpp} — 20×20 pixel-art animation engine. CELL = 24 (480²) for 2.16, 18 (360² centered) for 1.8
imu.{h,cpp} — accelerometer-driven rotation tracker (returns 0..3). Result is ignored on AMOLED-1.8.
power.{h,cpp} — AXP2101 wrapper (battery %, charging, VBUS, PWR button). PWR source is conditional: AXP PKEY IRQ on 2.16, XCA9554 EXIO4 polling on 1.8.
ble.{h,cpp} — NimBLE peripheral: custom data service + HID keyboard
data.h — UsageData struct
icons.h — icon arrays. Battery (5×) are RGB565A8 with alpha; rest are raw RGB565.
logo.h — 80×80 RGB565 logo
font_*.c — pre-compiled LVGL 9 bitmap fonts (Tiempos 56/34, Styrene 48/28/24/20/16/14/12, Mono 32/18)
splash_animations.h — generated, do not hand-edit
Build / flash
pio run -d firmware -e waveshare_amoled_216 # build 2.16 (default original)
pio run -d firmware -e waveshare_amoled_18 # build 1.8 (new port)
pio run -d firmware -e waveshare_amoled_18 -t upload --upload-port /dev/cu.usbmodem101 # flash 1.8 on macOS
pio run -d firmware -e waveshare_amoled_216 -t upload --upload-port /dev/ttyACM0 # flash 2.16 on Linux
If pio isn't on PATH: try ~/.platformio/penv/bin/pio (Linux/macOS pio install) or brew install platformio on macOS.
Device path differs by OS: /dev/cu.usbmodem* on macOS, /dev/ttyACM0 on Linux. Both expose the ESP32-S3 native USB-JTAG (no boot-mode dance needed).
QA your own UI changes — don't ask the user
The firmware ships a screenshot serial command that dumps the LVGL framebuffer. ./screenshot.sh out.png [port] captures a PNG sized to the active display (480×480 or 368×448). Use this on every UI iteration — Read the PNG with the Read tool, verify the change visually, iterate. Script auto-picks the macOS/Linux default port and falls back to pio's bundled Python if pyserial isn't on the system Python.
The boot screen is SCREEN_SPLASH and only advances on a physical button press, so a fresh flash will sit on the splash. To screenshot the screen you're actually editing without asking the user to press a button, temporarily change the default boot screen in main.cpp (search for ui_show_screen(SCREEN_SPLASH);) to SCREEN_USAGE / SCREEN_CONTROLLER / SCREEN_BLUETOOTH, do your iteration, then revert before committing.
Critical gotchas
- CO5300 cannot rotate. Its MADCTL only supports axis flips, not column/row exchange. Rotation is done by CPU pixel remapping in
my_flush_cbin main.cpp. We use PARTIAL render mode with strip rotation (small 480×40 strips, fast). On rotation change → AMOLED brightness flash → force redraw. - OPI PSRAM required:
board_build.arduino.memory_type = qio_opiin platformio.ini. Without this,MALLOC_CAP_SPIRAMreturns NULL and the screen is black. - pioarduino platform required. GFX Library for Arduino needs Arduino Core 3.x (
esp32-hal-periman.h), not the 2.x that standardespressif32ships. We pinpioarduino/platform-espressif3255.03.38-1. - LVGL 9 font patching.
lv_font_convoutputs LVGL 8 format. Must remove#if LVGL_VERSION_MAJOR >= 8guards, drop.cachefield, add.release_glyph,.kerning,.static_bitmap,.fallback,.user_data. Without patching, fonts render invisible. - Touch reading must be centralized. CST9220's
getPoint()does a full I2C transaction. Calling it from multiple places consumed each other's data and broke input.touch_read()is called once per loop in main.cpp; both LVGLmy_touch_cbandtouch.cppread from sharedtouch_pressed/touch_x/touch_ystate. - CO5300 needs even-aligned flush regions.
rounder_cbenforces this. - Touch
setSwapXY(true)andsetMirrorXY(true, false)are the empirically-correct values for default rotation 0. IMU rotation logic doesn't change touch mapping (it does CPU-side rotation of the rendered pixels, so LVGL still thinks the display is portrait at 0°). - LVGL RGB565A8 is planar.
w*hRGB565 pixels followed byw*halpha bytes;data_size = w*h*3,stride = w*2. Useinit_icon_dsc_rgb565a8()for icons that overlap non-uniform backgrounds (e.g. battery over splash). Lucide source PNGs are black-on-transparent — converter must tint to white or icons render invisible. Seetools/png_to_lvgl.js.
Icons
tools/png_to_lvgl.js <input.png> <symbol> [W_MACRO] [H_MACRO] [--tint=RRGGBB | --no-tint] converts an alpha PNG to RGB565A8. Default tint is white (0xFFFFFF) — necessary for Lucide PNGs. Splice output into firmware/src/icons.h and use init_icon_dsc_rgb565a8() in ui.cpp. Currently only the 5 battery icons use this format; the rest are still raw RGB565 baked over the panel background, fine because they live inside opaque zones.
Splash animations
13 × 20×20 pixel-art creature animations sourced from claudepix.vercel.app. Pipeline:
node tools/scrape_claudepix.js # → tools/claudepix_data/*.json
node tools/convert_to_c.js # → firmware/src/splash_animations.h
Each animation has a per-animation 10-color RGB565 palette. Cell values 0..9 index it. Default boot screen.
User profile / preferences
See ~/.claude/projects/.../memory/ files for persistent context (user is an embedded-beginner senior dev, brand-conscious, prefers iterative UI refinement, dislikes me authoring my own art when third-party assets are intended). Always read those memory files at session start.
Recent session highlights
- Migrated from Panlee SC01 Plus (480×320 IPS) to Waveshare 2.16" AMOLED (480×480 square). Full hardware/library swap.
- Added IMU auto-rotation, battery indicator, USB-state-aware screen switching.
- Added splash screen with scraped pixel-art animations and 3-button physical input layout.
- Fonts and icons re-scaled ~1.9× for the higher-DPI panel.
- All UI margins widened to 20px to clear the rounded display corners.
- Battery icons converted to RGB565A8 alpha so they blend cleanly over the splash animations.
Daemon / host side
Bash daemon (daemon/claude-usage-daemon.sh) reads OAuth token, polls Anthropic API, sends JSON over BLE GATT. Run with systemctl --user start claude-usage-daemon. The unit file's ExecStart is the absolute path to the script — repoint it when switching between the worktree and the main checkout.
Discovery & resilience:
- Connects by name (
"Claude Controller") on first run, caches resolved MAC at~/.config/claude-usage-monitor/ble-address. ESP32 BLE addresses are factory-burned per-chip, so swapping any board invalidates the cache. - On connect failure: cache is dropped AND device is removed from bluez (
bluetoothctl remove) so the next scan won't re-pick a dead MAC. Multi-candidate scans pickhead -1and let the failure cycle converge. POLL_INTERVAL=60,TICK=5. Inner loop wakes every 5s to detect disconnects fast; polls Anthropic when 60s elapsed OR when ESP fires a refresh request.
GATT characteristics on service 4c41555a-...0001:
...0002RX — daemon writes JSON usage payload here....0003TX — firmware notifies ack/nack (daemon doesn't subscribe)....0004REQ — firmware fires0x01notify inonSubscribeifhas_received_datais false. Daemon subscribes viasetsid bash -c "stdbuf -oL dbus-monitor … | awk …"; awk drops a flag file the inner loop picks up. See thefeedback_dbus_monitor_pipememory for the three subtle gotchas (pipe buffering, busctl-exits race,waitblocking on pipeline jobs).