feat(boards): Add Waveshare ESP32-C6-Touch-AMOLED-2.16 support

- Add complete board support for Waveshare ESP32-C6-Touch-AMOLED-2.16 with SH8601 display driver
- Implement board initialization with AXP2101 PMU rail configuration for LCD and touch power
- Add display driver for SH8601 QSPI panel (480×480 resolution)
- Add CST9217 touch controller support via I2C
- Add QMI8658 IMU initialization and sensor reading
- Add AXP2101 power management and battery monitoring
- Add input handling for BOOT button (GPIO 9)
- Configure PlatformIO environment with C6-specific build flags and 16 MB flash layout
- Update capability flags documentation to clarify BOARD_HAS_PSRAM build-flag macro usage
- C6 has no external PSRAM; shared code gates on this flag to use internal SRAM and reduce LVGL buffer sizes
- Screenshot capture disabled on this board due to internal SRAM constraints
This commit is contained in:
Alexander Wennerstrøm
2026-05-24 08:36:53 +02:00
parent c25e89672a
commit ab8b64d949
12 changed files with 479 additions and 5 deletions
+4
View File
@@ -24,6 +24,10 @@ Keep the two in sync. The pattern in `caps.cpp` does this for you:
| `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()`. |
## Build-flag macros
`BOARD_HAS_PSRAM` is set as a `-D` build flag in `platformio.ini` (not in `board.h`) on chips with external PSRAM wired up. Shared code (`main.cpp`, `splash.cpp`) and per-board display drivers use it to choose between `MALLOC_CAP_SPIRAM` (large buffers) and `MALLOC_CAP_INTERNAL` (small buffers, partial-render LVGL, splash canvas capped at ~80 KB, screenshot capture disabled). New ESP32-C6 / ESP32-C3 ports must leave this undefined.
## Future capabilities
Add a new flag when:
+68
View File
@@ -109,3 +109,71 @@ lib_deps =
lvgl/lvgl@^9.2.0
bblanchon/ArduinoJson@^7.0.0
h2zero/NimBLE-Arduino@^2.1.1
[env:waveshare_amoled_216_c6]
; C6 sibling of the AMOLED-2.16. Same panel/touch/PMU/IMU, no PSRAM,
; single-core RISC-V, BLE 5.3 only (no classic BT). If the build fails
; resolving the C6 board, bump pioarduino to a newer 55.x release that
; ships esp32-c6-devkitc-1.
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip
board = esp32-c6-devkitc-1
framework = arduino
upload_speed = 921600
monitor_speed = 115200
; Waveshare's C6-AMOLED-2.16 ships with 16 MB flash. The default 1.25 MB
; app partition can't hold this firmware (~1.43 MB) — switch to the
; 16 MB layout that gives the app ~6.5 MB, matching the AMOLED-1.8 env.
board_upload.flash_size = 16MB
board_upload.maximum_size = 16777216
board_build.partitions = default_16MB.csv
build_src_filter =
+<*>
-<boards/>
+<boards/waveshare_amoled_216_c6/>
build_flags =
-DBOARD_AMOLED_216_C6
; C6 has only USB-Serial-JTAG (HWCDC), no native USB-OTG. The Arduino
; core maps Serial → HWCDCSerial only when BOTH flags below are set;
; CDC_ON_BOOT alone selects USBSerial (TinyUSB) which doesn't exist on
; C6 and breaks the build. Without either flag, Serial routes to UART0
; on physical pins and nothing reaches the USB host.
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
; No -DBOARD_HAS_PSRAM — C6 has no external PSRAM. Shared code
; (main.cpp, splash.cpp) gates on this to pick MALLOC_CAP_INTERNAL
; and shrink LVGL buffers + splash canvas accordingly.
-DXPOWERS_CHIP_AXP2101
-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
-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
; Screenshot capture needs a full-frame RGB565 buffer (~460 KB on
; 480×480) which doesn't fit in C6 internal SRAM. send_screenshot
; prints SCREENSHOT_UNSUPPORTED on this board.
-DLV_USE_SNAPSHOT=0
lib_deps =
; 1.6.4+ required for Arduino_SH8601 (this board uses SH8601, not CO5300)
moononournation/GFX Library for Arduino@^1.6.4
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
@@ -0,0 +1,54 @@
#pragma once
// Waveshare ESP32-C6-Touch-AMOLED-2.16.
//
// Despite the matching "2.16" model number, this board is hardware-
// distinct from the S3 AMOLED-2.16: it uses an SH8601 panel (same driver
// family as the AMOLED-1.8), a CST9217 touch controller, and the C6 SoC's
// own GPIO map. There is no PSRAM. AXP2101 PMU and QMI8658 IMU carry over.
//
// Pin assignments verified against the official Waveshare XiaoZhi BSP at
// waveshareteam/ESP32-C6-Touch-AMOLED-2.16 (XiaoZhi config.h) and the
// ESP-IDF example user_config.h files in the same repo.
#define BOARD_NAME "Waveshare AMOLED 2.16 (C6)"
#define LCD_WIDTH 480
#define LCD_HEIGHT 480
// ---- QSPI display pins (SH8601) ----
#define LCD_CS 15
#define LCD_SCLK 0
#define LCD_SDIO0 1
#define LCD_SDIO1 2
#define LCD_SDIO2 3
#define LCD_SDIO3 4
// LCD reset is not wired to a MCU GPIO on this board — the SH8601 relies
// on its internal power-on reset. The Arduino_GFX driver gets
// GFX_NOT_DEFINED for reset.
// ---- I2C bus (touch + PMU + IMU all share one bus) ----
#define IIC_SDA 8
#define IIC_SCL 7
// ---- Touch (CST9217 via TouchDrvCST92xx library) ----
// CST9217 is register-compatible with CST9220 in the relevant subset; the
// SensorLib CST92xx driver works against both. I2C address is the same.
#define TP_INT 5
#define TP_RST 11
#define CST9220_ADDR 0x5A
// ---- PMU ----
#define AXP2101_ADDR 0x34
// ---- Buttons ----
// Only one user GPIO button (BOOT). The "PWR" side button is the AXP2101
// PKEY input — already serviced by power.cpp via the PKEY_SHORT_IRQ path.
#define BTN_BACK_GPIO 9 // BOOT — primary, Space (PTT)
// ---- Capability flags ----
#define BOARD_HAS_SECONDARY_BUTTON 0
#define BOARD_HAS_ROTATION 0 // C6 has no PSRAM headroom for the rotation strip
#define BOARD_HAS_IMU 1 // present + initialized for I2C bus health
#define BOARD_HAS_BATTERY 1
#define BOARD_HAS_IO_EXPANDER 0 // TCA9554 exists on board but only services audio
@@ -0,0 +1,51 @@
#include "board.h"
#include <Arduino.h>
#include <Wire.h>
#include <XPowersLib.h>
// On this C6 board the SH8601 LCD + CST9217 touch are powered from the
// AXP2101's ALDO rails, not directly from 3V3. The display init must run
// AFTER the rails are up, so we bring the PMU up here in board_init()
// (before display_hal_init) instead of in power_hal_init() which runs
// later. power.cpp re-uses the same XPowersPMU handle for battery polling.
//
// Rail config mirrors the Waveshare XiaoZhi BSP for this board:
// DC1 = 3.3V (system rail)
// ALDO1..4 = 3.3V, all enabled (LCD, touch, sensors)
XPowersPMU board_pmu; // shared instance — power.cpp uses extern reference
extern "C" void board_init(void) {
Wire.begin(IIC_SDA, IIC_SCL);
if (!board_pmu.begin(Wire, AXP2101_ADDR, IIC_SDA, IIC_SCL)) {
Serial.println("AXP2101 init failed (board_init)");
return;
}
// Set all four ALDOs to 3.3V.
board_pmu.setALDO1Voltage(3300);
board_pmu.setALDO2Voltage(3300);
board_pmu.setALDO3Voltage(3300);
board_pmu.setALDO4Voltage(3300);
// ALDO1, 2, 4 just need to be enabled.
board_pmu.enableALDO1();
board_pmu.enableALDO2();
board_pmu.enableALDO4();
// ALDO3 doubles as the LCD reset line on this board (the SH8601's RST
// pin isn't wired to a MCU GPIO). The Waveshare BSP pulses it
// HIGH → LOW → HIGH with 100 ms holds to issue a proper reset before
// the panel sees its first SPI command. Without this pulse the SH8601
// stays in an indeterminate state and the screen is black even though
// QSPI init and brightness writes succeed.
board_pmu.enableALDO3();
delay(100);
board_pmu.disableALDO3();
delay(100);
board_pmu.enableALDO3();
delay(100);
Serial.println("AXP2101 rails up (ALDO1-4 @ 3.3V, LCD reset pulsed)");
}
@@ -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, // BOOT only; PWR is via AXP PKEY IRQ
.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; }
@@ -0,0 +1,77 @@
#include "../../hal/display_hal.h"
#include "board.h"
#include <Arduino.h>
#include <Arduino_GFX_Library.h>
// C6 AMOLED-2.16 uses an SH8601 panel — same driver family as the
// AMOLED-1.8 port. LCD reset is not wired to any MCU GPIO; the SH8601
// boots from its internal POR. Rotation is disabled (no PSRAM headroom
// for the strip buffer).
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);
gfx = new Arduino_SH8601(
bus, GFX_NOT_DEFINED, 0, LCD_WIDTH, LCD_HEIGHT);
}
// Vendor-specific init commands from the Waveshare C6-2.16 BSP
// (02_Example/Arduino-v3.3.3/09_LVGL_V9_Test/bsp_lvgl_port.cpp in the
// waveshareteam/ESP32-C6-Touch-AMOLED-2.16 repo). The stock Arduino_GFX
// SH8601 init does SLPOUT + NORON + INVOFF + PIXFMT + DISPON + brightness,
// which is enough for the AMOLED-1.8 panel but leaves this 2.16 panel
// dark. The page-switch sequence (0xFE 0x20 ... 0xFE 0x00) writes two
// panel-specific manufacturer registers (0x19 and 0x1C) that gate the
// driving voltages — without them the panel stays black even with the
// rails up and the reset pulse applied.
static void send_vendor_init(Arduino_DataBus* b) {
b->beginWrite();
b->writeC8D8(0xFE, 0x20); // enter manufacturer command page 0x20
b->writeC8D8(0x19, 0x10); // panel driving
b->writeC8D8(0x1C, 0xA0); // panel driving
b->writeC8D8(0xFE, 0x00); // back to user command page
b->writeC8D8(0xC4, 0x80); // SPI mode control
b->writeC8D8(0x36, 0x30); // MADCTL (BSP value)
b->writeC8D8(0x53, 0x20); // CTRL display 1 (brightness control on)
b->writeC8D8(0x51, 0xFF); // brightness = max
b->writeC8D8(0x63, 0xFF); // HBM brightness = max
b->writeCommand(0x29); // DISPON (idempotent — stock init already did this)
b->endWrite();
delay(20);
}
void display_hal_begin(void) {
gfx->begin();
send_vendor_init(bus); // patch up panel-specific regs the stock init misses
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 cycle on this board.
}
// Mirrors the CO5300/SH8601 even-alignment pattern from the other ports.
// Harmless on SH8601, kept for consistency.
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,26 @@
#include "../../hal/imu_hal.h"
#include "board.h"
#include <Arduino.h>
#include <Wire.h>
#include <SensorQMI8658.hpp>
// QMI8658 is populated on the 2.16 carrier PCB but rotation is disabled
// on the C6 build (BOARD_HAS_ROTATION=0). We still initialize the device
// so the shared I2C bus stays healthy and so a future build can flip
// rotation on without changing this file. Always reports quadrant 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; }
@@ -0,0 +1,17 @@
#include "../../hal/input_hal.h"
#include "board.h"
#include <Arduino.h>
// Only BOOT is wired to a MCU GPIO. The "PWR" side button is the AXP2101
// PKEY, handled in power.cpp.
void input_hal_init(void) {
pinMode(BTN_BACK_GPIO, INPUT_PULLUP);
}
bool input_hal_is_held(InputButton btn) {
if (btn == INPUT_BTN_PRIMARY) {
return digitalRead(BTN_BACK_GPIO) == LOW;
}
return false;
}
@@ -0,0 +1,82 @@
#include "../../hal/power_hal.h"
#include "board.h"
#include <Arduino.h>
#include <Wire.h>
#include <XPowersLib.h>
// AXP2101 PMU — identical chip and protocol to the S3 variant.
#define BATTERY_POLL_MS 2000
#define CHARGING_POLL_MS 500
#define PWR_POLL_MS 50
// The PMU instance is owned by board_init.cpp on this board — it has to
// come up before display_hal_init() to enable the LCD power rails. We
// reuse the same handle here for battery polling and PKEY IRQ wiring.
extern XPowersPMU board_pmu;
#define pmu board_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) {
// pmu.begin() already ran in board_init(); just configure battery +
// IRQ wiring here.
pmu.enableBattDetection();
pmu.enableBattVoltageMeasure();
// Mirror the Waveshare XiaoZhi BSP charging config so the on-chip
// fuel gauge has the right reference numbers. Without these,
// getBatteryPercent() returns -1 / shows "---" on the UI.
pmu.setChargeTargetVoltage(XPOWERS_AXP2101_CHG_VOL_4V1);
pmu.setChargerConstantCurr(XPOWERS_AXP2101_CHG_CUR_400MA);
pmu.setChargerTerminationCurr(XPOWERS_AXP2101_CHG_ITERM_25MA);
pmu.setPrechargeCurr(XPOWERS_AXP2101_PRECHARGE_50MA);
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,51 @@
#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);
// C6 2.16 panel mapping (verified empirically): the CST9217's raw
// axes are swapped relative to the SH8601 raster AND X is mirrored.
// Matches the Waveshare BSP, which reads y = raw_byte1, x = W - raw_byte2.
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;
}
+19 -3
View File
@@ -21,8 +21,17 @@
static UsageData usage = {};
// ---- LVGL draw buffers (PSRAM, partial render mode) ----
// ---- LVGL draw buffers (partial render mode) ----
// PSRAM-equipped boards (S3) can comfortably hold larger strips. PSRAM-free
// boards (e.g. ESP32-C6) allocate from internal SRAM, so we shrink the strip
// — 480×20 RGB565 = 19 KB × 2 buffers = 38 KB, fits beside everything else.
#ifdef BOARD_HAS_PSRAM
#define BUF_LINES 40
#define LV_BUF_CAPS (MALLOC_CAP_SPIRAM)
#else
#define BUF_LINES 20
#define LV_BUF_CAPS (MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)
#endif
static uint16_t* buf1 = nullptr;
static uint16_t* buf2 = nullptr;
@@ -111,6 +120,12 @@ static char cmd_buf[CMD_BUF_SIZE];
static int cmd_pos = 0;
static void send_screenshot() {
#ifndef BOARD_HAS_PSRAM
// A full RGB565 framebuffer doesn't fit in internal SRAM on PSRAM-free
// boards (e.g. 480×480×2 = 460 KB). Capture is unsupported there.
Serial.println("SCREENSHOT_UNSUPPORTED");
return;
#else
const uint32_t w = board_caps().width;
const uint32_t h = board_caps().height;
const uint32_t row_bytes = w * 2;
@@ -139,6 +154,7 @@ static void send_screenshot() {
Serial.println();
Serial.println("SCREENSHOT_END");
heap_caps_free(sbuf);
#endif
}
static void check_serial_cmd() {
@@ -182,8 +198,8 @@ void setup() {
lv_init();
lv_tick_set_cb(my_tick);
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);
buf1 = (uint16_t*)heap_caps_malloc(W * BUF_LINES * 2, LV_BUF_CAPS);
buf2 = (uint16_t*)heap_caps_malloc(W * BUF_LINES * 2, LV_BUF_CAPS);
lv_display_t* disp = lv_display_create(W, H);
lv_display_set_color_format(disp, LV_COLOR_FORMAT_RGB565);
+16 -2
View File
@@ -103,11 +103,25 @@ void splash_init(lv_obj_t *parent) {
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;
#ifdef BOARD_HAS_PSRAM
const uint32_t canvas_caps = MALLOC_CAP_SPIRAM;
#else
// Without PSRAM the full 480×480 RGB565 canvas (460 KB) won't fit. Cap
// the canvas so the buffer stays under ~80 KB, leaving the rest of
// internal SRAM free for LVGL, NimBLE, and the audio/PMU stacks. The
// canvas is centered, so the cost is extra black border around the
// pixel art — not cropping.
const uint32_t canvas_caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT;
const int MAX_CELL_NO_PSRAM = 10; // 10*20=200; 200*200*2=78 KB
if (cell > MAX_CELL_NO_PSRAM) cell = MAX_CELL_NO_PSRAM;
#endif
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);
canvas_buf = (uint16_t*)heap_caps_malloc(canvas_w * canvas_h * 2, canvas_caps);
row_buf = (uint16_t*)heap_caps_malloc(canvas_w * 2, canvas_caps);
if (!canvas_buf || !row_buf) {
Serial.println("splash: failed to alloc canvas buffer");
return;