Migrate to Waveshare ESP32-S3-Touch-AMOLED-2.16 with auto-rotation, splash, and battery

Full hardware swap from Panlee SC01 Plus (480×320 IPS) to Waveshare 2.16"
square AMOLED (480×480, CO5300 + CST9220 + AXP2101 + QMI8658). Library
stack moves to Arduino_GFX, SensorLib, XPowersLib on the pioarduino
platform 55.03.38-1 (Arduino Core 3.x).

UI:
- 4 screens (splash, usage, controller, bluetooth) with 3-button physical
  navigation: GPIO 0 = prev, AXP PKEY = cycle, GPIO 18 = next.
- IMU-driven 90° auto-rotation. CO5300 can't rotate via MADCTL, so flush
  callback does CPU strip-rotation in PARTIAL render mode. Rotation
  transitions use AMOLED brightness flash (instant black → redraw → ramp).
- Battery indicator (Lucide icons) in upper-right, RGB565A8 alpha so it
  blends over the splash animations.
- USB plugged/unplugged auto-switches between Usage and Controller
  screens (suppressed while on splash).
- Fonts and icons re-scaled ~1.9× for the higher-DPI panel; 20px margins
  to clear rounded corners.

Splash:
- 13 × 20×20 pixel-art creature animations sourced from
  claudepix.vercel.app via tools/scrape_claudepix.js (scraper handles
  both PRESET creature-engine and standalone FRAMES+PAL formats).
  tools/convert_to_c.js emits firmware/src/splash_animations.h.
  Attribution preserved in README and the generated header.

Tooling:
- tools/png_to_lvgl.js converts alpha PNGs to LVGL RGB565A8 (planar
  layout, with --tint to colorize Lucide black-on-transparent sources).
- tools/scrape_claudepix.js, tools/convert_to_c.js for the splash
  pipeline.

Docs:
- New worktree CLAUDE.md with hardware pin map, file inventory, build
  commands, and the 8 critical gotchas (CO5300 rotation, OPI PSRAM,
  pioarduino requirement, LVGL 9 font patching, centralized touch read,
  even-aligned flush regions, touch swap/mirror values, RGB565A8 layout).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Hermann Björgvin Haraldsson
