From f3ed2425bfc92ba657171a4fbc4e13ff7aa0b04c Mon Sep 17 00:00:00 2001 From: tobby168 Date: Sun, 17 May 2026 16:54:09 -0700 Subject: [PATCH 1/2] =?UTF-8?q?Add=20Waveshare=20ESP32-S3-Touch-AMOLED-1.8?= =?UTF-8?q?=20(368=C3=97448)=20board=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 47 +++++++---- firmware/platformio.ini | 53 +++++++++++++ firmware/src/display_cfg.h | 61 ++++++++++++++- firmware/src/io_expander.cpp | 72 +++++++++++++++++ firmware/src/io_expander.h | 19 +++++ firmware/src/main.cpp | 148 ++++++++++++++++++++++++++++++----- firmware/src/power.cpp | 25 +++++- firmware/src/splash.cpp | 12 ++- firmware/src/ui.cpp | 91 +++++++++++++++------ 9 files changed, 463 insertions(+), 65 deletions(-) create mode 100644 firmware/src/io_expander.cpp create mode 100644 firmware/src/io_expander.h 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); From 20351212b2b542bcc8b15f7707fc4bf1d75b70c7 Mon Sep 17 00:00:00 2001 From: tobby168 Date: Wed, 20 May 2026 18:27:24 -0700 Subject: [PATCH 2/2] Device-abstraction refactor: HAL + per-board folders + responsive UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the build-flag-driven #ifdef sprawl (~30 blocks across 6 files) with a small HAL in firmware/src/hal/ and per-board folders under firmware/src/boards/. Shared code (main.cpp, ui.cpp, splash.cpp) no longer contains a single `#ifdef BOARD_*` — optional features are guarded by BoardCaps (runtime) and BOARD_HAS_* macros (compile-time, inside the board's own files). Why: lets community contributors port to new ESP32 + AMOLED + touch combos by dropping in a boards// folder + a PlatformIO env, without touching shared files. See docs/porting/adding-a-board.md. Highlights: - New HAL: display_hal, touch_hal, input_hal, power_hal, imu_hal, board_caps. Each board provides display.cpp, touch.cpp, input.cpp, power.cpp, imu.cpp, caps.cpp, board_init.cpp + private hardware drivers (e.g. io_expander.{h,cpp} on AMOLED-1.8). - PlatformIO build_src_filter selects each board's folder per env. - ui.cpp picks fonts and layout from board_caps() via compute_layout() with screen-height breakpoints (>= 460 → large, else compact). - splash.cpp computes CELL = min(W,H)/20 — responsive instead of two hardcoded values. - idle.cpp (from #24) rewired through display_hal + power_hal — no longer depends on the deleted display_cfg.h / power.h. - power_hal gains power_hal_is_vbus_in() for idle's IDLE_SLEEP_WHEN_CHARGING gate. - boards/template/ + docs/porting/{adding-a-board,hal-contract, capability-flags}.md to bootstrap new ports. - display_cfg.h, power.{h,cpp}, imu.{h,cpp}, io_expander.{h,cpp} deleted from src/ root (moved into boards// or hal/). Verification: both `pio run -e waveshare_amoled_216` and `pio run -e waveshare_amoled_18` succeed unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 67 ++- README.md | 35 +- docs/porting/adding-a-board.md | 111 ++++ docs/porting/capability-flags.md | 39 ++ docs/porting/hal-contract.md | 86 +++ firmware/platformio.ini | 14 + firmware/src/boards/template/board.h | 50 ++ firmware/src/boards/template/board_init.cpp | 12 + firmware/src/boards/template/caps.cpp | 14 + firmware/src/boards/template/display.cpp | 59 ++ firmware/src/boards/template/imu.cpp | 9 + firmware/src/boards/template/input.cpp | 25 + firmware/src/boards/template/power.cpp | 22 + firmware/src/boards/template/touch.cpp | 39 ++ .../src/boards/waveshare_amoled_18/board.h | 53 ++ .../boards/waveshare_amoled_18/board_init.cpp | 11 + .../src/boards/waveshare_amoled_18/caps.cpp | 14 + .../boards/waveshare_amoled_18/display.cpp | 54 ++ .../src/boards/waveshare_amoled_18/imu.cpp | 25 + .../src/boards/waveshare_amoled_18/input.cpp | 20 + .../waveshare_amoled_18}/io_expander.cpp | 25 +- .../boards/waveshare_amoled_18/io_expander.h | 12 + .../src/boards/waveshare_amoled_18/power.cpp | 74 +++ .../src/boards/waveshare_amoled_18/touch.cpp | 70 +++ .../src/boards/waveshare_amoled_216/board.h | 45 ++ .../waveshare_amoled_216/board_init.cpp | 9 + .../src/boards/waveshare_amoled_216/caps.cpp | 14 + .../boards/waveshare_amoled_216/display.cpp | 134 +++++ .../{ => boards/waveshare_amoled_216}/imu.cpp | 46 +- .../src/boards/waveshare_amoled_216/input.cpp | 18 + .../waveshare_amoled_216}/power.cpp | 66 +-- .../src/boards/waveshare_amoled_216/touch.cpp | 48 ++ firmware/src/display_cfg.h | 94 ---- firmware/src/hal/board_caps.h | 23 + firmware/src/hal/display_hal.h | 32 ++ firmware/src/hal/imu_hal.h | 11 + firmware/src/hal/input_hal.h | 26 + firmware/src/hal/power_hal.h | 19 + firmware/src/hal/touch_hal.h | 17 + firmware/src/idle.cpp | 8 +- firmware/src/imu.h | 6 - firmware/src/io_expander.h | 19 - firmware/src/main.cpp | 516 +++++------------- firmware/src/power.h | 8 - firmware/src/splash.cpp | 51 +- firmware/src/ui.cpp | 229 ++++---- screenshots/amoled_18/bluetooth.png | Bin 0 -> 16966 bytes screenshots/amoled_18/splash.png | Bin 0 -> 1623 bytes screenshots/amoled_18/usage.png | Bin 0 -> 20603 bytes 49 files changed, 1604 insertions(+), 775 deletions(-) create mode 100644 docs/porting/adding-a-board.md create mode 100644 docs/porting/capability-flags.md create mode 100644 docs/porting/hal-contract.md create mode 100644 firmware/src/boards/template/board.h create mode 100644 firmware/src/boards/template/board_init.cpp create mode 100644 firmware/src/boards/template/caps.cpp create mode 100644 firmware/src/boards/template/display.cpp create mode 100644 firmware/src/boards/template/imu.cpp create mode 100644 firmware/src/boards/template/input.cpp create mode 100644 firmware/src/boards/template/power.cpp create mode 100644 firmware/src/boards/template/touch.cpp create mode 100644 firmware/src/boards/waveshare_amoled_18/board.h create mode 100644 firmware/src/boards/waveshare_amoled_18/board_init.cpp create mode 100644 firmware/src/boards/waveshare_amoled_18/caps.cpp create mode 100644 firmware/src/boards/waveshare_amoled_18/display.cpp create mode 100644 firmware/src/boards/waveshare_amoled_18/imu.cpp create mode 100644 firmware/src/boards/waveshare_amoled_18/input.cpp rename firmware/src/{ => boards/waveshare_amoled_18}/io_expander.cpp (74%) create mode 100644 firmware/src/boards/waveshare_amoled_18/io_expander.h create mode 100644 firmware/src/boards/waveshare_amoled_18/power.cpp create mode 100644 firmware/src/boards/waveshare_amoled_18/touch.cpp create mode 100644 firmware/src/boards/waveshare_amoled_216/board.h create mode 100644 firmware/src/boards/waveshare_amoled_216/board_init.cpp create mode 100644 firmware/src/boards/waveshare_amoled_216/caps.cpp create mode 100644 firmware/src/boards/waveshare_amoled_216/display.cpp rename firmware/src/{ => boards/waveshare_amoled_216}/imu.cpp (61%) create mode 100644 firmware/src/boards/waveshare_amoled_216/input.cpp rename firmware/src/{ => boards/waveshare_amoled_216}/power.cpp (52%) create mode 100644 firmware/src/boards/waveshare_amoled_216/touch.cpp delete mode 100644 firmware/src/display_cfg.h create mode 100644 firmware/src/hal/board_caps.h create mode 100644 firmware/src/hal/display_hal.h create mode 100644 firmware/src/hal/imu_hal.h create mode 100644 firmware/src/hal/input_hal.h create mode 100644 firmware/src/hal/power_hal.h create mode 100644 firmware/src/hal/touch_hal.h delete mode 100644 firmware/src/imu.h delete mode 100644 firmware/src/io_expander.h delete mode 100644 firmware/src/power.h create mode 100644 screenshots/amoled_18/bluetooth.png create mode 100644 screenshots/amoled_18/splash.png create mode 100644 screenshots/amoled_18/usage.png diff --git a/CLAUDE.md b/CLAUDE.md index 672fc51..01143f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,11 +1,17 @@ # Project context -ESP32-S3 firmware for a desk-side Claude Code usage monitor. Two board variants are supported via a build-flag macro: +ESP32-S3 firmware for a desk-side Claude Code usage monitor. Each supported +board lives in its own `firmware/src/boards//` folder and is selected +via PlatformIO's `build_src_filter`. Adding a board means dropping in a new +folder + a new `[env:...]` block — `main.cpp`, `ui.cpp`, and `splash.cpp` +never see board-specific code. See [`docs/porting/adding-a-board.md`](docs/porting/adding-a-board.md). -- **`-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`. +Two reference ports today: -`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`. +- `boards/waveshare_amoled_216/` — original Waveshare ESP32-S3-Touch-AMOLED-2.16 (CO5300, 480×480 square, CST9220 touch, IMU rotation). Build env: `waveshare_amoled_216`. +- `boards/waveshare_amoled_18/` — Waveshare ESP32-S3-Touch-AMOLED-1.8 (SH8601, 368×448 portrait, FT3168 touch, XCA9554 IO expander). Build env: `waveshare_amoled_18`. + +The shared code calls a small HAL (`firmware/src/hal/`) that each board implements: display, touch, input, power, IMU. Optional features are guarded by `BoardCaps` (runtime) and `BOARD_HAS_*` (compile-time) rather than `#ifdef BOARD_*`. 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. @@ -30,21 +36,36 @@ Connects to a host daemon over BLE; daemon polls Anthropic API for usage data. T ## Architecture ```text -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 +firmware/src/ + hal/ — board-agnostic interfaces shared code calls into + board_caps.h — runtime BoardCaps struct (W, H, button_count, has_* flags) + display_hal.h — init / begin / set_brightness / draw_bitmap / tick / round_area + touch_hal.h — init / read(&x, &y, &pressed) + input_hal.h — init / is_held(PRIMARY|SECONDARY) + power_hal.h — init / tick / battery_pct / is_charging / pwr_pressed (edge) + imu_hal.h — init / tick / rotation_quadrant + boards/ + waveshare_amoled_216/ — CO5300 + CST9220 + AXP PKEY + QMI8658 rotation + waveshare_amoled_18/ — SH8601 + FT3168 + AXP + XCA9554 (PWR via EXIO4), no rotation + template/ — copy this to bootstrap a new port + main.cpp — setup() + loop(): HAL calls only, zero #ifdef BOARD_* + ui.{h,cpp} — 3-screen UI (splash, usage, bluetooth). compute_layout() picks fonts/positions from board_caps() (responsive — current breakpoint: H >= 460 → large, else compact) + splash.{h,cpp} — 20×20 pixel-art engine. CELL = min(W,H)/20, centered. + 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 +docs/porting/ — adding-a-board.md, hal-contract.md, capability-flags.md ``` +Each board folder contains: `board.h` (pins, I2C addresses, `BOARD_HAS_*` flags), +`board_init.cpp` (Wire.begin + any IO expander), `display.cpp`, `touch.cpp`, +`input.cpp`, `power.cpp`, `imu.cpp`, `caps.cpp` (the `BoardCaps` instance), plus +any board-private hardware drivers (e.g. `io_expander.{h,cpp}` on AMOLED-1.8). +PlatformIO's `build_src_filter` includes shared code + one board's folder per env. + ## Build / flash ```bash @@ -66,14 +87,16 @@ The boot screen is `SCREEN_SPLASH` and only advances on a physical button press, ## Critical gotchas -1. **CO5300 cannot rotate.** Its MADCTL only supports axis flips, not column/row exchange. Rotation is done by **CPU pixel remapping in `my_flush_cb`** in main.cpp. We use **PARTIAL render mode with strip rotation** (small 480×40 strips, fast). On rotation change → AMOLED brightness flash → force redraw. +1. **CO5300 cannot rotate.** Its MADCTL only supports axis flips, not column/row exchange. Rotation is done by **CPU pixel remapping inside `display_hal_draw_bitmap`** in `boards/waveshare_amoled_216/display.cpp`. We use **PARTIAL render mode with strip rotation** (small 480×40 strips, fast). On rotation change → AMOLED brightness flash → force redraw (handled inside `display_hal_tick`). 2. **OPI PSRAM** required: `board_build.arduino.memory_type = qio_opi` in platformio.ini. Without this, `MALLOC_CAP_SPIRAM` returns NULL and the screen is black. 3. **pioarduino platform required.** GFX Library for Arduino needs Arduino Core 3.x (`esp32-hal-periman.h`), not the 2.x that standard `espressif32` ships. We pin `pioarduino/platform-espressif32` 55.03.38-1. 4. **LVGL 9 font patching.** `lv_font_conv` outputs LVGL 8 format. Must remove `#if LVGL_VERSION_MAJOR >= 8` guards, drop `.cache` field, add `.release_glyph`, `.kerning`, `.static_bitmap`, `.fallback`, `.user_data`. Without patching, fonts render invisible. -5. **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 LVGL `my_touch_cb` and `touch.cpp` read from shared `touch_pressed/touch_x/touch_y` state. -6. **CO5300 needs even-aligned flush regions.** `rounder_cb` enforces this. -7. **Touch `setSwapXY(true)` and `setMirrorXY(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°). +5. **Touch reading is centralized inside each board's `touch.cpp`.** The HAL `touch_hal_read()` is called once per loop from `my_touch_cb`; the board's implementation owns its latched `touch_pressed/x/y` state. Don't call the underlying controller from anywhere else — CST9220's `getPoint()` etc. do a full I2C transaction and concurrent callers consume each other's data. +6. **Even-aligned flush regions.** `display_hal_round_area` (called from `rounder_cb`) is what each board uses to enforce this. Required on CO5300, harmless on SH8601. +7. **Touch axis swap/mirror is per-board.** The 2.16's CST9220 needs `setSwapXY(true)` + `setMirrorXY(true, false)` — applied inside `boards/waveshare_amoled_216/touch.cpp::touch_hal_init()`. New ports apply their own. 8. **LVGL RGB565A8 is planar.** `w*h` RGB565 pixels followed by `w*h` alpha bytes; `data_size = w*h*3`, `stride = w*2`. Use `init_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. See `tools/png_to_lvgl.js`. +9. **Per-board pre-init is `board_init()`.** Each board's `board_init.cpp` brings up `Wire` and any reset-gating IO expander BEFORE `display_hal_init()`. Skipping the IO expander release on AMOLED-1.8 leaves SH8601 + FT3168 in reset and they silently fail to probe. +10. **No `#ifdef BOARD_*` in shared code.** The whole point of the refactor — if you're about to add one, you probably want a `BoardCaps` field or a per-board file instead. See `docs/porting/capability-flags.md`. ## Icons @@ -97,6 +120,8 @@ See `~/.claude/projects/.../memory/` files for persistent context (user is an em ## Recent session highlights +- **Device-abstraction refactor (2026-05-18).** All board-conditional code moved out of shared files into `boards//` and behind a HAL in `hal/`. ~30 `#ifdef BOARD_*` blocks went to zero. UI is responsive via `compute_layout()` driven by `board_caps()`. New ports add a folder + a PlatformIO env — no shared file edits. +- Added second board port: Waveshare AMOLED-1.8 (368×448 portrait, SH8601, FT3168, XCA9554 IO expander). - 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. diff --git a/README.md b/README.md index e9321f8..4914c6e 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,18 @@ While the splash is up, the middle button cycles animations instead of screens. ## Hardware -- [Waveshare ESP32-S3-Touch-AMOLED-2.16](https://www.waveshare.com/esp32-s3-touch-amoled-2.16.htm?&aff_id=149786) - ESP32-S3R8, 2.16" 480×480 AMOLED (CO5300 QSPI), CST9220 cap touch, AXP2101 PMU + Li-Po battery, QMI8658 IMU +Two boards are supported out of the box: + +- [Waveshare ESP32-S3-Touch-AMOLED-2.16](https://www.waveshare.com/esp32-s3-touch-amoled-2.16.htm?&aff_id=149786) — ESP32-S3R8, 2.16" 480×480 AMOLED (CO5300 QSPI), CST9220 cap touch, AXP2101 PMU + Li-Po battery, QMI8658 IMU. Three side buttons, IMU auto-rotation. Build env: `waveshare_amoled_216`. +- [Waveshare ESP32-S3-Touch-AMOLED-1.8](https://www.waveshare.com/esp32-s3-touch-amoled-1.8.htm) — ESP32-S3R8, 1.8" 368×448 portrait AMOLED (SH8601 QSPI), FT3168 cap touch, AXP2101 PMU, QMI8658 IMU, XCA9554 IO expander, 16 MB flash. Two buttons (BOOT + PWR), fixed orientation. Build env: `waveshare_amoled_18`. + +Plus per board: + - USB-C cable for flashing firmware and charging - 3.7V Li-Po battery (MX1.25 2-pin connector, optional) +**Porting to another board:** the firmware is a thin HAL with per-board folders under `firmware/src/boards/`. Drop in a new folder and a new PlatformIO env — `main.cpp`, `ui.cpp`, and `splash.cpp` never need to change. See [`docs/porting/adding-a-board.md`](docs/porting/adding-a-board.md) for the walk-through and [`docs/porting/hal-contract.md`](docs/porting/hal-contract.md) for the interfaces a port must implement. + ## Prerequisites - Linux (tested on Ubuntu) or macOS @@ -188,6 +196,31 @@ lv_font_conv --font assets/DejaVuSansMono.ttf \ Without these patches, fonts compile but render as invisible. +### CJK support + +`firmware/src/font_cjk_16.c` covers the full CJK Unified Ideographs basic +block (U+4E00–U+9FFF, ~20k glyphs) plus ASCII, CJK punctuation, and +halfwidth/fullwidth forms. Generated from [Noto Sans CJK SC](https://github.com/notofonts/noto-cjk) +(SIL OFL 1.1) at 16px, 2bpp: + +```bash +lv_font_conv --font NotoSansCJKsc-Regular.otf --size 16 --bpp 2 \ + --no-compress --format lvgl --lv-include 'lvgl.h' \ + -r '0x20-0x7E,0xB7,0x2014,0x2018-0x2019,0x201C-0x201D,0x2026,0x3000-0x303F,0x4E00-0x9FFF,0xFF00-0xFFEF' \ + -o firmware/src/font_cjk_16.c +``` + +Then apply the four LVGL 9 patches above. Because the font has >65k of +glyph bitmap data, the build needs `-DLV_FONT_FMT_TXT_LARGE=1` in +`platformio.ini` build flags so font descriptor offsets switch from +16-bit to 32-bit. + +The CJK font is used for the Activity screen's user-prompt row and todo +content rows. The headline (28pt Styrene B) and titles stay ASCII-only +to preserve the brand font — Chinese text in those slots renders as +empty boxes. Add a `font_cjk_28.c` if full coverage is needed (~1MB +more flash). + ## Converting Lucide icons The UI uses a small set of [Lucide](https://lucide.dev) icons (bluetooth + battery states) converted to RGB565 / RGB565A8 C arrays for LVGL. diff --git a/docs/porting/adding-a-board.md b/docs/porting/adding-a-board.md new file mode 100644 index 0000000..3bdc232 --- /dev/null +++ b/docs/porting/adding-a-board.md @@ -0,0 +1,111 @@ +# Porting Clawdmeter to a new board + +A board port is a folder under `firmware/src/boards/` plus a new +`[env:...]` block in `firmware/platformio.ini`. You should never need +to edit `firmware/src/main.cpp`, `firmware/src/ui.cpp`, or anything +under `firmware/src/hal/`. If you find yourself wanting to, that's a +gap in the HAL — open an issue. + +## Hardware you need + +At minimum: + +- An **ESP32-S3** (other ESP32 family members may work; this is what the + upstream firmware is tested on). OPI PSRAM is **required** — partial + flush buffers and the splash canvas are allocated from PSRAM. +- A QSPI **AMOLED panel** with a driver supported by + [GFX Library for Arduino](https://github.com/moononournation/Arduino_GFX) + (CO5300, SH8601, NV3041A, etc.). Other interfaces aren't supported yet. +- A **touch controller** over I2C. The HAL just needs init + read; you + can use any driver you can compile. +- A **primary button** (typically the BOOT/GPIO 0 push button). + +Optional: + +- A second physical button (e.g. for HID Shift+Tab mode toggle). +- An AXP2101 PMU for battery monitoring + a power button. +- A QMI8658 (or compatible) IMU for automatic rotation. +- An XCA9554 / PCA9554 IO expander if reset / enable lines are routed + through one (the AMOLED-1.8 board does this). + +## Step-by-step + +1. **Copy the template folder.** + + ```bash + cp -r firmware/src/boards/template firmware/src/boards/my_board + ``` + +2. **Fill in `boards/my_board/board.h`.** Replace every `TODO` with your + board's pins, I2C addresses, dimensions, and capability flags. The + capability flags drive both compile-time dead-stripping in the HAL + implementations and runtime UI decisions via `BoardCaps`. + +3. **Implement the per-board sources.** Each one corresponds to a HAL + header in `firmware/src/hal/`. Look at one of the reference ports for + a worked example: + + | File | Reference port (start here) | + |-------------------|----------------------------------------------------------------| + | `display.cpp` | `boards/waveshare_amoled_216/display.cpp` (with CPU rotation) or `_18/display.cpp` (no rotation) | + | `touch.cpp` | `_216/touch.cpp` (library-based) or `_18/touch.cpp` (vendored I2C reader) | + | `input.cpp` | `_216/input.cpp` (two buttons) or `_18/input.cpp` (one button) | + | `power.cpp` | `_216/power.cpp` (PMU IRQ) or `_18/power.cpp` (PMU + IO expander button) | + | `imu.cpp` | `_216/imu.cpp` (full rotation) or `_18/imu.cpp` (init-only stub) | + | `caps.cpp` | either reference — just edit the struct literal | + | `board_init.cpp` | `_216/board_init.cpp` (no expander) or `_18/board_init.cpp` (with expander) | + +4. **Add a PlatformIO env.** In `firmware/platformio.ini`, copy one of + the existing `[env:waveshare_amoled_*]` blocks and adjust: + + ```ini + [env:my_board] + ; ... platform / board / framework as before ... + + build_src_filter = + +<*> + - + + ; the only line you change here + + build_flags = + -DBOARD_MY_BOARD ; identity-only — the shared code never + ; branches on this; per-board code may + ``` + + If your panel needs flash > 4 MB (extra animations, larger fonts), + copy the `board_upload.*` block from the AMOLED-1.8 env. + +5. **Build.** `pio run -d firmware -e my_board`. The link step is the + real verification — any missing HAL symbol or duplicated definition + shows up here. + +6. **Flash + smoke test.** The first boot should land on the splash + screen. If it doesn't, check `pio device monitor` for HAL init + messages — every reference port logs OK / failure for display, touch, + PMU, IMU during `setup()`. + +7. **Visual QA.** `./screenshot.sh out.png` over USB serial captures + the live framebuffer at the active resolution. The UI is responsive + (see [hal-contract.md](hal-contract.md) for breakpoint details); + most ports will look acceptable out of the box. If your screen size + doesn't match an existing breakpoint, you may want to add one to + `compute_layout()` in `firmware/src/ui.cpp`. + +## Common pitfalls + +- **Display stays black, no panic.** Usually one of: OPI PSRAM not enabled + in platformio.ini (check `board_build.arduino.memory_type = qio_opi`); + IO expander not released before `gfx->begin()` (run `io_expander_init()` + from `board_init()`); GFX library version too old to know about your + panel chip. +- **Touch reads zeros / wrong coordinates.** The HAL hands LVGL whatever + the controller reports — apply any axis swap / mirror inside your + `touch.cpp`. CST9220 needs `setSwapXY(true)` + `setMirrorXY(true, + false)` on the AMOLED-2.16 board; your controller will likely differ. +- **GPL warning when picking a touch driver.** The project intentionally + avoids copyleft dependencies. If the only available library is GPL, + vendor a minimal I2C reader instead (see `_18/touch.cpp`). +- **Both boards built fine but one runs and the other doesn't.** The + build_src_filter is per-env — re-check you copied the existing env + blocks correctly and the `-` then `+` + ordering is right (filters apply in declaration order). diff --git a/docs/porting/capability-flags.md b/docs/porting/capability-flags.md new file mode 100644 index 0000000..76c2d63 --- /dev/null +++ b/docs/porting/capability-flags.md @@ -0,0 +1,39 @@ +# Capability flags + +Each board's `board.h` declares these. They're consumed in two places: + +1. **`caps.cpp`** — copies them into the `BoardCaps` instance so shared + code (`ui.cpp`, `main.cpp`) can query them at runtime via + `board_caps()`. +2. **The per-board source files** — `#if BOARD_HAS_*` lets the linker + dead-strip entire functions on boards that don't need them. + +Keep the two in sync. The pattern in `caps.cpp` does this for you: +```c +.button_count = (uint8_t)(1 + BOARD_HAS_SECONDARY_BUTTON), +.has_rotation = (bool)BOARD_HAS_ROTATION, +``` + +## The flags + +| Macro | Default | What it gates | +|--------------------------------|---------|---------------| +| `BOARD_HAS_SECONDARY_BUTTON` | 0 | A second physical button (HID Shift+Tab on the reference ports). `caps.button_count = 1 + this`. UI uses `caps.button_count >= 2` to decide whether to poll/handle the secondary button — there is no `#ifdef` in shared code. | +| `BOARD_HAS_ROTATION` | 0 | IMU-driven auto-rotation via CPU strip transformation in `display_hal_draw_bitmap`. When 0, `display_hal_tick` is a no-op and the rotation buffer in `display.cpp` doesn't get allocated. | +| `BOARD_HAS_IMU` | 0 | Whether the accelerometer is populated and initialized. Distinct from `BOARD_HAS_ROTATION` — the AMOLED-1.8 has the QMI8658 (so `HAS_IMU=1`) but the kit's enclosure mounts the panel at a fixed orientation, so rotation is off. | +| `BOARD_HAS_BATTERY` | 0 | Whether PMU battery measurement is meaningful on this board. UI hides the battery indicator when false. | +| `BOARD_HAS_IO_EXPANDER` | 0 | Whether an IO expander gates display / touch reset lines. Doesn't directly gate any code path — but signals to the porter that `board_init()` must release the expander before `display_hal_init()`. | + +## Future capabilities + +Add a new flag when: + +- A shared-code decision currently uses `if (caps.has_)` and + you want to extend it (e.g. add `BOARD_HAS_HAPTIC` for vibration + feedback). +- A per-board file conditionally compiles a block of code (e.g. + audio amp init under `BOARD_HAS_AUDIO`). + +Don't add a flag for a one-off detail. If only one board cares about it +and shared code never queries it, leave it as a constant in that board's +`board.h` and use it only in that board's `.cpp` files. diff --git a/docs/porting/hal-contract.md b/docs/porting/hal-contract.md new file mode 100644 index 0000000..afaea11 --- /dev/null +++ b/docs/porting/hal-contract.md @@ -0,0 +1,86 @@ +# HAL contract + +Each header under `firmware/src/hal/` defines functions that a board port +must provide. The shared code (`main.cpp`, `ui.cpp`, `splash.cpp`) calls +these and nothing else. Where a function has non-functional requirements +(latency, ordering), they're listed here — silently violating them tends +to produce subtle bugs (dropped frames, missed events) rather than +crashes. + +## `board_caps.h` + +Runtime description of the board. Provided by your `caps.cpp` as a +single `const BoardCaps` instance returned from `board_caps()`. The UI +queries this at startup and gates optional features (battery indicator, +secondary-button HID mapping) by what's true here. Keep the struct in +sync with the compile-time `BOARD_HAS_*` flags in `board.h`. + +## `display_hal.h` + +| Function | Responsibility | +|-----------------------------|----------------| +| `display_hal_init` | Construct the QSPI bus + driver. Must run AFTER `board_init()` so any IO expander has released the LCD reset line. | +| `display_hal_begin` | `gfx->begin()`, clear screen, set default brightness. Allocate any rotation buffers needed by `display_hal_draw_bitmap`. | +| `display_hal_set_brightness`| Pass-through to the driver. Driver-defined scale (typically 0..255). | +| `display_hal_fill_screen` | Used by tests / boot screen — `gfx->fillScreen(color)`. | +| `display_hal_draw_bitmap` | Push a w×h RGB565 strip at (x, y). If the panel can't rotate natively, apply CPU rotation here before pushing — `imu_hal_rotation_quadrant()` returns the current orientation. **Must complete inside LVGL's render budget** (a few ms at typical strip sizes). | +| `display_hal_tick` | Per-loop housekeeping — used by rotation-aware boards to blank the panel + ramp brightness during a rotation transition. No-op on boards without rotation. | +| `display_hal_round_area` | LVGL invalidate-area hook. Most QSPI AMOLED drivers expect even-aligned flush regions; apply `& ~1` / `| 1` to coordinates. | + +## `touch_hal.h` + +| Function | Responsibility | +|-------------------|----------------| +| `touch_hal_init` | Initialize the controller + attach a touch interrupt. Configure axis swap / mirror so coordinates returned in `touch_hal_read` match the panel's pixel coordinates after any rotation. | +| `touch_hal_read` | Return the latest sample. **Hard requirement: complete in well under 5 ms** — LVGL polls this every screen refresh and any I2C burst longer than a screen tick will visibly stutter. | + +Avoid GPL-licensed drivers — vendor a minimal reader instead. The +existing AMOLED-1.8 port has a ~40-line FT3168 reader you can model on. + +## `input_hal.h` + +| Function | Responsibility | +|-------------------|----------------| +| `input_hal_init` | `pinMode()` for the physical button GPIOs. | +| `input_hal_is_held` | Return true while the button is held. Active-low pull-up GPIOs are typical. Boards lacking a secondary button must return `false` for `INPUT_BTN_SECONDARY`. | + +The PWR button is **not** here — it belongs to `power_hal` because on +several boards (including all current reference ports) it's tied to the +PMU or an IO expander, not a GPIO. + +## `power_hal.h` + +| Function | Responsibility | +|-------------------------|----------------| +| `power_hal_init` | Bring up the PMU (if any). Configure battery measurement. Subscribe to the PWR button source (PMU IRQ or IO expander polling). | +| `power_hal_tick` | Refresh battery % and charging state at sensible intervals (the reference ports use 2s / 500 ms). Poll the PWR button if it's not interrupt-driven. | +| `power_hal_battery_pct` | 0..100, or `-1` when battery info isn't available. | +| `power_hal_is_charging` | Bool, false on no-battery boards. | +| `power_hal_pwr_pressed` | **Edge-triggered**: returns true once per short-press, then clears. Shared code calls this every loop and expects one true per press. | + +Boards with no PMU and no PWR button can return zero/false from all five +— set `BOARD_HAS_BATTERY=0` and the UI hides the battery indicator. + +## `imu_hal.h` + +| Function | Responsibility | +|------------------------------|----------------| +| `imu_hal_init` | Bring up the accelerometer. | +| `imu_hal_tick` | Sample the accelerometer at a low rate (~10 Hz) and update the rotation state with hysteresis. | +| `imu_hal_rotation_quadrant` | Current rotation, 0..3 (quarter turns CW). Used by `display_hal_draw_bitmap` on rotation-capable boards. Boards without rotation always return 0. | + +## Responsive UI breakpoints + +`ui.cpp::compute_layout()` picks layout values from `board_caps().width` +and `.height`. The current breakpoints are: + +- **`height >= 460`** → "large" layout, tuned for 480×480. +- **otherwise** → "compact" layout, tuned for 368×448. + +A new screen size lands on the closer breakpoint and renders correctly +without pixel-perfect alignment. If you want polish, add another branch +to `compute_layout()` (please open a PR — others with that size benefit). + +The splash screen is fully responsive — `CELL` is computed as +`min(width, height) / 20` so the 20×20 pixel-art creature fills the +smaller display dimension and centers in the larger one. diff --git a/firmware/platformio.ini b/firmware/platformio.ini index 1a0eda1..fc23263 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -6,6 +6,13 @@ board_build.arduino.memory_type = qio_opi upload_speed = 921600 monitor_speed = 115200 +; Compile shared code + this board's per-board folder; exclude all other +; boards. New ports add a `+/>` line in their own env block. +build_src_filter = + +<*> + - + + + build_flags = -DBOARD_AMOLED_216 -DARDUINO_USB_CDC_ON_BOOT=1 @@ -56,6 +63,13 @@ board_build.partitions = default_16MB.csv upload_speed = 921600 monitor_speed = 115200 +; Compile shared code + this board's per-board folder; exclude all other +; boards. New ports add a `+/>` line in their own env block. +build_src_filter = + +<*> + - + + + build_flags = -DBOARD_AMOLED_18 -DARDUINO_USB_CDC_ON_BOOT=1 diff --git a/firmware/src/boards/template/board.h b/firmware/src/boards/template/board.h new file mode 100644 index 0000000..62dcce7 --- /dev/null +++ b/firmware/src/boards/template/board.h @@ -0,0 +1,50 @@ +#pragma once + +// Template board.h — copy this file (and the rest of boards/template/) to +// boards// and fill in each TODO. See docs/porting/adding-a-board.md +// for a walk-through. + +#define BOARD_NAME "TODO: human-readable board name" + +// ---- Display geometry ---- +// Active panel pixel dimensions (post-orientation). +#define LCD_WIDTH 240 // TODO +#define LCD_HEIGHT 240 // TODO + +// ---- QSPI display pins ---- +// Wire your panel datasheet's QSPI pins to MCU GPIOs and list them here. +#define LCD_CS 12 // TODO +#define LCD_SCLK 38 // TODO +#define LCD_SDIO0 4 // TODO +#define LCD_SDIO1 5 // TODO +#define LCD_SDIO2 6 // TODO +#define LCD_SDIO3 7 // TODO +#define LCD_RESET 2 // TODO; use GFX_NOT_DEFINED if you reset via an expander + +// ---- I2C bus (shared by touch, PMU, IMU, IO expander) ---- +#define IIC_SDA 15 // TODO +#define IIC_SCL 14 // TODO + +// ---- Touch ---- +// I2C address depends on the controller. Replace TODO_TP_ADDR below and pick +// the matching driver in touch.cpp. +#define TP_INT 11 // TODO +#define TP_ADDR 0x00 // TODO + +// ---- PMU ---- +// Drop or change if your board doesn't ship an AXP2101. +#define AXP2101_ADDR 0x34 + +// ---- Buttons ---- +#define BTN_BACK_GPIO 0 // BOOT — primary, Space (PTT) +// If your board has a second physical button, set its GPIO and bump +// BOARD_HAS_SECONDARY_BUTTON to 1 below. + +// ---- Capability flags ---- +// Compile-time switches the linker uses to dead-strip optional features. +// Keep these in sync with the BoardCaps instance in caps.cpp. +#define BOARD_HAS_SECONDARY_BUTTON 0 // TODO +#define BOARD_HAS_ROTATION 0 // TODO: IMU-driven CPU rotation in display.cpp +#define BOARD_HAS_IMU 0 // TODO +#define BOARD_HAS_BATTERY 0 // TODO +#define BOARD_HAS_IO_EXPANDER 0 // TODO diff --git a/firmware/src/boards/template/board_init.cpp b/firmware/src/boards/template/board_init.cpp new file mode 100644 index 0000000..0c99ce9 --- /dev/null +++ b/firmware/src/boards/template/board_init.cpp @@ -0,0 +1,12 @@ +#include "board.h" +#include +#include + +// Called once at the very start of setup(), before any HAL device init. +// At minimum bring up the shared I2C bus. If your board has an IO expander +// gating the LCD or touch reset lines, initialize and release it here too +// (otherwise display_hal_init() will fail to probe the panel). +extern "C" void board_init(void) { + Wire.begin(IIC_SDA, IIC_SCL); + // TODO: io_expander_init() if your board needs one +} diff --git a/firmware/src/boards/template/caps.cpp b/firmware/src/boards/template/caps.cpp new file mode 100644 index 0000000..61dc59f --- /dev/null +++ b/firmware/src/boards/template/caps.cpp @@ -0,0 +1,14 @@ +#include "../../hal/board_caps.h" +#include "board.h" + +static const BoardCaps caps = { + .name = BOARD_NAME, + .width = LCD_WIDTH, + .height = LCD_HEIGHT, + .button_count = (uint8_t)(1 + BOARD_HAS_SECONDARY_BUTTON), + .has_rotation = (bool)BOARD_HAS_ROTATION, + .has_battery = (bool)BOARD_HAS_BATTERY, + .has_imu = (bool)BOARD_HAS_IMU, +}; + +const BoardCaps& board_caps(void) { return caps; } diff --git a/firmware/src/boards/template/display.cpp b/firmware/src/boards/template/display.cpp new file mode 100644 index 0000000..d4e6af2 --- /dev/null +++ b/firmware/src/boards/template/display.cpp @@ -0,0 +1,59 @@ +#include "../../hal/display_hal.h" +#include "board.h" +#include +#include + +// TODO: pick the right driver class from Arduino_GFX_Library (e.g. +// Arduino_CO5300, Arduino_SH8601, Arduino_NV3041A). Most QSPI AMOLED +// panels are supported in the upstream library — check the panel's +// chip and grep the library include path. + +static Arduino_DataBus* bus = nullptr; +// static Arduino_* gfx = nullptr; + +void display_hal_init(void) { + bus = new Arduino_ESP32QSPI( + LCD_CS, LCD_SCLK, LCD_SDIO0, LCD_SDIO1, LCD_SDIO2, LCD_SDIO3); + // gfx = new Arduino_(bus, LCD_RESET, 0, LCD_WIDTH, LCD_HEIGHT, ...); +} + +void display_hal_begin(void) { + // gfx->begin(); + // gfx->fillScreen(0x0000); + // gfx->setBrightness(200); +} + +void display_hal_set_brightness(uint8_t level) { + (void)level; + // gfx->setBrightness(level); +} + +void display_hal_fill_screen(uint16_t color) { + (void)color; + // gfx->fillScreen(color); +} + +void display_hal_draw_bitmap(int32_t x, int32_t y, int32_t w, int32_t h, + const uint16_t* pixels) { + (void)x; (void)y; (void)w; (void)h; (void)pixels; + // gfx->draw16bitRGBBitmap(x, y, (uint16_t*)pixels, w, h); + // + // If your panel needs CPU rotation (no native MADCTL rotate), set + // BOARD_HAS_ROTATION=1 in board.h, allocate a rotation strip buffer + // in display_hal_begin(), and transform (x, y, w, h) + pixels here. + // See boards/waveshare_amoled_216/display.cpp for a worked example. +} + +void display_hal_tick(void) { + // Only needed for boards that animate the brightness ramp during a + // CPU-rotation transition (see the 2.16 reference port). +} + +void display_hal_round_area(int32_t* x1, int32_t* y1, int32_t* x2, int32_t* y2) { + // Most QSPI AMOLED drivers expect even-aligned flush regions. Harmless + // to apply on panels that don't strictly require it. + *x1 = *x1 & ~1; + *y1 = *y1 & ~1; + *x2 = *x2 | 1; + *y2 = *y2 | 1; +} diff --git a/firmware/src/boards/template/imu.cpp b/firmware/src/boards/template/imu.cpp new file mode 100644 index 0000000..eb5230b --- /dev/null +++ b/firmware/src/boards/template/imu.cpp @@ -0,0 +1,9 @@ +#include "../../hal/imu_hal.h" + +// No IMU on this template. If your board ships an accelerometer (e.g. +// QMI8658 + want auto-rotation), copy boards/waveshare_amoled_216/imu.cpp +// here and set BOARD_HAS_ROTATION=1 in board.h. + +void imu_hal_init(void) {} +void imu_hal_tick(void) {} +uint8_t imu_hal_rotation_quadrant(void) { return 0; } diff --git a/firmware/src/boards/template/input.cpp b/firmware/src/boards/template/input.cpp new file mode 100644 index 0000000..f61bd1d --- /dev/null +++ b/firmware/src/boards/template/input.cpp @@ -0,0 +1,25 @@ +#include "../../hal/input_hal.h" +#include "board.h" +#include + +void input_hal_init(void) { + pinMode(BTN_BACK_GPIO, INPUT_PULLUP); +#if BOARD_HAS_SECONDARY_BUTTON + // pinMode(BTN_FWD_GPIO, INPUT_PULLUP); // TODO +#endif +} + +bool input_hal_is_held(InputButton btn) { + switch (btn) { + case INPUT_BTN_PRIMARY: + return digitalRead(BTN_BACK_GPIO) == LOW; + case INPUT_BTN_SECONDARY: +#if BOARD_HAS_SECONDARY_BUTTON + // return digitalRead(BTN_FWD_GPIO) == LOW; // TODO + return false; +#else + return false; // not present on this board +#endif + } + return false; +} diff --git a/firmware/src/boards/template/power.cpp b/firmware/src/boards/template/power.cpp new file mode 100644 index 0000000..c5d36eb --- /dev/null +++ b/firmware/src/boards/template/power.cpp @@ -0,0 +1,22 @@ +#include "../../hal/power_hal.h" +#include "board.h" +#include + +// Minimal stub — replace with real power management for your board. +// +// If your board has an AXP2101 or similar PMU, mirror +// boards/waveshare_amoled_216/power.cpp. If the PWR button is wired +// somewhere other than the PMU's PKEY pin (e.g. through an IO expander +// like the AMOLED-1.8 board), look at that port instead. +// +// If your board has no PMU and no PWR button, leave the stubs as below +// and set BOARD_HAS_BATTERY=0 in board.h — the UI honors caps.has_battery +// and hides the battery indicator. + +void power_hal_init(void) {} +void power_hal_tick(void) {} + +int power_hal_battery_pct(void) { return -1; } +bool power_hal_is_charging(void) { return false; } +bool power_hal_is_vbus_in(void) { return false; } +bool power_hal_pwr_pressed(void) { return false; } diff --git a/firmware/src/boards/template/touch.cpp b/firmware/src/boards/template/touch.cpp new file mode 100644 index 0000000..14a4468 --- /dev/null +++ b/firmware/src/boards/template/touch.cpp @@ -0,0 +1,39 @@ +#include "../../hal/touch_hal.h" +#include "board.h" +#include +#include + +// TODO: replace the body with a driver for your controller. Two patterns: +// 1. A library (SensorLib's CSTxxx, TAMC_GT911, etc.) — add to lib_deps +// in platformio.ini, mirror the AMOLED-2.16 port's touch.cpp. +// 2. A minimal vendored reader — preferred when the only available +// library is GPL-licensed (see boards/waveshare_amoled_18/touch.cpp). +// +// Whichever you pick, touch_hal_read() must complete in well under 5 ms +// (a single I2C burst is fine) so it doesn't drop frames. + +static volatile bool touch_data_ready = false; +static volatile bool touch_pressed = false; +static volatile uint16_t touch_x = 0; +static volatile uint16_t touch_y = 0; + +static void IRAM_ATTR touch_isr(void) { + touch_data_ready = true; +} + +void touch_hal_init(void) { + // TODO: initialize your controller over I2C; configure to active scanning. + pinMode(TP_INT, INPUT_PULLUP); + attachInterrupt(TP_INT, touch_isr, FALLING); +} + +void touch_hal_read(uint16_t* x, uint16_t* y, bool* pressed) { + if (touch_data_ready) { + touch_data_ready = false; + // TODO: read coords from your controller into touch_x, touch_y, + // touch_pressed. + } + *x = touch_x; + *y = touch_y; + *pressed = touch_pressed; +} diff --git a/firmware/src/boards/waveshare_amoled_18/board.h b/firmware/src/boards/waveshare_amoled_18/board.h new file mode 100644 index 0000000..bacb53e --- /dev/null +++ b/firmware/src/boards/waveshare_amoled_18/board.h @@ -0,0 +1,53 @@ +#pragma once + +// Waveshare ESP32-S3-Touch-AMOLED-1.8 — portrait AMOLED kit. +// 368x448 SH8601 + FT3168 touch + AXP2101 PMU + QMI8658 IMU + XCA9554 expander. +// IMU is present (initialized for I2C bus health) but rotation is disabled +// because the panel mounts in a fixed orientation in the kit's enclosure. + +#define BOARD_NAME "Waveshare AMOLED 1.8" + +// ---- Display geometry (portrait) ---- +#define LCD_WIDTH 368 +#define LCD_HEIGHT 448 + +// ---- QSPI display pins (SH8601) ---- +#define LCD_CS 12 +#define LCD_SCLK 11 // different from 2.16 board (was GPIO 38) +#define LCD_SDIO0 4 +#define LCD_SDIO1 5 +#define LCD_SDIO2 6 +#define LCD_SDIO3 7 +// LCD reset is routed through the XCA9554 IO expander (EXIO1). The Arduino +// GFX driver gets GFX_NOT_DEFINED; the expander releases reset before +// gfx->begin() runs. + +// ---- I2C bus (touch + PMU + IMU + IO expander all share one bus) ---- +#define IIC_SDA 15 +#define IIC_SCL 14 + +// ---- Touch (FT3168 via vendored minimal I2C reader) ---- +#define TP_INT 21 +#define FT3168_ADDR 0x38 + +// ---- PMU ---- +#define AXP2101_ADDR 0x34 + +// ---- IO expander (XCA9554/PCA9554 compatible) ---- +// Gates LCD_RST, TP_RST, audio amp enable, and reads the PWR button. +#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 +#define IOX_PIN_PWR_BTN 4 // EXIO4 → PWR button input, active HIGH + +// ---- Buttons ---- +#define BTN_BACK_GPIO 0 // BOOT — primary, Space (PTT) +// PWR comes via XCA9554 EXIO4 (see power.cpp); there is no secondary button. + +// ---- Capability flags ---- +#define BOARD_HAS_SECONDARY_BUTTON 0 +#define BOARD_HAS_ROTATION 0 +#define BOARD_HAS_IMU 1 // present + initialized, but rotation off +#define BOARD_HAS_BATTERY 1 +#define BOARD_HAS_IO_EXPANDER 1 diff --git a/firmware/src/boards/waveshare_amoled_18/board_init.cpp b/firmware/src/boards/waveshare_amoled_18/board_init.cpp new file mode 100644 index 0000000..a435dff --- /dev/null +++ b/firmware/src/boards/waveshare_amoled_18/board_init.cpp @@ -0,0 +1,11 @@ +#include "board.h" +#include "io_expander.h" +#include +#include + +// AMOLED-1.8 also needs the XCA9554 IO expander up first — the display +// and touch controllers stay in reset until EXIO0..1 go HIGH. +extern "C" void board_init(void) { + Wire.begin(IIC_SDA, IIC_SCL); + io_expander_init(); +} diff --git a/firmware/src/boards/waveshare_amoled_18/caps.cpp b/firmware/src/boards/waveshare_amoled_18/caps.cpp new file mode 100644 index 0000000..4cff9b4 --- /dev/null +++ b/firmware/src/boards/waveshare_amoled_18/caps.cpp @@ -0,0 +1,14 @@ +#include "../../hal/board_caps.h" +#include "board.h" + +static const BoardCaps caps = { + .name = BOARD_NAME, + .width = LCD_WIDTH, + .height = LCD_HEIGHT, + .button_count = 1, + .has_rotation = false, + .has_battery = true, + .has_imu = true, +}; + +const BoardCaps& board_caps(void) { return caps; } diff --git a/firmware/src/boards/waveshare_amoled_18/display.cpp b/firmware/src/boards/waveshare_amoled_18/display.cpp new file mode 100644 index 0000000..7dd3420 --- /dev/null +++ b/firmware/src/boards/waveshare_amoled_18/display.cpp @@ -0,0 +1,54 @@ +#include "../../hal/display_hal.h" +#include "board.h" +#include "io_expander.h" +#include +#include + +// AMOLED-1.8 is fixed at 0°. No CPU rotation, no rot_buf. +// Display reset is routed through the XCA9554 IO expander (EXIO1) which +// must be initialized + released before gfx->begin() runs — main.cpp +// arranges this by calling display_hal_init() after io_expander_init(). + +static Arduino_DataBus* bus = nullptr; +static Arduino_SH8601* gfx = nullptr; + +void display_hal_init(void) { + bus = new Arduino_ESP32QSPI( + LCD_CS, LCD_SCLK, LCD_SDIO0, LCD_SDIO1, LCD_SDIO2, LCD_SDIO3); + // SH8601 constructor: (bus, rst, rotation, w, h) + gfx = new Arduino_SH8601( + bus, GFX_NOT_DEFINED /* reset via XCA9554 */, 0, + LCD_WIDTH, LCD_HEIGHT); +} + +void display_hal_begin(void) { + gfx->begin(); + gfx->fillScreen(0x0000); + gfx->setBrightness(200); +} + +void display_hal_set_brightness(uint8_t level) { + if (gfx) gfx->setBrightness(level); +} + +void display_hal_fill_screen(uint16_t color) { + if (gfx) gfx->fillScreen(color); +} + +void display_hal_draw_bitmap(int32_t x, int32_t y, int32_t w, int32_t h, + const uint16_t* pixels) { + if (gfx) gfx->draw16bitRGBBitmap(x, y, (uint16_t*)pixels, w, h); +} + +void display_hal_tick(void) { + // No rotation handling needed on this board. +} + +// SH8601 driver doesn't strictly require even alignment in source, but the +// rounder is harmless and keeps behavior consistent with the CO5300 port. +void display_hal_round_area(int32_t* x1, int32_t* y1, int32_t* x2, int32_t* y2) { + *x1 = *x1 & ~1; + *y1 = *y1 & ~1; + *x2 = *x2 | 1; + *y2 = *y2 | 1; +} diff --git a/firmware/src/boards/waveshare_amoled_18/imu.cpp b/firmware/src/boards/waveshare_amoled_18/imu.cpp new file mode 100644 index 0000000..f8de856 --- /dev/null +++ b/firmware/src/boards/waveshare_amoled_18/imu.cpp @@ -0,0 +1,25 @@ +#include "../../hal/imu_hal.h" +#include "board.h" +#include +#include +#include + +// AMOLED-1.8 ships with QMI8658 populated, but the kit's enclosure mounts +// the panel in a fixed orientation. We initialize the device anyway so the +// shared I2C bus stays healthy, but always report rotation 0. + +static SensorQMI8658 imu; + +void imu_hal_init(void) { + if (!imu.begin(Wire, QMI8658_L_SLAVE_ADDRESS, IIC_SDA, IIC_SCL)) { + Serial.println("QMI8658 init failed"); + return; + } + Serial.println("QMI8658 init OK (rotation disabled on this board)"); +} + +void imu_hal_tick(void) { + // No-op — rotation is disabled. +} + +uint8_t imu_hal_rotation_quadrant(void) { return 0; } diff --git a/firmware/src/boards/waveshare_amoled_18/input.cpp b/firmware/src/boards/waveshare_amoled_18/input.cpp new file mode 100644 index 0000000..d7325b6 --- /dev/null +++ b/firmware/src/boards/waveshare_amoled_18/input.cpp @@ -0,0 +1,20 @@ +#include "../../hal/input_hal.h" +#include "board.h" +#include + +// AMOLED-1.8 has only the BOOT button as a secondary input — the PWR +// button comes through power_hal (XCA9554 EXIO4). No secondary button. + +void input_hal_init(void) { + pinMode(BTN_BACK_GPIO, INPUT_PULLUP); +} + +bool input_hal_is_held(InputButton btn) { + switch (btn) { + case INPUT_BTN_PRIMARY: + return digitalRead(BTN_BACK_GPIO) == LOW; + case INPUT_BTN_SECONDARY: + return false; // not present on this board + } + return false; +} diff --git a/firmware/src/io_expander.cpp b/firmware/src/boards/waveshare_amoled_18/io_expander.cpp similarity index 74% rename from firmware/src/io_expander.cpp rename to firmware/src/boards/waveshare_amoled_18/io_expander.cpp index 9de72be..15211e2 100644 --- a/firmware/src/io_expander.cpp +++ b/firmware/src/boards/waveshare_amoled_18/io_expander.cpp @@ -1,21 +1,17 @@ #include "io_expander.h" - -#ifdef BOARD_AMOLED_18 - -#include "display_cfg.h" +#include "board.h" #include #include // XCA9554/PCA9554 register map -#define IOX_REG_INPUT 0x00 -#define IOX_REG_OUTPUT 0x01 +#define IOX_REG_INPUT 0x00 +#define IOX_REG_OUTPUT 0x01 #define IOX_REG_POLARITY 0x02 -#define IOX_REG_CONFIG 0x03 // 1 = input, 0 = output +#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 - +#define IOX_CONFIG_MASK 0xF8 // All three outputs HIGH = resets released, amp enabled. #define IOX_OUTPUT_DEFAULT 0x07 @@ -28,7 +24,7 @@ static bool write_reg(uint8_t reg, uint8_t val) { return Wire.endTransmission() == 0; } -static bool read_reg(uint8_t reg, uint8_t &val) { +static bool read_reg(uint8_t reg, uint8_t& val) { Wire.beginTransmission(XCA9554_ADDR); Wire.write(reg); if (Wire.endTransmission(false) != 0) return false; @@ -38,19 +34,18 @@ static bool read_reg(uint8_t reg, uint8_t &val) { } 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. + // 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. + // 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 + delay(20); Serial.println("XCA9554 init OK"); return true; } @@ -68,5 +63,3 @@ bool io_expander_get(uint8_t pin) { if (!read_reg(IOX_REG_INPUT, v)) return false; return (v & (1u << pin)) != 0; } - -#endif // BOARD_AMOLED_18 diff --git a/firmware/src/boards/waveshare_amoled_18/io_expander.h b/firmware/src/boards/waveshare_amoled_18/io_expander.h new file mode 100644 index 0000000..d262054 --- /dev/null +++ b/firmware/src/boards/waveshare_amoled_18/io_expander.h @@ -0,0 +1,12 @@ +#pragma once +#include + +// XCA9554 / PCA9554-compatible 8-bit I2C IO expander. +// Board-private to the AMOLED-1.8 port; not exposed in hal/. +// +// Must be initialized BEFORE the display or touch — skipping the reset +// release leaves the SH8601 and FT3168 in reset and they fail to probe. + +bool io_expander_init(void); +void io_expander_set(uint8_t pin, bool high); +bool io_expander_get(uint8_t pin); diff --git a/firmware/src/boards/waveshare_amoled_18/power.cpp b/firmware/src/boards/waveshare_amoled_18/power.cpp new file mode 100644 index 0000000..5e6a9ed --- /dev/null +++ b/firmware/src/boards/waveshare_amoled_18/power.cpp @@ -0,0 +1,74 @@ +#include "../../hal/power_hal.h" +#include "board.h" +#include "io_expander.h" +#include +#include +#include + +// PWR button comes from XCA9554 EXIO4 (active HIGH). The PMU still +// provides battery monitoring; we just don't subscribe to its PKEY IRQ. + +#define BATTERY_POLL_MS 2000 +#define CHARGING_POLL_MS 500 +#define PWR_POLL_MS 50 + +static XPowersPMU pmu; + +static int cached_pct = -1; +static bool cached_charging = false; +static bool cached_vbus = false; +static bool pwr_pressed_flag = false; +static bool last_pwr_state = false; // edge detector for EXIO4 +static uint32_t last_battery_ms = 0; +static uint32_t last_charging_ms = 0; +static uint32_t last_pwr_ms = 0; + +void power_hal_init(void) { + if (!pmu.begin(Wire, AXP2101_ADDR, IIC_SDA, IIC_SCL)) { + Serial.println("AXP2101 init failed"); + return; + } + Serial.println("AXP2101 init OK"); + + pmu.enableBattDetection(); + pmu.enableBattVoltageMeasure(); + // No PMU IRQ wiring — PWR comes via io_expander_get() below. + + cached_charging = pmu.isCharging(); + cached_vbus = pmu.isVbusIn(); + cached_pct = pmu.getBatteryPercent(); +} + +void power_hal_tick(void) { + uint32_t now = millis(); + + if (now - last_charging_ms >= CHARGING_POLL_MS) { + last_charging_ms = now; + cached_charging = pmu.isCharging(); + cached_vbus = pmu.isVbusIn(); + } + if (now - last_battery_ms >= BATTERY_POLL_MS) { + last_battery_ms = now; + cached_pct = pmu.getBatteryPercent(); + } + if (now - last_pwr_ms >= PWR_POLL_MS) { + last_pwr_ms = now; + 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; + } +} + +int power_hal_battery_pct(void) { return cached_pct; } +bool power_hal_is_charging(void) { return cached_charging; } +bool power_hal_is_vbus_in(void) { return cached_vbus; } + +bool power_hal_pwr_pressed(void) { + if (pwr_pressed_flag) { + pwr_pressed_flag = false; + return true; + } + return false; +} diff --git a/firmware/src/boards/waveshare_amoled_18/touch.cpp b/firmware/src/boards/waveshare_amoled_18/touch.cpp new file mode 100644 index 0000000..95d2ada --- /dev/null +++ b/firmware/src/boards/waveshare_amoled_18/touch.cpp @@ -0,0 +1,70 @@ +#include "../../hal/touch_hal.h" +#include "board.h" +#include +#include + +// 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 volatile bool touch_data_ready = false; +static volatile bool touch_pressed = false; +static volatile uint16_t touch_x = 0; +static volatile uint16_t touch_y = 0; + +static void IRAM_ATTR touch_isr(void) { + touch_data_ready = true; +} + +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; +} + +void touch_hal_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"); + } + + pinMode(TP_INT, INPUT_PULLUP); + attachInterrupt(TP_INT, touch_isr, FALLING); + Serial.println("FT3168 attached on INT pin"); +} + +void touch_hal_read(uint16_t* x, uint16_t* y, bool* pressed) { + if (touch_data_ready) { + touch_data_ready = false; + ft3168_read_into_shared_state(); + } + *x = touch_x; + *y = touch_y; + *pressed = touch_pressed; +} diff --git a/firmware/src/boards/waveshare_amoled_216/board.h b/firmware/src/boards/waveshare_amoled_216/board.h new file mode 100644 index 0000000..2558ea7 --- /dev/null +++ b/firmware/src/boards/waveshare_amoled_216/board.h @@ -0,0 +1,45 @@ +#pragma once + +// Waveshare ESP32-S3-Touch-AMOLED-2.16 — original square AMOLED kit. +// 480x480 CO5300 + CST9220 touch + AXP2101 PMU + QMI8658 IMU. +// IMU-driven CPU rotation is enabled. + +#define BOARD_NAME "Waveshare AMOLED 2.16" + +// ---- Display geometry (matches BoardCaps; duplicated here as compile-time +// constants because the buffer-size math runs at file scope) ---- +#define LCD_WIDTH 480 +#define LCD_HEIGHT 480 + +// ---- QSPI display pins (CO5300) ---- +#define LCD_CS 12 +#define LCD_SCLK 38 +#define LCD_SDIO0 4 +#define LCD_SDIO1 5 +#define LCD_SDIO2 6 +#define LCD_SDIO3 7 +#define LCD_RESET 2 + +// ---- I2C bus (touch + PMU + IMU) ---- +#define IIC_SDA 15 +#define IIC_SCL 14 + +// ---- Touch (CST9220 via TouchDrvCST92xx library) ---- +#define TP_INT 11 +#define TP_RST 2 // shared with LCD_RESET +#define CST9220_ADDR 0x5A + +// ---- PMU ---- +#define AXP2101_ADDR 0x34 + +// ---- Buttons ---- +#define BTN_BACK_GPIO 0 // BOOT — primary, Space (PTT) +#define BTN_FWD_GPIO 18 // secondary, Shift+Tab (mode toggle) + +// ---- Capability flags (compile-time; redundant with BoardCaps but lets +// the linker dead-strip whole functions on boards that don't need them) ---- +#define BOARD_HAS_SECONDARY_BUTTON 1 +#define BOARD_HAS_ROTATION 1 +#define BOARD_HAS_IMU 1 +#define BOARD_HAS_BATTERY 1 +#define BOARD_HAS_IO_EXPANDER 0 diff --git a/firmware/src/boards/waveshare_amoled_216/board_init.cpp b/firmware/src/boards/waveshare_amoled_216/board_init.cpp new file mode 100644 index 0000000..f68d9c0 --- /dev/null +++ b/firmware/src/boards/waveshare_amoled_216/board_init.cpp @@ -0,0 +1,9 @@ +#include "board.h" +#include +#include + +// Bring up the shared I2C bus. AMOLED-2.16 has no IO expander, so this is +// all the early init needed before display/touch/power/imu HAL calls. +extern "C" void board_init(void) { + Wire.begin(IIC_SDA, IIC_SCL); +} diff --git a/firmware/src/boards/waveshare_amoled_216/caps.cpp b/firmware/src/boards/waveshare_amoled_216/caps.cpp new file mode 100644 index 0000000..fd04b69 --- /dev/null +++ b/firmware/src/boards/waveshare_amoled_216/caps.cpp @@ -0,0 +1,14 @@ +#include "../../hal/board_caps.h" +#include "board.h" + +static const BoardCaps caps = { + .name = BOARD_NAME, + .width = LCD_WIDTH, + .height = LCD_HEIGHT, + .button_count = 2, + .has_rotation = true, + .has_battery = true, + .has_imu = true, +}; + +const BoardCaps& board_caps(void) { return caps; } diff --git a/firmware/src/boards/waveshare_amoled_216/display.cpp b/firmware/src/boards/waveshare_amoled_216/display.cpp new file mode 100644 index 0000000..14b8561 --- /dev/null +++ b/firmware/src/boards/waveshare_amoled_216/display.cpp @@ -0,0 +1,134 @@ +#include "../../hal/display_hal.h" +#include "../../hal/imu_hal.h" +#include "board.h" +#include +#include +#include +#include + +// Render strip used when rotating in software. Sized to the largest LVGL +// partial flush we ever do (LCD_WIDTH × BUF_LINES, set in main.cpp). +#define ROT_BUF_LINES 40 +static uint16_t* rot_buf = nullptr; + +static Arduino_DataBus* bus = nullptr; +static Arduino_CO5300* gfx = nullptr; + +void display_hal_init(void) { + bus = new Arduino_ESP32QSPI( + LCD_CS, LCD_SCLK, LCD_SDIO0, LCD_SDIO1, LCD_SDIO2, LCD_SDIO3); + // CO5300 constructor: (bus, rst, rotation, w, h, col_offset1..2, row_offset1..2) + gfx = new Arduino_CO5300( + bus, LCD_RESET, 0 /* rotation handled in software */, + LCD_WIDTH, LCD_HEIGHT, 0, 0, 0, 0); +} + +void display_hal_begin(void) { + gfx->begin(); + gfx->fillScreen(0x0000); + gfx->setBrightness(200); + + // Allocate rotation strip (PSRAM). Sized to match main.cpp's BUF_LINES. + rot_buf = (uint16_t*)heap_caps_malloc(LCD_WIDTH * ROT_BUF_LINES * 2, MALLOC_CAP_SPIRAM); +} + +void display_hal_set_brightness(uint8_t level) { + if (gfx) gfx->setBrightness(level); +} + +void display_hal_fill_screen(uint16_t color) { + if (gfx) gfx->fillScreen(color); +} + +// Rotate a w×h strip into rot_buf and compute destination coordinates on the +// 480×480 panel. Src is row-major over the rectangle (sx, sy, w, h). +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; + + 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++) { + 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++) { + rot_buf[(w - 1 - x) * h + y] = src[y * w + x]; + } + } + break; + default: + *dx = sx; *dy = sy; *dw = w; *dh = h; + break; + } +} + +void display_hal_draw_bitmap(int32_t x, int32_t y, int32_t w, int32_t h, + const uint16_t* pixels) { + if (!gfx) return; + uint8_t r = imu_hal_rotation_quadrant(); + if (r == 0 || !rot_buf) { + gfx->draw16bitRGBBitmap(x, y, (uint16_t*)pixels, w, h); + return; + } + int32_t dx, dy, dw, dh; + rotate_strip(pixels, w, h, x, y, r, &dx, &dy, &dw, &dh); + gfx->draw16bitRGBBitmap(dx, dy, rot_buf, dw, dh); +} + +// On rotation change, 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. +void display_hal_tick(void) { + static uint8_t last_rotation = 0; + static uint8_t ramp_step = 0; // 0=idle, 1..4=ramping + static uint32_t ramp_last = 0; + + uint8_t rot = imu_hal_rotation_quadrant(); + if (rot != last_rotation) { + display_hal_set_brightness(0); + last_rotation = rot; + lv_obj_invalidate(lv_screen_active()); + ramp_step = 1; + return; + } + + if (ramp_step == 0) return; + uint32_t now = millis(); + if (now - ramp_last < 25) return; + ramp_last = now; + + static const uint8_t levels[] = {60, 120, 170, 200}; + display_hal_set_brightness(levels[ramp_step - 1]); + if (ramp_step >= 4) ramp_step = 0; + else ramp_step++; +} + +// CO5300 requires even-aligned flush regions. +void display_hal_round_area(int32_t* x1, int32_t* y1, int32_t* x2, int32_t* y2) { + *x1 = *x1 & ~1; + *y1 = *y1 & ~1; + *x2 = *x2 | 1; + *y2 = *y2 | 1; +} diff --git a/firmware/src/imu.cpp b/firmware/src/boards/waveshare_amoled_216/imu.cpp similarity index 61% rename from firmware/src/imu.cpp rename to firmware/src/boards/waveshare_amoled_216/imu.cpp index fd59341..0ead894 100644 --- a/firmware/src/imu.cpp +++ b/firmware/src/boards/waveshare_amoled_216/imu.cpp @@ -1,54 +1,47 @@ -#include "imu.h" -#include "display_cfg.h" +#include "../../hal/imu_hal.h" +#include "board.h" #include +#include +#include // Poll and hysteresis timing -#define IMU_POLL_MS 100 // read accel at ~10 Hz -#define STABLE_TIME_MS 300 // orientation must be stable this long before rotating -#define TILT_THRESHOLD 0.5f // ~30 degrees from axis (sin(30) ~ 0.5) +#define IMU_POLL_MS 100 // ~10 Hz +#define STABLE_TIME_MS 300 // orientation must hold this long before rotating +#define TILT_THRESHOLD 0.5f // ~30° from axis (sin 30° ≈ 0.5) -static uint8_t current_rotation = 0; +static SensorQMI8658 imu; +static uint8_t current_rotation = 0; static uint8_t candidate_rotation = 0; -static uint32_t candidate_since = 0; -static uint32_t last_poll_ms = 0; -static bool imu_ok = false; +static uint32_t candidate_since = 0; +static uint32_t last_poll_ms = 0; +static bool imu_ok = false; -// Determine target rotation from accelerometer gravity vector. -// Returns 0-3 or 255 if ambiguous (e.g. face-up/face-down). static uint8_t accel_to_rotation(float ax, float ay) { float abs_ax = fabsf(ax); float abs_ay = fabsf(ay); - if (abs_ax < TILT_THRESHOLD && abs_ay < TILT_THRESHOLD) { - return 255; // ambiguous, keep current - } - - if (abs_ay > abs_ax) { - return (ay > 0) ? 3 : 1; - } else { - return (ax > 0) ? 0 : 2; + return 255; // ambiguous (face-up/down) } + if (abs_ay > abs_ax) return (ay > 0) ? 3 : 1; + return (ax > 0) ? 0 : 2; } -void imu_init(void) { +void imu_hal_init(void) { if (!imu.begin(Wire, QMI8658_L_SLAVE_ADDRESS, IIC_SDA, IIC_SCL)) { Serial.println("QMI8658 init failed"); return; } Serial.println("QMI8658 init OK"); - imu.configAccelerometer( SensorQMI8658::ACC_RANGE_4G, SensorQMI8658::ACC_ODR_LOWPOWER_21Hz, SensorQMI8658::LPF_MODE_3); imu.enableAccelerometer(); - imu_ok = true; } -void imu_tick(void) { +void imu_hal_tick(void) { if (!imu_ok) return; - uint32_t now = millis(); if (now - last_poll_ms < IMU_POLL_MS) return; last_poll_ms = now; @@ -61,7 +54,6 @@ void imu_tick(void) { candidate_rotation = current_rotation; return; } - if (target != candidate_rotation) { candidate_rotation = target; candidate_since = now; @@ -71,6 +63,4 @@ void imu_tick(void) { } } -uint8_t imu_get_rotation(void) { - return current_rotation; -} +uint8_t imu_hal_rotation_quadrant(void) { return current_rotation; } diff --git a/firmware/src/boards/waveshare_amoled_216/input.cpp b/firmware/src/boards/waveshare_amoled_216/input.cpp new file mode 100644 index 0000000..af51473 --- /dev/null +++ b/firmware/src/boards/waveshare_amoled_216/input.cpp @@ -0,0 +1,18 @@ +#include "../../hal/input_hal.h" +#include "board.h" +#include + +void input_hal_init(void) { + pinMode(BTN_BACK_GPIO, INPUT_PULLUP); + pinMode(BTN_FWD_GPIO, INPUT_PULLUP); +} + +bool input_hal_is_held(InputButton btn) { + switch (btn) { + case INPUT_BTN_PRIMARY: + return digitalRead(BTN_BACK_GPIO) == LOW; + case INPUT_BTN_SECONDARY: + return digitalRead(BTN_FWD_GPIO) == LOW; + } + return false; +} diff --git a/firmware/src/power.cpp b/firmware/src/boards/waveshare_amoled_216/power.cpp similarity index 52% rename from firmware/src/power.cpp rename to firmware/src/boards/waveshare_amoled_216/power.cpp index 3896647..5533263 100644 --- a/firmware/src/power.cpp +++ b/firmware/src/boards/waveshare_amoled_216/power.cpp @@ -1,29 +1,26 @@ -#include "power.h" -#include "display_cfg.h" +#include "../../hal/power_hal.h" +#include "board.h" #include +#include +#include -#ifdef BOARD_AMOLED_18 -#include "io_expander.h" -#endif +// PWR button comes from AXP2101 PKEY short-press IRQ. -// Poll intervals -#define BATTERY_POLL_MS 2000 -#define CHARGING_POLL_MS 500 +#define BATTERY_POLL_MS 2000 +#define CHARGING_POLL_MS 500 +#define PWR_POLL_MS 50 -static int cached_pct = -1; -static bool cached_charging = false; -static bool cached_vbus = false; +static XPowersPMU pmu; + +static int cached_pct = -1; +static bool cached_charging = false; +static bool cached_vbus = false; static bool pwr_pressed_flag = false; static uint32_t last_battery_ms = 0; 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) { +void power_hal_init(void) { if (!pmu.begin(Wire, AXP2101_ADDR, IIC_SDA, IIC_SCL)) { Serial.println("AXP2101 init failed"); return; @@ -33,21 +30,16 @@ void power_init(void) { pmu.enableBattDetection(); pmu.enableBattVoltageMeasure(); -#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(); cached_pct = pmu.getBatteryPercent(); } -void power_tick(void) { +void power_hal_tick(void) { uint32_t now = millis(); if (now - last_charging_ms >= CHARGING_POLL_MS) { @@ -55,45 +47,25 @@ void power_tick(void) { cached_charging = pmu.isCharging(); cached_vbus = pmu.isVbusIn(); } - if (now - last_battery_ms >= BATTERY_POLL_MS) { last_battery_ms = now; cached_pct = pmu.getBatteryPercent(); } - - // 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 } } -int power_battery_pct(void) { - return cached_pct; -} +int power_hal_battery_pct(void) { return cached_pct; } +bool power_hal_is_charging(void) { return cached_charging; } +bool power_hal_is_vbus_in(void) { return cached_vbus; } -bool power_is_charging(void) { - return cached_charging; -} - -bool power_is_vbus_in(void) { - return cached_vbus; -} - -bool power_pwr_pressed(void) { +bool power_hal_pwr_pressed(void) { if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; diff --git a/firmware/src/boards/waveshare_amoled_216/touch.cpp b/firmware/src/boards/waveshare_amoled_216/touch.cpp new file mode 100644 index 0000000..2538536 --- /dev/null +++ b/firmware/src/boards/waveshare_amoled_216/touch.cpp @@ -0,0 +1,48 @@ +#include "../../hal/touch_hal.h" +#include "board.h" +#include +#include +#include + +static TouchDrvCST92xx touch; + +static volatile bool touch_data_ready = false; +static volatile bool touch_pressed = false; +static volatile uint16_t touch_x = 0; +static volatile uint16_t touch_y = 0; + +static void IRAM_ATTR touch_isr(void) { + touch_data_ready = true; +} + +void touch_hal_init(void) { + touch.setPins(TP_RST, TP_INT); + if (!touch.begin(Wire, CST9220_ADDR, IIC_SDA, IIC_SCL)) { + Serial.println("Touch init failed"); + return; + } + touch.setMaxCoordinates(LCD_WIDTH, LCD_HEIGHT); + touch.setSwapXY(true); + touch.setMirrorXY(true, false); + pinMode(TP_INT, INPUT_PULLUP); + attachInterrupt(TP_INT, touch_isr, FALLING); + Serial.println("Touch init OK"); +} + +void touch_hal_read(uint16_t* x, uint16_t* y, bool* pressed) { + if (touch_data_ready) { + 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; + } + } + *x = touch_x; + *y = touch_y; + *pressed = touch_pressed; +} diff --git a/firmware/src/display_cfg.h b/firmware/src/display_cfg.h deleted file mode 100644 index c62df73..0000000 --- a/firmware/src/display_cfg.h +++ /dev/null @@ -1,94 +0,0 @@ -#pragma once - -#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 - -// ---- QSPI display pins (CO5300) ---- -#define LCD_CS 12 -#define LCD_SCLK 38 -#define LCD_SDIO0 4 -#define LCD_SDIO1 5 -#define LCD_SDIO2 6 -#define LCD_SDIO3 7 -#define LCD_RESET 2 - -// ---- Touch pins (CST9220 via I2C) ---- -#define IIC_SDA 15 -#define IIC_SCL 14 -#define TP_INT 11 -#define TP_RST 2 // shared with LCD_RESET -#define CST9220_ADDR 0x5A - -// ---- 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 PlatformDisplay *gfx; -extern TouchDrvCST92xx touch; -extern XPowersPMU pmu; -extern SensorQMI8658 imu; - -#endif // BOARD_AMOLED_* diff --git a/firmware/src/hal/board_caps.h b/firmware/src/hal/board_caps.h new file mode 100644 index 0000000..fca9e24 --- /dev/null +++ b/firmware/src/hal/board_caps.h @@ -0,0 +1,23 @@ +#pragma once +#include + +// Runtime board description consumed by board-agnostic code (UI, main loop). +// Each board provides a single BoardCaps instance via board_caps(). +// +// Compile-time-only facts (pin numbers, library choice) belong in +// boards//board.h and never leak into shared code. Anything the UI or +// main loop needs at runtime — display size, optional-feature presence — +// goes here so shared code stays free of #ifdef BOARD_*. +struct BoardCaps { + const char* name; // human-readable, e.g. "Waveshare AMOLED 2.16" + + int16_t width; // active display width in pixels + int16_t height; // active display height in pixels + + uint8_t button_count; // 1 = primary (BOOT) only; 2 = primary + secondary + bool has_rotation; // IMU-driven CPU rotation in the flush callback + bool has_battery; // AXP2101 battery measurement is meaningful + bool has_imu; // QMI8658 (or compatible) is populated +}; + +const BoardCaps& board_caps(void); diff --git a/firmware/src/hal/display_hal.h b/firmware/src/hal/display_hal.h new file mode 100644 index 0000000..6549484 --- /dev/null +++ b/firmware/src/hal/display_hal.h @@ -0,0 +1,32 @@ +#pragma once +#include + +// Display abstraction. The board provides the QSPI bus, panel driver, and any +// CPU-side rotation. Shared code (main.cpp, LVGL glue) never sees the GFX +// driver type. Dimensions are not declared here — query board_caps(). + +// Construct bus + driver objects. Safe to call before display_hal_begin(). +// On boards with an IO expander gating the LCD reset, the board's +// implementation is responsible for ensuring the expander has released the +// reset before talking to the panel. +void display_hal_init(void); + +// Bring the panel out of reset, clear it, and apply default brightness. +void display_hal_begin(void); + +void display_hal_set_brightness(uint8_t level); // 0..255 (driver-defined scale) +void display_hal_fill_screen(uint16_t color565); + +// Write a w×h RGB565 bitmap at (x, y). Boards with software rotation +// (e.g. CO5300) transform (x, y, w, h) and the pixel buffer here before +// pushing to the panel. Shared LVGL flush_cb just calls this — no #ifdef. +void display_hal_draw_bitmap(int32_t x, int32_t y, int32_t w, int32_t h, + const uint16_t* pixels); + +// Per-loop housekeeping for rotation-aware boards: detects orientation +// changes from the IMU, blanks the panel, invalidates LVGL, and ramps +// brightness back up. No-op on boards without rotation. +void display_hal_tick(void); + +// LVGL flush regions must be even-aligned on the CO5300; harmless on others. +void display_hal_round_area(int32_t* x1, int32_t* y1, int32_t* x2, int32_t* y2); diff --git a/firmware/src/hal/imu_hal.h b/firmware/src/hal/imu_hal.h new file mode 100644 index 0000000..08fd90f --- /dev/null +++ b/firmware/src/hal/imu_hal.h @@ -0,0 +1,11 @@ +#pragma once +#include + +// Optional accelerometer-driven orientation tracker. Returns 0..3 (quarter +// turns CW from default mounting). Boards without an IMU — or boards with +// rotation intentionally disabled, like AMOLED-1.8 fixed at 0° — return 0 +// from imu_hal_rotation_quadrant() and no-op on init/tick. + +void imu_hal_init(void); +void imu_hal_tick(void); +uint8_t imu_hal_rotation_quadrant(void); diff --git a/firmware/src/hal/input_hal.h b/firmware/src/hal/input_hal.h new file mode 100644 index 0000000..18c94d7 --- /dev/null +++ b/firmware/src/hal/input_hal.h @@ -0,0 +1,26 @@ +#pragma once +#include + +// Physical button abstraction. Boards report up to two screen-independent +// buttons: +// PRIMARY — left button on this project's boards (BOOT / GPIO 0). +// Drives the Claude Code voice-mode PTT (HID Space). +// SECONDARY — right button on boards that have one (e.g. GPIO 18 on +// AMOLED-2.16). Drives mode-toggle (HID Shift+Tab). Boards +// without it report held=false forever and shared code +// handles that gracefully via BoardCaps.button_count. +// +// The PWR button is owned by power_hal (it's tied to the PMU on some boards +// and to an IO expander on others — see power_hal_pwr_pressed()). + +enum InputButton { + INPUT_BTN_PRIMARY = 0, + INPUT_BTN_SECONDARY = 1, +}; + +void input_hal_init(void); + +// True while the button is physically held (active-low GPIOs are +// de-bounced at the caller's expense — the existing code polls every +// loop iteration). Boards lacking a button always return false. +bool input_hal_is_held(InputButton btn); diff --git a/firmware/src/hal/power_hal.h b/firmware/src/hal/power_hal.h new file mode 100644 index 0000000..05b08d8 --- /dev/null +++ b/firmware/src/hal/power_hal.h @@ -0,0 +1,19 @@ +#pragma once + +// Power / battery / power-button abstraction. Replaces the legacy power.h +// API but keeps the same shape so existing call sites stay clean. +// +// Some boards (AMOLED-2.16) wire PWR through the PMU's PKEY IRQ; others +// (AMOLED-1.8) route it through an IO expander. The HAL hides which +// source produced the press — shared code just polls +// power_hal_pwr_pressed() once per loop. + +void power_hal_init(void); +void power_hal_tick(void); + +int power_hal_battery_pct(void); // 0..100, or -1 if no battery (see BoardCaps.has_battery) +bool power_hal_is_charging(void); +bool power_hal_is_vbus_in(void); // USB cable present (true even without a battery) + +// Edge-triggered: returns true once per PWR short-press, then clears. +bool power_hal_pwr_pressed(void); diff --git a/firmware/src/hal/touch_hal.h b/firmware/src/hal/touch_hal.h new file mode 100644 index 0000000..ee9149f --- /dev/null +++ b/firmware/src/hal/touch_hal.h @@ -0,0 +1,17 @@ +#pragma once +#include + +// Touch abstraction. The board owns the touch controller driver and the +// TP_INT pin wiring. The HAL implementation is responsible for keeping its +// own internal "latest sample" state — shared code calls touch_hal_read() +// once per loop and feeds it into LVGL. +// +// Implementations should complete touch_hal_read() in well under 5 ms (a +// single I2C burst). LVGL polls this at the screen refresh rate. + +void touch_hal_init(void); + +// Pump the controller and return the latest sample. *pressed reflects +// whether any finger is currently down; coordinates are valid only when +// pressed is true and are in display (post-orientation) coordinates. +void touch_hal_read(uint16_t* x, uint16_t* y, bool* pressed); diff --git a/firmware/src/idle.cpp b/firmware/src/idle.cpp index ad7349f..9552e67 100644 --- a/firmware/src/idle.cpp +++ b/firmware/src/idle.cpp @@ -1,8 +1,8 @@ #include #include "idle.h" #include "idle_cfg.h" -#include "display_cfg.h" // declares `extern Arduino_CO5300 *gfx;` -#include "power.h" +#include "hal/display_hal.h" +#include "hal/power_hal.h" enum IdleState { STATE_AWAKE, @@ -19,7 +19,7 @@ static uint8_t fade_from = DISPLAY_DEFAULT_BRIGHTNESS; static uint8_t fade_to = 0; static void apply_brightness(uint8_t b) { - gfx->setBrightness(b); + display_hal_set_brightness(b); } static void begin_fade(uint8_t to, uint32_t now) { @@ -72,7 +72,7 @@ void idle_tick(void) { // While on USB power (if configured), don't sleep — and wake from sleep // when power comes back. Treats USB-in as continuous activity. - if (!IDLE_SLEEP_WHEN_CHARGING && power_is_vbus_in()) { + if (!IDLE_SLEEP_WHEN_CHARGING && power_hal_is_vbus_in()) { last_activity_ms = now; if (state == STATE_ASLEEP || state == STATE_FADING_OUT) { begin_fade(DISPLAY_DEFAULT_BRIGHTNESS, now); diff --git a/firmware/src/imu.h b/firmware/src/imu.h deleted file mode 100644 index 2c0c87e..0000000 --- a/firmware/src/imu.h +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once -#include - -void imu_init(void); -void imu_tick(void); // call from loop(), handles auto-rotation -uint8_t imu_get_rotation(void); // current rotation 0-3 diff --git a/firmware/src/io_expander.h b/firmware/src/io_expander.h deleted file mode 100644 index 51a19be..0000000 --- a/firmware/src/io_expander.h +++ /dev/null @@ -1,19 +0,0 @@ -#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 ecb48bd..3d0fae5 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -2,267 +2,91 @@ #include #include #include -#include "display_cfg.h" +#include + #include "data.h" #include "ui.h" #include "ble.h" -#include "power.h" -#include "imu.h" #include "splash.h" #include "usage_rate.h" #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, 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); -#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; +#include "hal/board_caps.h" +#include "hal/display_hal.h" +#include "hal/touch_hal.h" +#include "hal/input_hal.h" +#include "hal/power_hal.h" +#include "hal/imu_hal.h" 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; -static volatile bool touch_data_ready = false; - -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) { - touch_pressed = true; - touch_x = (uint16_t)tx[0]; - touch_y = (uint16_t)ty[0]; - } 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 - // touch is swallowed (mirrors the button wake-consumption); a - // press while awake counts as activity. - // false → touch never counts as activity and is fully swallowed while - // the panel is dark, so pets/sleeves can't wake it overnight - // and LVGL can't quietly toggle splash<->usage on a black panel. - if (IDLE_WAKE_ON_TOUCH) { - static bool touch_was = false; - static bool touch_wake_swallowed = false; - bool touch_now = touch_pressed; - if (touch_now && !touch_was) { - if (idle_consume_wake_press()) { - touch_wake_swallowed = true; - touch_pressed = false; // hide this press from LVGL - } - } else if (!touch_now && touch_was) { - if (touch_wake_swallowed) { - touch_wake_swallowed = false; - touch_pressed = false; // also hide the corresponding release - } - } else if (touch_now && touch_wake_swallowed) { - // Held finger through wake — keep hiding until release. - touch_pressed = false; - } - touch_was = touch_now; - } else { - if (idle_is_asleep()) touch_pressed = false; - } -} - -// ---- LVGL draw buffers (PSRAM-backed, partial render) ---- +// ---- LVGL draw buffers (PSRAM, partial render mode) ---- #define BUF_LINES 40 -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; +static uint16_t* buf1 = nullptr; +static uint16_t* buf2 = nullptr; -// LVGL tick callback -static uint32_t my_tick(void) { - return millis(); -} +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) { - 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; - } -} -#endif // !BOARD_AMOLED_18 - -// 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; - -#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 { - 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); - } -#endif + display_hal_draw_bitmap(area->x1, area->y1, w, h, (uint16_t*)px_map); lv_display_flush_ready(disp); } -// 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; - area->y1 = area->y1 & ~1; - area->x2 = area->x2 | 1; - area->y2 = area->y2 | 1; + lv_area_t* area = (lv_area_t*)lv_event_get_param(e); + display_hal_round_area(&area->x1, &area->y1, &area->x2, &area->y2); } -// LVGL touch callback +// Touch policy is driven by IDLE_WAKE_ON_TOUCH: +// true → a press edge while asleep wakes the device and the first touch is +// swallowed (mirrors the button wake-consumption); a press while +// awake counts as activity. +// false → touch never counts as activity and is fully swallowed while the +// panel is dark, so pets/sleeves can't wake it overnight and LVGL +// can't quietly toggle splash<->usage on a black panel. static void my_touch_cb(lv_indev_t* indev, lv_indev_data_t* data) { - if (touch_pressed) { - data->point.x = touch_x; - data->point.y = touch_y; + uint16_t x, y; + bool pressed; + touch_hal_read(&x, &y, &pressed); + const bool raw_pressed = pressed; + + if (IDLE_WAKE_ON_TOUCH) { + static bool touch_was = false; + static bool touch_wake_swallowed = false; + if (raw_pressed && !touch_was) { + // Press edge — consume as wake if asleep. + if (idle_consume_wake_press()) { + touch_wake_swallowed = true; + pressed = false; + } + } else if (!raw_pressed && touch_was) { + // Release edge. + if (touch_wake_swallowed) { + touch_wake_swallowed = false; + pressed = false; + } + } else if (raw_pressed && touch_wake_swallowed) { + // Held finger through wake — keep hiding until release. + pressed = false; + } + touch_was = raw_pressed; + } else if (idle_is_asleep()) { + pressed = false; + } + + if (pressed) { + data->point.x = x; + data->point.y = y; data->state = LV_INDEV_STATE_PRESSED; } else { data->state = LV_INDEV_STATE_RELEASED; } } -// Parse a JSON line into UsageData +// Parse a JSON line into UsageData. static bool parse_json(const char* json, UsageData* out) { JsonDocument doc; DeserializationError err = deserializeJson(doc, json); @@ -281,13 +105,14 @@ static bool parse_json(const char* json, UsageData* out) { return true; } -// Serial command buffer +// ---- Serial command buffer ---- #define CMD_BUF_SIZE 64 static char cmd_buf[CMD_BUF_SIZE]; static int cmd_pos = 0; static void send_screenshot() { - const uint32_t w = LCD_WIDTH, h = LCD_HEIGHT; + const uint32_t w = board_caps().width; + const uint32_t h = board_caps().height; const uint32_t row_bytes = w * 2; const uint32_t buf_size = row_bytes * h; uint8_t* sbuf = (uint8_t*)heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM); @@ -306,13 +131,13 @@ static void send_screenshot() { return; } - Serial.printf("SCREENSHOT_START %lu %lu %lu\n", (unsigned long)w, (unsigned long)h, (unsigned long)buf_size); + Serial.printf("SCREENSHOT_START %lu %lu %lu\n", + (unsigned long)w, (unsigned long)h, (unsigned long)buf_size); Serial.flush(); Serial.write(sbuf, buf_size); Serial.flush(); Serial.println(); Serial.println("SCREENSHOT_END"); - heap_caps_free(sbuf); } @@ -321,9 +146,7 @@ static void check_serial_cmd() { char c = Serial.read(); if (c == '\n' || c == '\r') { cmd_buf[cmd_pos] = '\0'; - if (strcmp(cmd_buf, "screenshot") == 0) { - send_screenshot(); - } + if (strcmp(cmd_buf, "screenshot") == 0) send_screenshot(); cmd_pos = 0; } else if (cmd_pos < CMD_BUF_SIZE - 1) { cmd_buf[cmd_pos++] = c; @@ -331,198 +154,115 @@ static void check_serial_cmd() { } } +// Each board provides this. Must bring up the shared I2C bus (Wire.begin +// with the board's SDA/SCL pins) and any board-private hardware that has +// to settle before display/touch (e.g. an IO expander gating the LCD +// reset line). Called exactly once at the start of setup(). +extern "C" void board_init(void); + void setup() { Serial.begin(115200); delay(300); Serial.println("{\"ready\":true}"); - // Init I2C (shared by touch + PMU + IMU + IO expander) - Wire.begin(IIC_SDA, IIC_SCL); + board_init(); -#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 + display_hal_init(); + display_hal_begin(); + idle_init(); // takes over brightness (DISPLAY_DEFAULT_BRIGHTNESS) and starts the idle timer - // Init display - gfx->begin(); - gfx->fillScreen(0x0000); - idle_init(); // sets brightness to DISPLAY_DEFAULT_BRIGHTNESS and starts idle timer + power_hal_init(); + imu_hal_init(); + touch_hal_init(); - // Init PMU - power_init(); + // ---- LVGL ---- + const int W = board_caps().width; + const int H = board_caps().height; - // 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"); - } 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"); - } -#endif - - // Init LVGL lv_init(); lv_tick_set_cb(my_tick); - // 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); -#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 + buf1 = (uint16_t*)heap_caps_malloc(W * BUF_LINES * 2, MALLOC_CAP_SPIRAM); + buf2 = (uint16_t*)heap_caps_malloc(W * BUF_LINES * 2, MALLOC_CAP_SPIRAM); - lv_display_t* disp = lv_display_create(LCD_WIDTH, LCD_HEIGHT); + lv_display_t* disp = lv_display_create(W, H); 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, LCD_WIDTH * BUF_LINES * 2, + lv_display_set_buffers(disp, buf1, buf2, W * 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 BLE data channel ble_init(); + input_hal_init(); - // Physical buttons - pinMode(BTN_BACK, INPUT_PULLUP); -#ifndef BOARD_AMOLED_18 - pinMode(BTN_FWD, INPUT_PULLUP); -#endif - - // 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()); - + ui_update_battery(power_hal_battery_pct(), power_hal_is_charging()); ui_show_screen(SCREEN_SPLASH); - Serial.println("Dashboard ready, waiting for data on BLE..."); + Serial.printf("Dashboard ready (%s, %dx%d), waiting for data on BLE...\n", + board_caps().name, W, H); } static ble_state_t last_ble_state = BLE_STATE_INIT; -#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. -static void handle_rotation_change(void) { - static uint8_t last_rotation = 0; - static uint8_t ramp_step = 0; // 0=idle, 1-4=ramping - static uint32_t ramp_last = 0; - - // While asleep the rotation visual transition (blank + ramp) would fight - // the idle fade. Defer: a rotation that happens during sleep will be - // detected after wake and ramped in then. - if (idle_is_asleep()) return; - - uint8_t rot = imu_get_rotation(); - if (rot != last_rotation) { - gfx->setBrightness(0); - last_rotation = rot; - lv_obj_invalidate(lv_screen_active()); - ramp_step = 1; - return; - } - - if (ramp_step == 0) return; - uint32_t now = millis(); - if (now - ramp_last < 25) return; - ramp_last = now; - - static const uint8_t levels[] = {60, 120, 170, DISPLAY_DEFAULT_BRIGHTNESS}; - gfx->setBrightness(levels[ramp_step - 1]); - if (ramp_step >= 4) ramp_step = 0; - else ramp_step++; -} -#endif // !BOARD_AMOLED_18 - void loop() { - touch_read(); idle_tick(); lv_timer_handler(); ui_tick_anim(); ble_tick(); - power_tick(); - imu_tick(); + power_hal_tick(); + imu_hal_tick(); splash_tick(); + // Rotation transition (blank + ramp) would fight the idle fade — skip + // ticks while the panel is dark. A rotation that happens during sleep + // is detected by the next tick after wake and ramped in then. + if (!idle_is_asleep()) display_hal_tick(); - // 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. + // ---- Physical buttons ---- + // PRIMARY → HID Space (Claude Code voice-mode PTT) + // SECONDARY → HID Shift+Tab (mode toggle; only if the board has one) + // PWR → cycle screens; on splash, cycle animations + // First press from sleep is consumed as a wake-only event by + // idle_consume_wake_press(); the normal action 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; - 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); - if (back_now != back_was) { - if (back_now) { - if (idle_consume_wake_press()) { - back_wake_swallowed = true; - } else { - ble_keyboard_press(0x2C, 0); // HID Space, no mods - } + static bool primary_was = false; + static bool primary_wake_swallowed = false; + bool primary_now = input_hal_is_held(INPUT_BTN_PRIMARY); + if (primary_now != primary_was) { + if (primary_now) { + if (idle_consume_wake_press()) primary_wake_swallowed = true; + else ble_keyboard_press(0x2C, 0); // HID Space, no mods } else { - if (back_wake_swallowed) back_wake_swallowed = false; - else ble_keyboard_release(); + if (primary_wake_swallowed) primary_wake_swallowed = false; + else ble_keyboard_release(); } - back_was = back_now; + primary_was = primary_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()) { - fwd_wake_swallowed = true; + if (board_caps().button_count >= 2) { + static bool secondary_was = false; + static bool secondary_wake_swallowed = false; + bool secondary_now = input_hal_is_held(INPUT_BTN_SECONDARY); + if (secondary_now != secondary_was) { + if (secondary_now) { + if (idle_consume_wake_press()) secondary_wake_swallowed = true; + else ble_keyboard_press(0x2B, 0x02); // HID Tab + LEFT_SHIFT } else { - ble_keyboard_press(0x2B, 0x02); // HID Tab + LEFT_SHIFT + if (secondary_wake_swallowed) secondary_wake_swallowed = false; + else ble_keyboard_release(); } - } else { - if (fwd_wake_swallowed) fwd_wake_swallowed = false; - else ble_keyboard_release(); + secondary_was = secondary_now; } - fwd_was = fwd_now; } -#endif - if (power_pwr_pressed()) { + if (power_hal_pwr_pressed()) { if (!idle_consume_wake_press()) { if (ui_get_current_screen() == SCREEN_SPLASH) splash_next(); else ui_cycle_screen(); @@ -530,32 +270,24 @@ 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(); if (bs != last_ble_state) { last_ble_state = bs; ui_update_ble_status(bs, ble_get_device_name(), ble_get_mac_address()); } - // Update battery indicator - static int last_pct = -2; + static int last_pct = -2; static bool last_charging = false; - int pct = power_battery_pct(); - bool charging = power_is_charging(); + int pct = power_hal_battery_pct(); + bool charging = power_hal_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(); - // Process incoming BLE data if (ble_has_data()) { if (parse_json(ble_get_data(), &usage)) { int g_before = usage_rate_group(); diff --git a/firmware/src/power.h b/firmware/src/power.h deleted file mode 100644 index 5008c6c..0000000 --- a/firmware/src/power.h +++ /dev/null @@ -1,8 +0,0 @@ -#pragma once - -void power_init(void); -void power_tick(void); -int power_battery_pct(void); // 0-100, or -1 if no battery -bool power_is_charging(void); -bool power_is_vbus_in(void); // USB cable present (true even with no battery) -bool power_pwr_pressed(void); // true once per AXP2101 PWR button short-press diff --git a/firmware/src/splash.cpp b/firmware/src/splash.cpp index 83b1d5c..ad42463 100644 --- a/firmware/src/splash.cpp +++ b/firmware/src/splash.cpp @@ -2,23 +2,18 @@ #include "splash_animations.h" #include "theme.h" #include "usage_rate.h" -#include "display_cfg.h" +#include "hal/board_caps.h" #include #include #include -// 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 +// 20×20 grid. CELL sized so the canvas fits the smaller display dimension — +// the canvas is square and centered, so on portrait or letterboxed panels +// it leaves vertical margin rather than cropping. #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) +static int cell = 24; // recomputed in splash_init() +static int canvas_w = GRID * 24; +static int canvas_h = GRID * 24; // Background fallback when palette is missing #define COL_EMPTY 0x0000 // true black (matches THEME_BG) @@ -76,17 +71,19 @@ static void resolve_group_lists(void) { } } +static uint16_t *row_buf = NULL; // scratch row, sized to canvas_w + static void render_frame(const uint8_t *cells, const uint16_t *palette) { + if (!row_buf || !canvas_buf) return; for (int gy = 0; gy < GRID; gy++) { - uint16_t row[CANVAS_W]; for (int gx = 0; gx < GRID; gx++) { uint8_t code = cells[gy * GRID + gx]; uint16_t color = (palette && code < SPLASH_PALETTE_SIZE) ? palette[code] : COL_EMPTY; - uint16_t *p = &row[gx * CELL]; - for (int i = 0; i < CELL; i++) p[i] = color; + uint16_t *p = &row_buf[gx * cell]; + for (int i = 0; i < cell; i++) p[i] = color; } - for (int dy = 0; dy < CELL; dy++) { - memcpy(&canvas_buf[(gy * CELL + dy) * CANVAS_W], row, CANVAS_W * 2); + for (int dy = 0; dy < cell; dy++) { + memcpy(&canvas_buf[(gy * cell + dy) * canvas_w], row_buf, canvas_w * 2); } } if (canvas) lv_obj_invalidate(canvas); @@ -94,20 +91,30 @@ static void render_frame(const uint8_t *cells, const uint16_t *palette) { static void show_placeholder() { // Solid dark background + centered status label. - for (int i = 0; i < CANVAS_W * CANVAS_H; i++) canvas_buf[i] = COL_EMPTY; + if (canvas_buf) { + for (int i = 0; i < canvas_w * canvas_h; i++) canvas_buf[i] = COL_EMPTY; + } if (canvas) lv_obj_invalidate(canvas); if (label_status) lv_obj_clear_flag(label_status, LV_OBJ_FLAG_HIDDEN); } void splash_init(lv_obj_t *parent) { - canvas_buf = (uint16_t*)heap_caps_malloc(CANVAS_W * CANVAS_H * 2, MALLOC_CAP_SPIRAM); - if (!canvas_buf) { + const BoardCaps& c = board_caps(); + int min_dim = (c.width < c.height) ? c.width : c.height; + cell = min_dim / GRID; // fits within the smaller display dimension + if (cell < 4) cell = 4; + canvas_w = GRID * cell; + canvas_h = GRID * cell; + + canvas_buf = (uint16_t*)heap_caps_malloc(canvas_w * canvas_h * 2, MALLOC_CAP_SPIRAM); + row_buf = (uint16_t*)heap_caps_malloc(canvas_w * 2, MALLOC_CAP_SPIRAM); + if (!canvas_buf || !row_buf) { Serial.println("splash: failed to alloc canvas buffer"); return; } splash_container = lv_obj_create(parent); - lv_obj_set_size(splash_container, LCD_WIDTH, LCD_HEIGHT); + lv_obj_set_size(splash_container, c.width, c.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); @@ -116,7 +123,7 @@ void splash_init(lv_obj_t *parent) { lv_obj_clear_flag(splash_container, LV_OBJ_FLAG_SCROLLABLE); canvas = lv_canvas_create(splash_container); - lv_canvas_set_buffer(canvas, canvas_buf, CANVAS_W, CANVAS_H, LV_COLOR_FORMAT_RGB565); + lv_canvas_set_buffer(canvas, canvas_buf, canvas_w, canvas_h, LV_COLOR_FORMAT_RGB565); lv_obj_center(canvas); // Placeholder label (visible only when no animations are loaded) diff --git a/firmware/src/ui.cpp b/firmware/src/ui.cpp index 585afd4..6e12188 100644 --- a/firmware/src/ui.cpp +++ b/firmware/src/ui.cpp @@ -3,7 +3,7 @@ #include #include "logo.h" #include "icons.h" -#include "display_cfg.h" +#include "hal/board_caps.h" // Custom fonts (scaled for 314 PPI, ~1.9x from original 165 PPI) LV_FONT_DECLARE(font_tiempos_56); @@ -16,21 +16,76 @@ 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 +// Layout values computed from the active board's geometry. Populated once +// in ui_init() and treated as const for the rest of the program. Adding a +// new display size means extending compute_layout() with another +// breakpoint — never editing the screen-builder functions below. +struct Layout { + int16_t scr_w, scr_h; + int16_t margin; + int16_t title_y; + int16_t content_y; + int16_t content_w; + + // Usage screen + int16_t usage_panel_h; + int16_t usage_panel_gap; + int16_t usage_bar_y; + int16_t usage_reset_y; + + // Bluetooth screen + int16_t bt_info_panel_h; + int16_t bt_reset_zone_h; + const lv_font_t* bt_title_font; + const lv_font_t* bt_status_font; + const lv_font_t* bt_device_font; + const lv_font_t* bt_credit_1_font; + const lv_font_t* bt_credit_2_font; +}; +static Layout L = {}; + +// Pick layout values from the active board's pixel dimensions. The two +// existing boards happen to land on the two breakpoints below; new ports +// inherit the closer one — visually OK, may need a polish pass for +// pixel-perfect alignment but never blocks the port from booting. +static void compute_layout(const BoardCaps& c) { + L.scr_w = c.width; + L.scr_h = c.height; + L.margin = 20; + L.title_y = 30; + + if (c.height >= 460) { + // Large layout — tuned for 480x480 (AMOLED-2.16). + L.content_y = 100; + L.usage_panel_h = 150; + L.usage_panel_gap = 16; + L.usage_bar_y = 56; + L.usage_reset_y = 94; + L.bt_info_panel_h = 160; + L.bt_reset_zone_h = 110; + L.bt_title_font = &font_tiempos_56; + L.bt_status_font = &font_styrene_48; + L.bt_device_font = &font_styrene_28; + L.bt_credit_1_font = &font_styrene_24; + L.bt_credit_2_font = &font_styrene_20; + } else { + // Compact layout — tuned for 368x448 (AMOLED-1.8). + L.content_y = 85; + L.usage_panel_h = 130; + L.usage_panel_gap = 12; + L.usage_bar_y = 48; + L.usage_reset_y = 78; + L.bt_info_panel_h = 140; + L.bt_reset_zone_h = 90; + L.bt_title_font = &font_tiempos_34; + L.bt_status_font = &font_styrene_28; + L.bt_device_font = &font_styrene_20; + L.bt_credit_1_font = &font_styrene_16; + L.bt_credit_2_font = &font_styrene_14; + } + + L.content_w = L.scr_w - 2 * L.margin; +} // Anthropic brand palette — design tokens live in theme.h #include "theme.h" @@ -44,20 +99,6 @@ LV_FONT_DECLARE(font_mono_32); #define COL_RED THEME_RED #define COL_BAR_BG THEME_BAR_BG -// ---- 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 -#endif -#define CONTENT_W (SCR_W - 2 * MARGIN) - // ---- Usage screen widgets ---- static lv_obj_t* usage_container; static lv_obj_t* lbl_title; @@ -101,9 +142,6 @@ static const char* const spinner_frames[] = { #define SPINNER_COUNT 6 #define SPINNER_PHASES (2 * (SPINNER_COUNT - 1)) // 10: ping-pong 0..5..0 -// Per-frame hold time. Modeled on Claude Code's spinner (Cavalry triangle -// oscillator, range 0..5, period 5s) — turn-around frames (0 and 5) appear -// once per cycle, middle frames twice, so 0/5 read as held longer. static const uint16_t spinner_ms[SPINNER_COUNT] = { 260, 130, 130, 130, 130, 260, }; @@ -178,8 +216,6 @@ static lv_obj_t* make_panel(lv_obj_t* parent, int x, int y, int w, int h) { lv_obj_set_style_pad_top(panel, 12, 0); lv_obj_set_style_pad_bottom(panel, 12, 0); lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE); - // Bubble click events up to the screen / usage_container so a tap anywhere - // on the panel fires the global click handler. lv_obj_add_flag(panel, LV_OBJ_FLAG_EVENT_BUBBLE); return panel; } @@ -208,8 +244,6 @@ static void init_icon_dsc(lv_image_dsc_t* dsc, int w, int h, const uint16_t* dat dsc->data_size = w * h * 2; } -// RGB565A8: planar — w*h RGB565 pixels followed by w*h alpha bytes. -// Stride is RGB565-only (w*2); LVGL infers alpha plane location from header. static void init_icon_dsc_rgb565a8(lv_image_dsc_t* dsc, int w, int h, const uint8_t* data) { dsc->header.w = w; dsc->header.h = h; @@ -234,7 +268,6 @@ static lv_obj_t* make_pill(lv_obj_t* parent, const char* text) { return lbl; } -// ---- Battery icon initialization ---- static void init_battery_icons(void) { init_icon_dsc_rgb565a8(&battery_dscs[0], ICON_BATTERY_W, ICON_BATTERY_H, icon_battery_data); init_icon_dsc_rgb565a8(&battery_dscs[1], ICON_BATTERY_LOW_W, ICON_BATTERY_LOW_H, icon_battery_low_data); @@ -245,28 +278,10 @@ static void init_battery_icons(void) { // ======== Usage Screen ======== -#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. 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) { - lv_obj_t* panel = make_panel(parent, MARGIN, y, CONTENT_W, PANEL_H); + lv_obj_t* panel = make_panel(parent, L.margin, y, L.content_w, L.usage_panel_h); *out_pct = lv_label_create(panel); lv_label_set_text(*out_pct, "---%"); @@ -277,18 +292,18 @@ 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, PANEL_BAR_Y, CONTENT_W - 32, 24); + *out_bar = make_bar(panel, 0, L.usage_bar_y, L.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, PANEL_RESET_Y); + lv_obj_set_pos(*out_reset, 0, L.usage_reset_y); } static void init_usage_screen(lv_obj_t* scr) { usage_container = lv_obj_create(scr); - lv_obj_set_size(usage_container, SCR_W, SCR_H); + lv_obj_set_size(usage_container, L.scr_w, L.scr_h); lv_obj_set_pos(usage_container, 0, 0); lv_obj_set_style_bg_opa(usage_container, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(usage_container, 0, 0); @@ -300,12 +315,13 @@ static void init_usage_screen(lv_obj_t* scr) { lv_label_set_text(lbl_title, "Usage"); lv_obj_set_style_text_font(lbl_title, &font_tiempos_56, 0); lv_obj_set_style_text_color(lbl_title, COL_TEXT, 0); - lv_obj_align(lbl_title, LV_ALIGN_TOP_MID, 16, TITLE_Y); + lv_obj_align(lbl_title, LV_ALIGN_TOP_MID, 16, L.title_y); - make_usage_panel(usage_container, CONTENT_Y, "Current", + make_usage_panel(usage_container, L.content_y, "Current", &lbl_session_pct, &lbl_session_label, &bar_session, &lbl_session_reset); - make_usage_panel(usage_container, CONTENT_Y + PANEL_H + PANEL_GAP, "Weekly", + make_usage_panel(usage_container, + L.content_y + L.usage_panel_h + L.usage_panel_gap, "Weekly", &lbl_weekly_pct, &lbl_weekly_label, &bar_weekly, &lbl_weekly_reset); @@ -318,34 +334,25 @@ static void init_usage_screen(lv_obj_t* scr) { // ======== 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); - lv_obj_set_size(ble_container, SCR_W, SCR_H); + lv_obj_set_size(ble_container, L.scr_w, L.scr_h); lv_obj_set_pos(ble_container, 0, 0); lv_obj_set_style_bg_opa(ble_container, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(ble_container, 0, 0); lv_obj_set_style_pad_all(ble_container, 0, 0); lv_obj_clear_flag(ble_container, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(ble_container, global_click_cb, LV_EVENT_CLICKED, NULL); - // 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, &BT_TITLE_FONT, 0); + lv_obj_set_style_text_font(lbl_ble_title, L.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); + lv_obj_align(lbl_ble_title, LV_ALIGN_TOP_MID, 16, L.title_y); - // Info panel - lv_obj_t* p_info = make_panel(ble_container, MARGIN, CONTENT_Y, CONTENT_W, BT_INFO_PANEL_H); + lv_obj_t* p_info = make_panel(ble_container, L.margin, L.content_y, + L.content_w, L.bt_info_panel_h); - // Bluetooth icon + status row static lv_image_dsc_t icon_bt_dsc; init_icon_dsc(&icon_bt_dsc, ICON_BLUETOOTH_W, ICON_BLUETOOTH_H, icon_bluetooth_data); @@ -355,27 +362,26 @@ 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, &BT_STATUS_FONT, 0); + lv_obj_set_style_text_font(lbl_ble_status, L.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, &BT_DEVICE_FONT, 0); + lv_obj_set_style_text_font(lbl_ble_device, L.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, &BT_DEVICE_FONT, 0); + lv_obj_set_style_text_font(lbl_ble_mac, L.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 + BT_INFO_PANEL_H + 16; + int reset_y = L.content_y + L.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, BT_RESET_ZONE_H); + lv_obj_set_pos(reset_zone, L.margin, reset_y); + lv_obj_set_size(reset_zone, L.content_w, L.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); @@ -393,59 +399,51 @@ 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, &BT_DEVICE_FONT, 0); + lv_obj_set_style_text_font(reset_lbl, L.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, &BT_CREDIT_1_FONT, 0); + lv_obj_set_style_text_font(lbl_credit, L.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, &BT_CREDIT_2_FONT, 0); + lv_obj_set_style_text_font(lbl_credit2, L.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); - // Start hidden lv_obj_add_flag(ble_container, LV_OBJ_FLAG_HIDDEN); } // ======== Public API ======== void ui_init(void) { + compute_layout(board_caps()); + lv_obj_t* scr = lv_screen_active(); lv_obj_set_style_bg_color(scr, COL_BG, 0); lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); - // Logo (shared, always visible, on top of all containers) - // Logo is RGB565A8 (planar: w*h RGB565 then w*h alpha) so it composites - // cleanly against whatever bg is behind it. init_icon_dsc_rgb565a8(&logo_dsc, LOGO_WIDTH, LOGO_HEIGHT, logo_data); - - // Initialize battery icon descriptors init_battery_icons(); init_usage_screen(scr); init_bluetooth_screen(scr); splash_init(scr); - // Splash is touch-toggled — tap anywhere on the splash dismisses it if (splash_get_root()) { lv_obj_add_event_cb(splash_get_root(), global_click_cb, LV_EVENT_CLICKED, NULL); } - // Logo on top of all containers (inset for rounded corners) logo_img = lv_image_create(scr); lv_image_set_src(logo_img, &logo_dsc); - lv_obj_set_pos(logo_img, MARGIN, TITLE_Y - 10); + lv_obj_set_pos(logo_img, L.margin, L.title_y - 10); - // Battery indicator on top of all containers (upper-right, inset) battery_img = lv_image_create(scr); lv_image_set_src(battery_img, &battery_dscs[0]); - lv_obj_set_pos(battery_img, SCR_W - 48 - MARGIN, TITLE_Y); + lv_obj_set_pos(battery_img, L.scr_w - 48 - L.margin, L.title_y); } void ui_update(const UsageData* data) { @@ -453,7 +451,6 @@ void ui_update(const UsageData* data) { int s_pct = (int)(data->session_pct + 0.5f); - // Usage screen lv_label_set_text_fmt(lbl_session_pct, "%d%%", s_pct); lv_bar_set_value(bar_session, s_pct, LV_ANIM_ON); lv_obj_set_style_bg_color(bar_session, pct_color(data->session_pct), LV_PART_INDICATOR); @@ -496,22 +493,16 @@ void ui_tick_anim(void) { } static screen_t prev_non_splash_screen = SCREEN_USAGE; -// Hide the battery indicator on the splash screen — the icon is visually -// noisy over the pixel-art creature animations. static void apply_battery_visibility(void) { if (!battery_img) return; if (current_screen == SCREEN_SPLASH) lv_obj_add_flag(battery_img, LV_OBJ_FLAG_HIDDEN); else lv_obj_clear_flag(battery_img, LV_OBJ_FLAG_HIDDEN); } -// LVGL handles click debouncing internally. Screen-level handler fires when -// no child consumed the event (children only consume if they have their own -// event callback, e.g. the Reset Bluetooth zone). On BT screen we skip the -// splash toggle so only the reset zone is interactive there. static void global_click_cb(lv_event_t* e) { (void)e; - if (ui_get_current_screen() == SCREEN_BLUETOOTH) return; - ui_toggle_splash(); + if (current_screen == SCREEN_SPLASH) ui_show_screen(prev_non_splash_screen); + else ui_show_screen(SCREEN_SPLASH); } static void ble_reset_click_cb(lv_event_t* e) { @@ -531,7 +522,6 @@ void ui_show_screen(screen_t screen) { default: break; } - // Hide the logo overlay on the splash screen so the animation has a clean canvas if (logo_img) { if (screen == SCREEN_SPLASH) lv_obj_add_flag(logo_img, LV_OBJ_FLAG_HIDDEN); else lv_obj_clear_flag(logo_img, LV_OBJ_FLAG_HIDDEN); @@ -543,7 +533,12 @@ void ui_show_screen(screen_t screen) { } void ui_cycle_screen(void) { - screen_t next = (current_screen == SCREEN_USAGE) ? SCREEN_BLUETOOTH : SCREEN_USAGE; + screen_t next; + switch (current_screen) { + case SCREEN_USAGE: next = SCREEN_BLUETOOTH; break; + case SCREEN_BLUETOOTH: next = SCREEN_USAGE; break; + default: next = SCREEN_USAGE; break; + } ui_show_screen(next); } @@ -591,17 +586,17 @@ void ui_update_ble_status(ble_state_t state, const char* name, const char* mac) void ui_update_battery(int percent, bool charging) { int idx; if (charging) { - idx = 4; // charging icon + idx = 4; } else if (percent < 0) { - idx = 0; // no battery / unknown + idx = 0; } else if (percent <= 10) { - idx = 0; // empty + idx = 0; } else if (percent <= 35) { - idx = 1; // low + idx = 1; } else if (percent <= 75) { - idx = 2; // medium + idx = 2; } else { - idx = 3; // full + idx = 3; } lv_image_set_src(battery_img, &battery_dscs[idx]); apply_battery_visibility(); diff --git a/screenshots/amoled_18/bluetooth.png b/screenshots/amoled_18/bluetooth.png new file mode 100644 index 0000000000000000000000000000000000000000..ad25f2a402c22ecc08b4c0edfe4e350d77ed074c GIT binary patch literal 16966 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 literal 0 HcmV?d00001 diff --git a/screenshots/amoled_18/splash.png b/screenshots/amoled_18/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..96fa3313aaf3e1c1f8b6851976b32847db1076ca GIT binary patch literal 1623 zcmeAS@N?(olHy`uVBq!ia0y~yU@QRQ0~}015tEHeWPucCfk$L9kOl!phL%ckAcu|9 z)5S5QV$Rz;8*^J67+f8ri(I$3^157ch`#dV%cRMwU-ld@oV@;<^4ho)-^CrCJb%M@ z;JJW^g0hB#TR=n40tQJ(Mx!RC6c*MK99$CwhqDCR&FPglVlVIF-Tithef|A(ZSBnS z^A5Pry!Q4D=YPK;5$<<%G=3Y+w`Js$`dPKSRJ!c?>e;NUJB$W@YN>PySXOFXkQM)| zhSUFBOZktE1q{igr){ABkN>M={a(7>yngcnhH$kZkYR*oU(3A_``bv~!nEk2;r0LP zlDX$Q42jgnaVq-quDP}k`LD-UocX%{^)K%8N<$#P2T{5COg!t0Mgfoc^<{Qv%LTS literal 0 HcmV?d00001 diff --git a/screenshots/amoled_18/usage.png b/screenshots/amoled_18/usage.png new file mode 100644 index 0000000000000000000000000000000000000000..236f174424e44ce6e40c739a91a98fabcbf22c74 GIT binary patch literal 20603 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 Zw%cE-?)&Cy{azt>+XQy2_D|>N{{i1w3y}Z- literal 0 HcmV?d00001