Device-abstraction refactor: HAL + per-board folders + responsive UI

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/<name>/ 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/<name>/ 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) <noreply@anthropic.com>
This commit is contained in:
tobby168
2026-05-20 18:27:24 -07:00
co-authored by Claude Opus 4.7
parent f3ed2425bf
commit 20351212b2
49 changed files with 1604 additions and 775 deletions
@@ -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
@@ -0,0 +1,9 @@
#include "board.h"
#include <Arduino.h>
#include <Wire.h>
// 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);
}
@@ -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; }
@@ -0,0 +1,134 @@
#include "../../hal/display_hal.h"
#include "../../hal/imu_hal.h"
#include "board.h"
#include <Arduino.h>
#include <Arduino_GFX_Library.h>
#include <esp_heap_caps.h>
#include <lvgl.h>
// 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;
}
@@ -0,0 +1,66 @@
#include "../../hal/imu_hal.h"
#include "board.h"
#include <Arduino.h>
#include <Wire.h>
#include <SensorQMI8658.hpp>
// Poll and hysteresis timing
#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 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 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 (face-up/down)
}
if (abs_ay > abs_ax) return (ay > 0) ? 3 : 1;
return (ax > 0) ? 0 : 2;
}
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_hal_tick(void) {
if (!imu_ok) return;
uint32_t now = millis();
if (now - last_poll_ms < IMU_POLL_MS) return;
last_poll_ms = now;
float ax, ay, az;
if (!imu.getAccelerometer(ax, ay, az)) return;
uint8_t target = accel_to_rotation(ax, ay);
if (target == 255 || target == current_rotation) {
candidate_rotation = current_rotation;
return;
}
if (target != candidate_rotation) {
candidate_rotation = target;
candidate_since = now;
} else if (now - candidate_since >= STABLE_TIME_MS) {
current_rotation = target;
Serial.printf("Rotation: %d\n", current_rotation);
}
}
uint8_t imu_hal_rotation_quadrant(void) { return current_rotation; }
@@ -0,0 +1,18 @@
#include "../../hal/input_hal.h"
#include "board.h"
#include <Arduino.h>
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;
}
@@ -0,0 +1,74 @@
#include "../../hal/power_hal.h"
#include "board.h"
#include <Arduino.h>
#include <Wire.h>
#include <XPowersLib.h>
// PWR button comes from AXP2101 PKEY short-press 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 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();
pmu.disableIRQ(XPOWERS_AXP2101_ALL_IRQ);
pmu.clearIrqStatus();
pmu.enableIRQ(XPOWERS_AXP2101_PKEY_SHORT_IRQ);
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;
pmu.getIrqStatus();
if (pmu.isPekeyShortPressIrq()) {
pwr_pressed_flag = true;
}
pmu.clearIrqStatus();
}
}
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;
}
@@ -0,0 +1,48 @@
#include "../../hal/touch_hal.h"
#include "board.h"
#include <Arduino.h>
#include <Wire.h>
#include <TouchDrvCSTXXX.hpp>
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;
}