diff --git a/CLAUDE.md b/CLAUDE.md index 6f8347f..672fc51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,49 +1,66 @@ # Project context -ESP32-S3 firmware for a desk-side Claude Code usage monitor on a **Waveshare ESP32-S3-Touch-AMOLED-2.16** board (480×480 square AMOLED). Connects to a host daemon over BLE; daemon polls Anthropic API for usage data. +ESP32-S3 firmware for a desk-side Claude Code usage monitor. Two board variants are supported via a build-flag macro: -This file is for future Claude Code sessions to bootstrap quickly. Read this first. +- **`-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 GPLv3 `Arduino_DriveBus` library). +- PMU: AXP2101 @ 0x34 (same chip as 2.16 — `XPowersLib` reused; 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 before `gfx->begin()` or `ft3168_init()`** — otherwise display/touch stay in reset and silently fail. PWR button is on EXIO4, active HIGH (verified empirically with the deleted `iox` serial 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 ```text -main.cpp — setup(), loop(), button polling (left→Space, right→Shift+Tab, mid→cycle), rotation flash -display_cfg.h — pin defines, extern object decls -ui.{h,cpp} — 3-screen UI (splash, usage, bluetooth); splash is touch-toggled, usage↔bluetooth via mid button -splash.{h,cpp} — 20×20 pixel-art animation engine, 24× upscale to 480×480 -imu.{h,cpp} — accelerometer-driven rotation tracker (returns 0..3) -power.{h,cpp} — AXP2101 wrapper (battery %, charging, VBUS, PWR button) -touch.{h,cpp} — minimal tap detector → ui_toggle_splash() (Usage/Splash) or ble_clear_bonds() (BT reset zone) +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, Styrene 48/28/24/20, Mono 32) +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 ```bash -pio run -d firmware # build -pio run -d firmware -t upload --upload-port /dev/ttyACM0 # flash (binary path uses USB JTAG) +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 ``` -`/home/hermann/.platformio/penv/bin/pio` if `pio` isn't on PATH. +If `pio` isn't on PATH: try `~/.platformio/penv/bin/pio` (Linux/macOS pio install) or `brew install platformio` on macOS. -Device shows up as `/dev/ttyACM0` (Espressif USB JTAG/serial debug unit). No boot-mode gymnastics needed — direct flash works. +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 over `/dev/ttyACM0`. `./screenshot.sh out.png /dev/ttyACM0` captures a 480×480 PNG. **Use this on every UI iteration** — Read the PNG with the Read tool, verify the change visually, iterate. +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. diff --git a/firmware/platformio.ini b/firmware/platformio.ini index 8d44c1b..1a0eda1 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -7,6 +7,7 @@ upload_speed = 921600 monitor_speed = 115200 build_flags = + -DBOARD_AMOLED_216 -DARDUINO_USB_CDC_ON_BOOT=1 -DBOARD_HAS_PSRAM ; AXP2101 PMU @@ -42,3 +43,55 @@ lib_deps = lvgl/lvgl@^9.2.0 bblanchon/ArduinoJson@^7.0.0 h2zero/NimBLE-Arduino@^2.1.1 + + +[env:waveshare_amoled_18] +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip +board = esp32-s3-devkitc-1 +framework = arduino +board_build.arduino.memory_type = qio_opi +board_upload.flash_size = 16MB +board_upload.maximum_size = 16777216 +board_build.partitions = default_16MB.csv +upload_speed = 921600 +monitor_speed = 115200 + +build_flags = + -DBOARD_AMOLED_18 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DBOARD_HAS_PSRAM + ; AXP2101 PMU is present on the AMOLED-1.8 board too (battery is optional kit add-on) + -DXPOWERS_CHIP_AXP2101 + ; NimBLE config — peripheral, 2 simultaneous connections so the OS can + ; hold the HID link (Space key from BOOT button) while the daemon holds + ; its own connection for the custom data service. + -DCONFIG_BT_NIMBLE_ROLE_BROADCASTER=1 + -DCONFIG_BT_NIMBLE_ROLE_PERIPHERAL=1 + -DCONFIG_BT_NIMBLE_ROLE_CENTRAL=0 + -DCONFIG_BT_NIMBLE_ROLE_OBSERVER=0 + -DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=2 + ; LVGL config via build flags + -DLV_CONF_SKIP + -DLV_COLOR_DEPTH=16 + -DLV_USE_LOG=0 + -DLV_USE_BAR=1 + -DLV_USE_LABEL=1 + -DLV_USE_ARC=1 + -DLV_USE_BTN=1 + -DLV_USE_LINE=1 + -DLV_USE_OBJ=1 + -DLV_USE_IMG=1 + -DLV_USE_IMAGE=1 + -DLV_USE_ANIMIMG=0 + -DLV_TICK_CUSTOM=1 + -DLV_USE_SNAPSHOT=1 + +lib_deps = + ; 1.6.4+ ships Arduino_SH8601 in mainline + moononournation/GFX Library for Arduino@^1.6.4 + ; SensorLib still used for QMI8658 IMU on AMOLED-1.8 + lewisxhe/SensorLib@^0.2.6 + lewisxhe/XPowersLib@^0.2.7 + lvgl/lvgl@^9.2.0 + bblanchon/ArduinoJson@^7.0.0 + h2zero/NimBLE-Arduino@^2.1.1 diff --git a/firmware/src/display_cfg.h b/firmware/src/display_cfg.h index 4e6f41c..c62df73 100644 --- a/firmware/src/display_cfg.h +++ b/firmware/src/display_cfg.h @@ -1,11 +1,63 @@ #pragma once #include -#include #include #include #include +// ============================================================================ +// Board variant selection — driven by build flag in platformio.ini +// -DBOARD_AMOLED_216 → original Waveshare ESP32-S3-Touch-AMOLED-2.16 (CO5300, CST9220, 480x480) +// -DBOARD_AMOLED_18 → newer Waveshare ESP32-S3-Touch-AMOLED-1.8 (SH8601, FT3168, 368x448) +// ============================================================================ + +#if defined(BOARD_AMOLED_18) + +// ---- Display resolution (portrait) ---- +#define LCD_WIDTH 368 +#define LCD_HEIGHT 448 + +// ---- QSPI display pins (SH8601) ---- +#define LCD_CS 12 +#define LCD_SCLK 11 // NOTE: different from AMOLED-2.16 (was GPIO 38) +#define LCD_SDIO0 4 +#define LCD_SDIO1 5 +#define LCD_SDIO2 6 +#define LCD_SDIO3 7 +#define LCD_RESET GFX_NOT_DEFINED // routed via XCA9554 EXIO1 + +// ---- I2C bus (touch + PMU + IMU + IO expander all share one bus) ---- +#define IIC_SDA 15 +#define IIC_SCL 14 + +// ---- Touch (FT3168 via I2C) ---- +#define TP_INT 21 +#define FT3168_ADDR 0x38 + +// ---- PMU (AXP2101 via I2C) ---- +#define AXP2101_ADDR 0x34 + +// ---- IO expander (XCA9554/PCA9554-compatible via I2C) ---- +// Gates LCD_RST, TP_RST, audio amp reset, and PWR button readback. +#define XCA9554_ADDR 0x20 +#define IOX_PIN_TP_RST 0 // EXIO0 → touch reset (active LOW) +#define IOX_PIN_LCD_RST 1 // EXIO1 → display reset (active LOW) +#define IOX_PIN_PA_EN 2 // EXIO2 → audio amp enable (we keep HIGH to release) +#define IOX_PIN_PWR_BTN 4 // EXIO4 → PWR button input, active HIGH + +// ---- Display class typedef ---- +typedef Arduino_SH8601 PlatformDisplay; + +// ---- Global hardware objects (defined in main.cpp) ---- +extern Arduino_DataBus *bus; +extern PlatformDisplay *gfx; +extern XPowersPMU pmu; +extern SensorQMI8658 imu; + +#else // BOARD_AMOLED_216 (default / original board) + +#include + // ---- Display resolution ---- #define LCD_WIDTH 480 #define LCD_HEIGHT 480 @@ -29,9 +81,14 @@ // ---- PMU (AXP2101 via same I2C) ---- #define AXP2101_ADDR 0x34 +// ---- Display class typedef ---- +typedef Arduino_CO5300 PlatformDisplay; + // ---- Global hardware objects (defined in main.cpp) ---- extern Arduino_DataBus *bus; -extern Arduino_CO5300 *gfx; +extern PlatformDisplay *gfx; extern TouchDrvCST92xx touch; extern XPowersPMU pmu; extern SensorQMI8658 imu; + +#endif // BOARD_AMOLED_* diff --git a/firmware/src/io_expander.cpp b/firmware/src/io_expander.cpp new file mode 100644 index 0000000..9de72be --- /dev/null +++ b/firmware/src/io_expander.cpp @@ -0,0 +1,72 @@ +#include "io_expander.h" + +#ifdef BOARD_AMOLED_18 + +#include "display_cfg.h" +#include +#include + +// XCA9554/PCA9554 register map +#define IOX_REG_INPUT 0x00 +#define IOX_REG_OUTPUT 0x01 +#define IOX_REG_POLARITY 0x02 +#define IOX_REG_CONFIG 0x03 // 1 = input, 0 = output + +// EXIO0..2 are outputs (reset lines + audio amp). Everything else is input. +// Bit layout: 0bIIIIIOOO = 0xF8 +#define IOX_CONFIG_MASK 0xF8 + +// All three outputs HIGH = resets released, amp enabled. +#define IOX_OUTPUT_DEFAULT 0x07 + +static uint8_t output_state = 0x00; + +static bool write_reg(uint8_t reg, uint8_t val) { + Wire.beginTransmission(XCA9554_ADDR); + Wire.write(reg); + Wire.write(val); + return Wire.endTransmission() == 0; +} + +static bool read_reg(uint8_t reg, uint8_t &val) { + Wire.beginTransmission(XCA9554_ADDR); + Wire.write(reg); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom(XCA9554_ADDR, (uint8_t)1) != 1) return false; + val = Wire.read(); + return true; +} + +bool io_expander_init(void) { + // 1. Configure direction: EXIO0..2 outputs, rest inputs. + if (!write_reg(IOX_REG_CONFIG, IOX_CONFIG_MASK)) { + Serial.println("XCA9554 init failed (config)"); + return false; + } + // 2. Drive all outputs LOW → hold display + touch in reset. + output_state = 0x00; + write_reg(IOX_REG_OUTPUT, output_state); + delay(20); + // 3. Release resets and enable audio amp output line. + output_state = IOX_OUTPUT_DEFAULT; + write_reg(IOX_REG_OUTPUT, output_state); + delay(20); // give SH8601 / FT3168 time to come out of reset + Serial.println("XCA9554 init OK"); + return true; +} + +void io_expander_set(uint8_t pin, bool high) { + if (pin > 7) return; + if (high) output_state |= (1u << pin); + else output_state &= ~(1u << pin); + write_reg(IOX_REG_OUTPUT, output_state); +} + +bool io_expander_get(uint8_t pin) { + if (pin > 7) return false; + uint8_t v = 0; + if (!read_reg(IOX_REG_INPUT, v)) return false; + return (v & (1u << pin)) != 0; +} + +#endif // BOARD_AMOLED_18 diff --git a/firmware/src/io_expander.h b/firmware/src/io_expander.h new file mode 100644 index 0000000..51a19be --- /dev/null +++ b/firmware/src/io_expander.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +// XCA9554 / PCA9554-compatible 8-bit I2C IO expander @ 0x20. +// Only compiled for BOARD_AMOLED_18 (the AMOLED-1.8 board routes LCD_RST, +// TP_RST, audio amp enable, and the PWR button through this expander). +// +// Must be initialized BEFORE the display or touch — skipping the reset +// release leaves the SH8601 and FT3168 in reset and they will fail to probe. + +bool io_expander_init(void); + +// Drive an EXIO pin (one of IOX_PIN_* in display_cfg.h that is configured +// as output). Updates the cached output register. +void io_expander_set(uint8_t pin, bool high); + +// Read an EXIO pin configured as input (e.g. PWR button on EXIO4). +bool io_expander_get(uint8_t pin); diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 4ba85af..ecb48bd 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include "display_cfg.h" @@ -12,26 +13,44 @@ #include "idle.h" #include "idle_cfg.h" +#ifdef BOARD_AMOLED_18 +#include "io_expander.h" +#endif + // Physical buttons (global, screen-independent): -// BTN_BACK (GPIO 0) — left, send Space (Claude Code voice mode push-to-talk) -// BTN_FWD (GPIO 18) — right, send Shift+Tab (Claude Code mode toggle) -// AXP PWR (PMU) — middle, cycle screens; on splash, cycle animations +// BTN_BACK (GPIO 0, BOOT) — left, send Space (Claude Code voice-mode PTT) +// BTN_FWD (GPIO 18) — AMOLED-2.16 only: Shift+Tab (mode toggle) +// PWR — middle, cycle screens; on splash, cycle animations +// AMOLED-2.16: AXP2101 PKEY IRQ +// AMOLED-1.8 : XCA9554 EXIO4 (polled over I2C) #define BTN_BACK 0 +#ifndef BOARD_AMOLED_18 #define BTN_FWD 18 +#endif // ---- 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( +#ifdef BOARD_AMOLED_18 +// SH8601 constructor: (bus, rst, rotation, w, h) +PlatformDisplay *gfx = new PlatformDisplay( + bus, LCD_RESET /* GFX_NOT_DEFINED — reset via XCA9554 */, 0, + LCD_WIDTH, LCD_HEIGHT); +#else +// CO5300 constructor: (bus, rst, rotation, w, h, col_offset1..2, row_offset1..2) +PlatformDisplay *gfx = new PlatformDisplay( bus, LCD_RESET, 0 /* rotation */, LCD_WIDTH, LCD_HEIGHT, 0, 0, 0, 0); TouchDrvCST92xx touch; +#endif XPowersPMU pmu; SensorQMI8658 imu; static UsageData usage = {}; // ---- Touch interrupt + shared state ---- +// Centralized once-per-loop read (CLAUDE.md gotcha #5): calling getPoint() / +// reading FT3168 from multiple sites consumes each other's data. static volatile bool touch_pressed = false; static volatile uint16_t touch_x = 0; static volatile uint16_t touch_y = 0; @@ -41,10 +60,56 @@ static void IRAM_ATTR touch_isr(void) { touch_data_ready = true; } +#ifdef BOARD_AMOLED_18 +// Minimal FT3168 reader (FocalTech standard register layout). +// Avoids vendoring Waveshare's GPLv3 Arduino_DriveBus library. +// reg 0x02: low nibble = active finger count +// reg 0x03/0x04: X1 high (low nibble), X1 low +// reg 0x05/0x06: Y1 high (low nibble), Y1 low +static void ft3168_init(void) { + // Power-mode register 0xA5 = 0x00: active scanning. + Wire.beginTransmission(FT3168_ADDR); + Wire.write(0xA5); + Wire.write(0x00); + Wire.endTransmission(); + // Verify device ID register 0xA0 (FT3168 reports 0x03 but Waveshare's + // panel sometimes returns 0x86 — log but don't fail). + Wire.beginTransmission(FT3168_ADDR); + Wire.write(0xA0); + if (Wire.endTransmission(false) == 0 && Wire.requestFrom(FT3168_ADDR, (uint8_t)1) == 1) { + Serial.printf("FT3168 ID=0x%02X\n", Wire.read()); + } else { + Serial.println("FT3168 ID read failed"); + } +} + +static void ft3168_read_into_shared_state(void) { + Wire.beginTransmission(FT3168_ADDR); + Wire.write(0x02); + if (Wire.endTransmission(false) != 0) { touch_pressed = false; return; } + if (Wire.requestFrom(FT3168_ADDR, (uint8_t)5) != 5) { touch_pressed = false; return; } + uint8_t fingers = Wire.read() & 0x0F; + uint8_t xH = Wire.read(); + uint8_t xL = Wire.read(); + uint8_t yH = Wire.read(); + uint8_t yL = Wire.read(); + if (fingers == 0 || fingers > 5) { + touch_pressed = false; + return; + } + touch_x = ((uint16_t)(xH & 0x0F) << 8) | xL; + touch_y = ((uint16_t)(yH & 0x0F) << 8) | yL; + touch_pressed = true; +} +#endif + static void touch_read() { if (!touch_data_ready) return; touch_data_ready = false; +#ifdef BOARD_AMOLED_18 + ft3168_read_into_shared_state(); +#else int16_t tx[5], ty[5]; uint8_t n = touch.getPoint(tx, ty, touch.getSupportTouchPoint()); if (n > 0) { @@ -54,6 +119,7 @@ static void touch_read() { } else { touch_pressed = false; } +#endif // Touch policy is driven by IDLE_WAKE_ON_TOUCH: // true → a press edge while asleep wakes the device and the first @@ -99,9 +165,11 @@ static uint32_t my_tick(void) { return millis(); } +#ifndef BOARD_AMOLED_18 // 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. +// AMOLED-1.8 port is fixed at 0° so this code is excluded. 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) { @@ -148,14 +216,20 @@ static void rotate_strip(const uint16_t *src, int32_t w, int32_t h, break; } } +#endif // !BOARD_AMOLED_18 -// LVGL flush callback — rotates partial strips and writes to display +// LVGL flush callback — writes pixels to display. +// AMOLED-2.16: applies CPU strip rotation based on IMU. +// AMOLED-1.8 : fixed orientation, direct pass-through. static void my_flush_cb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) { 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(); +#ifdef BOARD_AMOLED_18 + gfx->draw16bitRGBBitmap(area->x1, area->y1, src, w, h); +#else + uint8_t r = imu_get_rotation(); if (r == 0) { gfx->draw16bitRGBBitmap(area->x1, area->y1, src, w, h); } else { @@ -163,10 +237,12 @@ static void my_flush_cb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_m rotate_strip(src, w, h, area->x1, area->y1, r, &dx, &dy, &dw, &dh); gfx->draw16bitRGBBitmap(dx, dy, rot_buf, dw, dh); } +#endif lv_display_flush_ready(disp); } -// CO5300 requires even-aligned flush regions +// CO5300 requires even-aligned flush regions. SH8601's driver doesn't +// enforce this in source, but keeping the rounder is harmless. 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; @@ -260,9 +336,15 @@ void setup() { delay(300); Serial.println("{\"ready\":true}"); - // Init I2C (shared by touch + PMU) + // Init I2C (shared by touch + PMU + IMU + IO expander) Wire.begin(IIC_SDA, IIC_SCL); +#ifdef BOARD_AMOLED_18 + // XCA9554 must come up FIRST — display + touch are held in reset until + // EXIO0..2 go HIGH (see io_expander_init()). + io_expander_init(); +#endif + // Init display gfx->begin(); gfx->fillScreen(0x0000); @@ -271,10 +353,17 @@ void setup() { // Init PMU power_init(); - // Init IMU (accelerometer for auto-rotation) + // Init IMU (accelerometer for auto-rotation; on AMOLED-1.8 we keep init + // for I2C bus health but ignore rotation — see imu.cpp). imu_init(); // Init touch +#ifdef BOARD_AMOLED_18 + ft3168_init(); + pinMode(TP_INT, INPUT_PULLUP); + attachInterrupt(TP_INT, touch_isr, FALLING); + Serial.println("FT3168 attached on INT pin"); +#else touch.setPins(TP_RST, TP_INT); if (!touch.begin(Wire, CST9220_ADDR, IIC_SDA, IIC_SCL)) { Serial.println("Touch init failed"); @@ -285,6 +374,7 @@ void setup() { attachInterrupt(TP_INT, touch_isr, FALLING); Serial.println("Touch init OK"); } +#endif // Init LVGL lv_init(); @@ -293,9 +383,11 @@ void setup() { // 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 +#ifndef BOARD_AMOLED_18 + // rot_buf only needed for AMOLED-2.16 (CPU strip rotation). + // Holds the largest possible strip after rotation (same pixel count as src). rot_buf = (uint16_t*)heap_caps_malloc(LCD_WIDTH * BUF_LINES * 2, MALLOC_CAP_SPIRAM); +#endif lv_display_t* disp = lv_display_create(LCD_WIDTH, LCD_HEIGHT); lv_display_set_color_format(disp, LV_COLOR_FORMAT_RGB565); @@ -313,9 +405,11 @@ void setup() { // Init BLE data channel ble_init(); - // Physical buttons: back (GPIO 0) and forward (GPIO 18) + // Physical buttons pinMode(BTN_BACK, INPUT_PULLUP); +#ifndef BOARD_AMOLED_18 pinMode(BTN_FWD, INPUT_PULLUP); +#endif // Build dashboard ui_init(); @@ -333,7 +427,9 @@ void setup() { static ble_state_t last_ble_state = BLE_STATE_INIT; -// Brightness ramp state for rotation transition +#ifndef BOARD_AMOLED_18 +// Brightness ramp state for rotation transition. +// AMOLED-2.16 only — the 1.8" port is fixed at 0° (no IMU rotation). // On rotation change we blank the panel, force a full LVGL redraw at the // new orientation, then ramp brightness back up over ~125ms so the // transition reads as deliberate instead of as a glitch. @@ -366,6 +462,7 @@ static void handle_rotation_change(void) { if (ramp_step >= 4) ramp_step = 0; else ramp_step++; } +#endif // !BOARD_AMOLED_18 void loop() { touch_read(); @@ -377,20 +474,23 @@ void loop() { imu_tick(); splash_tick(); - // Three-button input (global, screen-independent): - // LEFT (GPIO 0) → Space (voice-mode push-to-talk; press & release tracked) - // RIGHT (GPIO 18) → Shift+Tab (Claude Code mode toggle) - // PWR (AXP) → cycle screens; on splash, cycle animations + // Physical button input (global, screen-independent): + // LEFT (GPIO 0 / BOOT) → Space (voice-mode push-to-talk) + // RIGHT (GPIO 18) → Shift+Tab (Claude Code mode toggle). AMOLED-2.16 only. + // PWR → cycle screens; on splash, cycle animations. + // AMOLED-2.16: AXP2101 PKEY IRQ; AMOLED-1.8: XCA9554 EXIO4. // First press from sleep is consumed for wake only (idle_consume_wake_press // returns true) — the normal action only fires from the second press. // Activity bookkeeping happens inside idle_consume_wake_press, so no // separate idle_note_activity() call is needed here. { - static bool back_was = false, fwd_was = false; - static bool back_wake_swallowed = false, fwd_wake_swallowed = false; + static bool back_was = false; + static bool back_wake_swallowed = false; +#ifndef BOARD_AMOLED_18 + static bool fwd_was = false; + static bool fwd_wake_swallowed = false; +#endif bool back_now = (digitalRead(BTN_BACK) == LOW); - bool fwd_now = (digitalRead(BTN_FWD) == LOW); - if (back_now != back_was) { if (back_now) { if (idle_consume_wake_press()) { @@ -404,6 +504,9 @@ void loop() { } back_was = back_now; } + +#ifndef BOARD_AMOLED_18 + bool fwd_now = (digitalRead(BTN_FWD) == LOW); if (fwd_now != fwd_was) { if (fwd_now) { if (idle_consume_wake_press()) { @@ -417,6 +520,7 @@ void loop() { } fwd_was = fwd_now; } +#endif if (power_pwr_pressed()) { if (!idle_consume_wake_press()) { @@ -426,7 +530,9 @@ void loop() { } } +#ifndef BOARD_AMOLED_18 handle_rotation_change(); +#endif // Update BLE status on screen when state changes ble_state_t bs = ble_get_state(); diff --git a/firmware/src/power.cpp b/firmware/src/power.cpp index d131dea..3896647 100644 --- a/firmware/src/power.cpp +++ b/firmware/src/power.cpp @@ -2,6 +2,10 @@ #include "display_cfg.h" #include +#ifdef BOARD_AMOLED_18 +#include "io_expander.h" +#endif + // Poll intervals #define BATTERY_POLL_MS 2000 #define CHARGING_POLL_MS 500 @@ -15,6 +19,10 @@ static uint32_t last_charging_ms = 0; static uint32_t last_pwr_ms = 0; #define PWR_POLL_MS 50 +#ifdef BOARD_AMOLED_18 +static bool last_pwr_state = false; // edge detection for XCA9554 EXIO4 +#endif + void power_init(void) { if (!pmu.begin(Wire, AXP2101_ADDR, IIC_SDA, IIC_SCL)) { Serial.println("AXP2101 init failed"); @@ -25,10 +33,14 @@ void power_init(void) { pmu.enableBattDetection(); pmu.enableBattVoltageMeasure(); - // Enable PWR button short-press IRQ (mid button for cycling screens) +#ifndef BOARD_AMOLED_18 + // AMOLED-2.16: PWR button events come from AXP2101 PKEY short-press IRQ. + // AMOLED-1.8 routes the PWR button through XCA9554 EXIO4 instead — we + // poll it in power_tick() rather than subscribing to the PMU IRQ. pmu.disableIRQ(XPOWERS_AXP2101_ALL_IRQ); pmu.clearIrqStatus(); pmu.enableIRQ(XPOWERS_AXP2101_PKEY_SHORT_IRQ); +#endif cached_charging = pmu.isCharging(); cached_vbus = pmu.isVbusIn(); @@ -49,14 +61,23 @@ void power_tick(void) { cached_pct = pmu.getBatteryPercent(); } - // Poll PWR button (AXP2101 short-press IRQ) + // Poll PWR button if (now - last_pwr_ms >= PWR_POLL_MS) { last_pwr_ms = now; +#ifdef BOARD_AMOLED_18 + // XCA9554 EXIO4 — active HIGH, edge-trigger on press + bool pwr_now = io_expander_get(IOX_PIN_PWR_BTN); + if (pwr_now && !last_pwr_state) { + pwr_pressed_flag = true; + } + last_pwr_state = pwr_now; +#else pmu.getIrqStatus(); if (pmu.isPekeyShortPressIrq()) { pwr_pressed_flag = true; } pmu.clearIrqStatus(); +#endif } } diff --git a/firmware/src/splash.cpp b/firmware/src/splash.cpp index 3b8d4b2..83b1d5c 100644 --- a/firmware/src/splash.cpp +++ b/firmware/src/splash.cpp @@ -2,13 +2,21 @@ #include "splash_animations.h" #include "theme.h" #include "usage_rate.h" +#include "display_cfg.h" #include #include #include -// 20x20 grid scaled 24x to fill 480x480 +// 20x20 grid. CELL chosen per board so the canvas fits the screen +// (must satisfy GRID*CELL <= min(LCD_WIDTH, LCD_HEIGHT)). +// AMOLED-2.16 (480x480 square): CELL=24 → 480x480 fills screen +// AMOLED-1.8 (368x448 portrait): CELL=18 → 360x360 centered, vertical margin #define GRID 20 +#ifdef BOARD_AMOLED_18 +#define CELL 18 +#else #define CELL 24 +#endif #define CANVAS_W (GRID * CELL) #define CANVAS_H (GRID * CELL) @@ -99,7 +107,7 @@ void splash_init(lv_obj_t *parent) { } splash_container = lv_obj_create(parent); - lv_obj_set_size(splash_container, 480, 480); + lv_obj_set_size(splash_container, LCD_WIDTH, LCD_HEIGHT); lv_obj_set_pos(splash_container, 0, 0); lv_obj_set_style_bg_color(splash_container, THEME_BG, 0); lv_obj_set_style_bg_opa(splash_container, LV_OPA_COVER, 0); diff --git a/firmware/src/ui.cpp b/firmware/src/ui.cpp index 14dcc47..585afd4 100644 --- a/firmware/src/ui.cpp +++ b/firmware/src/ui.cpp @@ -7,12 +7,31 @@ // Custom fonts (scaled for 314 PPI, ~1.9x from original 165 PPI) LV_FONT_DECLARE(font_tiempos_56); +LV_FONT_DECLARE(font_tiempos_34); LV_FONT_DECLARE(font_styrene_48); LV_FONT_DECLARE(font_styrene_28); LV_FONT_DECLARE(font_styrene_24); LV_FONT_DECLARE(font_styrene_20); +LV_FONT_DECLARE(font_styrene_16); +LV_FONT_DECLARE(font_styrene_14); LV_FONT_DECLARE(font_mono_32); +// AMOLED-1.8 (368 wide) needs smaller fonts on the Bluetooth screen so the +// MAC address and credit lines don't overflow horizontally. +#ifdef BOARD_AMOLED_18 +#define BT_TITLE_FONT font_tiempos_34 +#define BT_STATUS_FONT font_styrene_28 +#define BT_DEVICE_FONT font_styrene_20 +#define BT_CREDIT_1_FONT font_styrene_16 +#define BT_CREDIT_2_FONT font_styrene_14 +#else +#define BT_TITLE_FONT font_tiempos_56 +#define BT_STATUS_FONT font_styrene_48 +#define BT_DEVICE_FONT font_styrene_28 +#define BT_CREDIT_1_FONT font_styrene_24 +#define BT_CREDIT_2_FONT font_styrene_20 +#endif + // Anthropic brand palette — design tokens live in theme.h #include "theme.h" #define COL_BG THEME_BG @@ -25,13 +44,19 @@ LV_FONT_DECLARE(font_mono_32); #define COL_RED THEME_RED #define COL_BAR_BG THEME_BAR_BG -// ---- Layout constants for 480x480 (scaled for 2.16" high-DPI + rounded corners) ---- -#define SCR_W 480 -#define SCR_H 480 -#define MARGIN 20 // wider margin for rounded display corners +// ---- Layout constants ---- +// Width/height track the active display (480x480 for AMOLED-2.16, 368x448 for AMOLED-1.8). +// MARGIN clears rounded display corners on both panels. +#define SCR_W LCD_WIDTH +#define SCR_H LCD_HEIGHT +#define MARGIN 20 #define TITLE_Y 30 +#ifdef BOARD_AMOLED_18 +#define CONTENT_Y 85 // tighter vertical packing for 448-tall portrait +#else #define CONTENT_Y 100 -#define CONTENT_W (SCR_W - 2 * MARGIN) // 440 +#endif +#define CONTENT_W (SCR_W - 2 * MARGIN) // ---- Usage screen widgets ---- static lv_obj_t* usage_container; @@ -218,14 +243,26 @@ static void init_battery_icons(void) { init_icon_dsc_rgb565a8(&battery_dscs[4], ICON_BATTERY_CHARGING_W, ICON_BATTERY_CHARGING_H, icon_battery_charging_data); } -// ======== Usage Screen (480x480) ======== +// ======== Usage Screen ======== -#define PANEL_H 150 -#define PANEL_GAP 16 +#ifdef BOARD_AMOLED_18 +// 368x448 portrait — compressed vertical layout so two panels + bottom anim +// label fit without overlap. +#define PANEL_H 130 +#define PANEL_GAP 12 +#define PANEL_BAR_Y 48 +#define PANEL_RESET_Y 78 +#else +// 480x480 square (original) +#define PANEL_H 150 +#define PANEL_GAP 16 +#define PANEL_BAR_Y 56 +#define PANEL_RESET_Y 94 +#endif // One Session/Weekly panel: big % label, pill on the right, bar, reset label. // Pill y=1: symmetric inside the panel — panel-outer-top → pill-top equals -// pill-bottom → bar-top (pill height 42 + panel pad_top 12 + bar y=56). +// pill-bottom → bar-top. static void make_usage_panel(lv_obj_t* parent, int y, const char* pill_text, lv_obj_t** out_pct, lv_obj_t** out_pill, lv_obj_t** out_bar, lv_obj_t** out_reset) { @@ -240,13 +277,13 @@ static void make_usage_panel(lv_obj_t* parent, int y, const char* pill_text, *out_pill = make_pill(panel, pill_text); lv_obj_align(*out_pill, LV_ALIGN_TOP_RIGHT, 0, 1); - *out_bar = make_bar(panel, 0, 56, CONTENT_W - 32, 24); + *out_bar = make_bar(panel, 0, PANEL_BAR_Y, CONTENT_W - 32, 24); *out_reset = lv_label_create(panel); lv_label_set_text(*out_reset, "---"); lv_obj_set_style_text_font(*out_reset, &font_styrene_28, 0); lv_obj_set_style_text_color(*out_reset, COL_DIM, 0); - lv_obj_set_pos(*out_reset, 0, 94); + lv_obj_set_pos(*out_reset, 0, PANEL_RESET_Y); } static void init_usage_screen(lv_obj_t* scr) { @@ -279,7 +316,15 @@ static void init_usage_screen(lv_obj_t* scr) { lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, -15); } -// ======== Bluetooth Screen (480x480) ======== +// ======== Bluetooth Screen ======== + +#ifdef BOARD_AMOLED_18 +#define BT_INFO_PANEL_H 140 +#define BT_RESET_ZONE_H 90 +#else +#define BT_INFO_PANEL_H 160 +#define BT_RESET_ZONE_H 110 +#endif static void init_bluetooth_screen(lv_obj_t* scr) { ble_container = lv_obj_create(scr); @@ -293,12 +338,12 @@ static void init_bluetooth_screen(lv_obj_t* scr) { // Title lv_obj_t* lbl_ble_title = lv_label_create(ble_container); lv_label_set_text(lbl_ble_title, "Bluetooth"); - lv_obj_set_style_text_font(lbl_ble_title, &font_tiempos_56, 0); + lv_obj_set_style_text_font(lbl_ble_title, &BT_TITLE_FONT, 0); lv_obj_set_style_text_color(lbl_ble_title, COL_TEXT, 0); lv_obj_align(lbl_ble_title, LV_ALIGN_TOP_MID, 16, TITLE_Y); - // Info panel (taller for 480x480) - lv_obj_t* p_info = make_panel(ble_container, MARGIN, CONTENT_Y, CONTENT_W, 160); + // Info panel + lv_obj_t* p_info = make_panel(ble_container, MARGIN, CONTENT_Y, CONTENT_W, BT_INFO_PANEL_H); // Bluetooth icon + status row static lv_image_dsc_t icon_bt_dsc; @@ -310,27 +355,27 @@ static void init_bluetooth_screen(lv_obj_t* scr) { lbl_ble_status = lv_label_create(p_info); lv_label_set_text(lbl_ble_status, "Initializing..."); - lv_obj_set_style_text_font(lbl_ble_status, &font_styrene_48, 0); + lv_obj_set_style_text_font(lbl_ble_status, &BT_STATUS_FONT, 0); lv_obj_set_style_text_color(lbl_ble_status, COL_DIM, 0); lv_obj_set_pos(lbl_ble_status, 56, 2); lbl_ble_device = lv_label_create(p_info); lv_label_set_text(lbl_ble_device, "Device: ---"); - lv_obj_set_style_text_font(lbl_ble_device, &font_styrene_28, 0); + lv_obj_set_style_text_font(lbl_ble_device, &BT_DEVICE_FONT, 0); lv_obj_set_style_text_color(lbl_ble_device, COL_DIM, 0); lv_obj_set_pos(lbl_ble_device, 0, 64); lbl_ble_mac = lv_label_create(p_info); lv_label_set_text(lbl_ble_mac, "Address: ---"); - lv_obj_set_style_text_font(lbl_ble_mac, &font_styrene_28, 0); + lv_obj_set_style_text_font(lbl_ble_mac, &BT_DEVICE_FONT, 0); lv_obj_set_style_text_color(lbl_ble_mac, COL_DIM, 0); lv_obj_set_pos(lbl_ble_mac, 0, 100); // Reset Bluetooth tap zone with trash icon - int reset_y = CONTENT_Y + 160 + 16; + int reset_y = CONTENT_Y + BT_INFO_PANEL_H + 16; lv_obj_t* reset_zone = lv_obj_create(ble_container); lv_obj_set_pos(reset_zone, MARGIN, reset_y); - lv_obj_set_size(reset_zone, CONTENT_W, 110); + lv_obj_set_size(reset_zone, CONTENT_W, BT_RESET_ZONE_H); lv_obj_set_style_bg_color(reset_zone, COL_PANEL, 0); lv_obj_set_style_bg_opa(reset_zone, LV_OPA_COVER, 0); lv_obj_set_style_radius(reset_zone, 8, 0); @@ -348,19 +393,19 @@ static void init_bluetooth_screen(lv_obj_t* scr) { lv_obj_t* reset_lbl = lv_label_create(reset_zone); lv_label_set_text(reset_lbl, "Reset Bluetooth"); - lv_obj_set_style_text_font(reset_lbl, &font_styrene_28, 0); + lv_obj_set_style_text_font(reset_lbl, &BT_DEVICE_FONT, 0); lv_obj_set_style_text_color(reset_lbl, COL_DIM, 0); // Attribution lv_obj_t* lbl_credit = lv_label_create(ble_container); lv_label_set_text(lbl_credit, "Built by @hermannbjorgvin"); - lv_obj_set_style_text_font(lbl_credit, &font_styrene_24, 0); + lv_obj_set_style_text_font(lbl_credit, &BT_CREDIT_1_FONT, 0); lv_obj_set_style_text_color(lbl_credit, COL_DIM, 0); lv_obj_align(lbl_credit, LV_ALIGN_BOTTOM_MID, 0, -46); lv_obj_t* lbl_credit2 = lv_label_create(ble_container); lv_label_set_text(lbl_credit2, "Clawd animation by @amaanbuilds"); - lv_obj_set_style_text_font(lbl_credit2, &font_styrene_20, 0); + lv_obj_set_style_text_font(lbl_credit2, &BT_CREDIT_2_FONT, 0); lv_obj_set_style_text_color(lbl_credit2, COL_DIM, 0); lv_obj_align(lbl_credit2, LV_ALIGN_BOTTOM_MID, 0, -20);