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
+111
View File
@@ -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 =
+<*>
-<boards/>
+<boards/my_board/> ; 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 `-<boards/>` then `+<boards/your_one/>`
ordering is right (filters apply in declaration order).
+39
View File
@@ -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_<thing>)` 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.
+86
View File
@@ -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.