2026-05-09 22:04:04 +00:00
co-authored by Claude Opus 4.7
parent 10b8052bcc
commit 97a5443601
65 changed files with 121542 additions and 801 deletions
+270 -31
View File
@@ -7,37 +7,159 @@
#include "hid.h"
#include "touch.h"
#include "ble.h"
#include "power.h"
#include "imu.h"
#include "splash.h"
// Physical buttons:
// BTN_BACK (GPIO 0) — left, previous animation on splash
// BTN_FWD (GPIO 18) — right, next animation on splash
// AXP PWR (PMU) — middle, cycle screens
#define BTN_BACK 0
#define BTN_FWD 18
// ---- Hardware objects ----
Arduino_DataBus *bus = new Arduino_ESP32QSPI(
LCD_CS, LCD_SCLK, LCD_SDIO0, LCD_SDIO1, LCD_SDIO2, LCD_SDIO3);
Arduino_CO5300 *gfx = new Arduino_CO5300(
bus, LCD_RESET, 0 /* rotation */,
LCD_WIDTH, LCD_HEIGHT, 0, 0, 0, 0);
TouchDrvCST92xx touch;
XPowersPMU pmu;
SensorQMI8658 imu;
LGFX lcd; // global - used by touch.cpp
static UsageData usage = {};
// LVGL draw buffers
// ---- Touch interrupt + shared state ----
volatile bool touch_pressed = false;
volatile uint16_t touch_x = 0;
volatile uint16_t touch_y = 0;
static volatile bool touch_data_ready = false;
static void IRAM_ATTR touch_isr(void) {
touch_data_ready = true;
}
static void touch_read() {
if (!touch_data_ready) {
int16_t tx[5], ty[5];
uint8_t n = touch.getPoint(tx, ty, touch.getSupportTouchPoint());
if (n > 0) {
touch_pressed = true;
touch_x = (uint16_t)tx[0];
touch_y = (uint16_t)ty[0];
} else {
touch_pressed = false;
}
return;
}
touch_data_ready = false;
int16_t tx[5], ty[5];
uint8_t n = touch.getPoint(tx, ty, touch.getSupportTouchPoint());
if (n > 0) {
touch_pressed = true;
touch_x = (uint16_t)tx[0];
touch_y = (uint16_t)ty[0];
} else {
touch_pressed = false;
}
}
// ---- LVGL draw buffers (PSRAM-backed, partial render) ----
#define BUF_LINES 40
static uint16_t buf1[480 * BUF_LINES];
static uint16_t buf2[480 * BUF_LINES];
static uint16_t *buf1 = nullptr;
static uint16_t *buf2 = nullptr;
// rot_buf for strip rotation — max size is 480×480 (full invalidation case)
// but typical partial strips are much smaller
static uint16_t *rot_buf = nullptr;
// LVGL tick callback
static uint32_t my_tick(void) {
return millis();
}
// LVGL flush callback
// Rotate a w×h strip and compute destination coordinates on the 480×480 display.
// src pixels are in row-major order for the rectangle (sx, sy, w, h).
// Output goes to rot_buf in row-major order for the destination rectangle.
static void rotate_strip(const uint16_t *src, int32_t w, int32_t h,
int32_t sx, int32_t sy, uint8_t r,
int32_t *dx, int32_t *dy, int32_t *dw, int32_t *dh) {
const int S = LCD_WIDTH; // 480
switch (r) {
case 1: { // 90° CW: (x,y) -> (S-1-y, x)
*dw = h; *dh = w;
*dx = S - sy - h;
*dy = sx;
for (int32_t y = 0; y < h; y++) {
for (int32_t x = 0; x < w; x++) {
// src(x,y) -> dst(h-1-y, x)
rot_buf[x * h + (h - 1 - y)] = src[y * w + x];
}
}
break;
}
case 2: { // 180°: (x,y) -> (S-1-x, S-1-y)
*dw = w; *dh = h;
*dx = S - sx - w;
*dy = S - sy - h;
for (int32_t y = 0; y < h; y++) {
for (int32_t x = 0; x < w; x++) {
rot_buf[(h - 1 - y) * w + (w - 1 - x)] = src[y * w + x];
}
}
break;
}
case 3: { // 270° CW: (x,y) -> (y, S-1-x)
*dw = h; *dh = w;
*dx = sy;
*dy = S - sx - w;
for (int32_t y = 0; y < h; y++) {
for (int32_t x = 0; x < w; x++) {
// src(x,y) -> dst(y, w-1-x)
rot_buf[(w - 1 - x) * h + y] = src[y * w + x];
}
}
break;
}
default:
*dx = sx; *dy = sy; *dw = w; *dh = h;
break;
}
}
// LVGL flush callback — rotates partial strips and writes to display
static void my_flush_cb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) {
uint32_t w = (area->x2 - area->x1 + 1);
uint32_t h = (area->y2 - area->y1 + 1);
lcd.startWrite();
lcd.setAddrWindow(area->x1, area->y1, w, h);
lcd.writePixels((uint16_t*)px_map, w * h, true);
lcd.endWrite();
int32_t w = area->x2 - area->x1 + 1;
int32_t h = area->y2 - area->y1 + 1;
uint16_t *src = (uint16_t*)px_map;
uint8_t r = imu_get_rotation();
if (r == 0) {
gfx->draw16bitRGBBitmap(area->x1, area->y1, src, w, h);
} else {
int32_t dx, dy, dw, dh;
rotate_strip(src, w, h, area->x1, area->y1, r, &dx, &dy, &dw, &dh);
gfx->draw16bitRGBBitmap(dx, dy, rot_buf, dw, dh);
}
lv_display_flush_ready(disp);
}
// CO5300 requires even-aligned flush regions
static void rounder_cb(lv_event_t* e) {
lv_area_t *area = (lv_area_t*)lv_event_get_param(e);
area->x1 = area->x1 & ~1;
area->y1 = area->y1 & ~1;
area->x2 = area->x2 | 1;
area->y2 = area->y2 | 1;
}
// LVGL touch callback
static void my_touch_cb(lv_indev_t* indev, lv_indev_data_t* data) {
uint16_t x, y;
if (lcd.getTouch(&x, &y)) {
data->point.x = x;
data->point.y = y;
if (touch_pressed) {
data->point.x = touch_x;
data->point.y = touch_y;
data->state = LV_INDEV_STATE_PRESSED;
} else {
data->state = LV_INDEV_STATE_RELEASED;
@@ -69,35 +191,33 @@ static char cmd_buf[CMD_BUF_SIZE];
static int cmd_pos = 0;
static void send_screenshot() {
// Allocate buffer in PSRAM (internal RAM is too small for 307KB)
const uint32_t w = 480, h = 320;
const uint32_t w = LCD_WIDTH, h = LCD_HEIGHT;
const uint32_t row_bytes = w * 2;
const uint32_t buf_size = row_bytes * h;
uint8_t* buf = (uint8_t*)heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM);
if (!buf) {
uint8_t* sbuf = (uint8_t*)heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM);
if (!sbuf) {
Serial.println("SCREENSHOT_ERR");
return;
}
// Use LVGL snapshot with caller-provided buffer
lv_draw_buf_t draw_buf;
lv_draw_buf_init(&draw_buf, w, h, LV_COLOR_FORMAT_RGB565, row_bytes, buf, buf_size);
lv_draw_buf_init(&draw_buf, w, h, LV_COLOR_FORMAT_RGB565, row_bytes, sbuf, buf_size);
lv_result_t res = lv_snapshot_take_to_draw_buf(lv_screen_active(), LV_COLOR_FORMAT_RGB565, &draw_buf);
if (res != LV_RESULT_OK) {
heap_caps_free(buf);
heap_caps_free(sbuf);
Serial.println("SCREENSHOT_ERR");
return;
}
Serial.printf("SCREENSHOT_START %lu %lu %lu\n", (unsigned long)w, (unsigned long)h, (unsigned long)buf_size);
Serial.flush();
Serial.write(buf, buf_size);
Serial.write(sbuf, buf_size);
Serial.flush();
Serial.println();
Serial.println("SCREENSHOT_END");
heap_caps_free(buf);
heap_caps_free(sbuf);
}
static void check_serial_cmd() {
@@ -120,26 +240,57 @@ void setup() {
delay(300);
Serial.println("{\"ready\":true}");
// Init I2C (shared by touch + PMU)
Wire.begin(IIC_SDA, IIC_SCL);
// Init display
lcd.init();
lcd.setRotation(1);
lcd.setBrightness(200);
lcd.fillScreen(TFT_BLACK);
gfx->begin();
gfx->fillScreen(0x0000);
gfx->setBrightness(200);
// Init PMU
power_init();
// Init IMU (accelerometer for auto-rotation)
imu_init();
// Init touch
touch.setPins(TP_RST, TP_INT);
if (!touch.begin(Wire, CST9220_ADDR, IIC_SDA, IIC_SCL)) {
Serial.println("Touch init failed");
} else {
touch.setMaxCoordinates(LCD_WIDTH, LCD_HEIGHT);
touch.setSwapXY(true);
touch.setMirrorXY(true, false);
attachInterrupt(TP_INT, touch_isr, FALLING);
Serial.println("Touch init OK");
}
// Init LVGL
lv_init();
lv_tick_set_cb(my_tick);
lv_display_t* disp = lv_display_create(480, 320);
// Allocate PSRAM-backed partial render buffers
buf1 = (uint16_t*)heap_caps_malloc(LCD_WIDTH * BUF_LINES * 2, MALLOC_CAP_SPIRAM);
buf2 = (uint16_t*)heap_caps_malloc(LCD_WIDTH * BUF_LINES * 2, MALLOC_CAP_SPIRAM);
// rot_buf needs to hold the largest possible strip after rotation
// A 480×40 strip rotated 90° becomes 40×480, same pixel count
rot_buf = (uint16_t*)heap_caps_malloc(LCD_WIDTH * BUF_LINES * 2, MALLOC_CAP_SPIRAM);
lv_display_t* disp = lv_display_create(LCD_WIDTH, LCD_HEIGHT);
lv_display_set_color_format(disp, LV_COLOR_FORMAT_RGB565);
lv_display_set_flush_cb(disp, my_flush_cb);
lv_display_set_buffers(disp, buf1, buf2, sizeof(buf1), LV_DISPLAY_RENDER_MODE_PARTIAL);
lv_display_set_buffers(disp, buf1, buf2, LCD_WIDTH * BUF_LINES * 2,
LV_DISPLAY_RENDER_MODE_PARTIAL);
// CO5300 even-alignment rounder
lv_display_add_event_cb(disp, rounder_cb, LV_EVENT_INVALIDATE_AREA, NULL);
lv_indev_t* indev = lv_indev_create();
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
lv_indev_set_read_cb(indev, my_touch_cb);
// Init USB HID keyboard
// Init USB HID keyboard (BLE-based)
hid_init();
// Init BLE data channel
@@ -148,22 +299,90 @@ void setup() {
// Init touch gesture detection
touch_init();
// Physical buttons: back (GPIO 0) and forward (GPIO 18)
pinMode(BTN_BACK, INPUT_PULLUP);
pinMode(BTN_FWD, INPUT_PULLUP);
// Build dashboard
ui_init();
// Show initial BLE status on Bluetooth screen
ui_update_ble_status(ble_get_state(), ble_get_device_name(), ble_get_mac_address());
// Show initial battery status
ui_update_battery(power_battery_pct(), power_is_charging());
// Default to splash screen (permanent while we work on it)
ui_show_screen(SCREEN_SPLASH);
Serial.println("Dashboard ready, waiting for data on BLE...");
}
static ble_state_t last_ble_state = BLE_STATE_INIT;
// Brightness ramp state for rotation transition
static uint8_t brightness_ramp = 0; // 0=idle, 1-4=ramping up
void loop() {
touch_read();
lv_timer_handler();
ui_tick_anim();
touch_tick();
ble_tick();
power_tick();
imu_tick();
splash_tick();
// Three-button input (edge-detected on press):
// BACK (GPIO 0) → previous animation on splash screen
// FWD (GPIO 18) → next animation on splash screen
// PWR (AXP) → cycle screens
{
static bool back_was = false, fwd_was = false;
bool back_now = (digitalRead(BTN_BACK) == LOW);
bool fwd_now = (digitalRead(BTN_FWD) == LOW);
if (back_now && !back_was && ui_get_current_screen() == SCREEN_SPLASH) {
splash_prev();
}
if (fwd_now && !fwd_was && ui_get_current_screen() == SCREEN_SPLASH) {
splash_next();
}
back_was = back_now;
fwd_was = fwd_now;
if (power_pwr_pressed()) {
ui_cycle_screen();
}
}
// Detect rotation change — brightness flash + force redraw
{
static uint8_t last_rotation = 0;
uint8_t rot = imu_get_rotation();
if (rot != last_rotation) {
gfx->setBrightness(0); // instant black
last_rotation = rot;
lv_obj_invalidate(lv_screen_active()); // force full redraw at new rotation
brightness_ramp = 1;
}
}
// Ramp brightness back up after rotation
if (brightness_ramp > 0) {
static uint32_t ramp_last = 0;
uint32_t now = millis();
if (now - ramp_last > 25) {
ramp_last = now;
uint8_t levels[] = {60, 120, 170, 200};
gfx->setBrightness(levels[brightness_ramp - 1]);
if (brightness_ramp >= 4) {
brightness_ramp = 0;
} else {
brightness_ramp++;
}
}
}
// Update BLE status on screen when state changes
ble_state_t bs = ble_get_state();
@@ -172,6 +391,26 @@ void loop() {
ui_update_ble_status(bs, ble_get_device_name(), ble_get_mac_address());
}
// Auto-switch screen on USB cable connect/disconnect — but never away from splash
if (power_usb_changed() && ui_get_current_screen() != SCREEN_SPLASH) {
if (power_is_usb_connected()) {
ui_show_screen(SCREEN_USAGE);
} else {
ui_show_screen(SCREEN_CONTROLLER);
}
}
// Update battery indicator
static int last_pct = -2;
static bool last_charging = false;
int pct = power_battery_pct();
bool charging = power_is_charging();
if (pct != last_pct || charging != last_charging) {
last_pct = pct;
last_charging = charging;
ui_update_battery(pct, charging);
}
// Check for serial commands (screenshot, etc.)
check_serial_cmd();