diff --git a/docs/porting/capability-flags.md b/docs/porting/capability-flags.md
index 76c2d63..191e900 100644
--- a/docs/porting/capability-flags.md
+++ b/docs/porting/capability-flags.md
@@ -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:
diff --git a/firmware/platformio.ini b/firmware/platformio.ini
index fc23263..560cbd3 100644
--- a/firmware/platformio.ini
+++ b/firmware/platformio.ini
@@ -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 =
+ +<*>
+ -
+ +
+
+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
diff --git a/firmware/src/boards/waveshare_amoled_216_c6/board.h b/firmware/src/boards/waveshare_amoled_216_c6/board.h
new file mode 100644
index 0000000..2a94676
--- /dev/null
+++ b/firmware/src/boards/waveshare_amoled_216_c6/board.h
@@ -0,0 +1,59 @@
+#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 ----
+// Three side-mounted buttons:
+// BOOT (primary) — GPIO 9, sends Space (PTT) over BLE HID
+// PWR (cycle screens) — AXP2101 PKEY IRQ, handled in power.cpp
+// KEY (secondary) — GPIO 10, sends Shift+Tab (mode toggle) over BLE HID
+// KEY GPIO isn't documented by Waveshare; identified empirically by
+// scanning unused GPIOs at boot (see git history of board_init.cpp).
+#define BTN_BACK_GPIO 9
+#define BTN_FWD_GPIO 10
+
+// ---- Capability flags ----
+#define BOARD_HAS_SECONDARY_BUTTON 1
+#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
diff --git a/firmware/src/boards/waveshare_amoled_216_c6/board_init.cpp b/firmware/src/boards/waveshare_amoled_216_c6/board_init.cpp
new file mode 100644
index 0000000..c72a5d0
--- /dev/null
+++ b/firmware/src/boards/waveshare_amoled_216_c6/board_init.cpp
@@ -0,0 +1,51 @@
+#include "board.h"
+#include
+#include
+#include
+
+// 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)");
+}
diff --git a/firmware/src/boards/waveshare_amoled_216_c6/caps.cpp b/firmware/src/boards/waveshare_amoled_216_c6/caps.cpp
new file mode 100644
index 0000000..cff3745
--- /dev/null
+++ b/firmware/src/boards/waveshare_amoled_216_c6/caps.cpp
@@ -0,0 +1,16 @@
+#include "../../hal/board_caps.h"
+#include "board.h"
+
+static const BoardCaps caps = {
+ .name = BOARD_NAME,
+ .width = LCD_WIDTH,
+ .height = LCD_HEIGHT,
+ // BOOT (primary) + KEY (secondary) GPIO buttons. PWR is on the AXP
+ // PKEY and handled separately (not counted here).
+ .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/waveshare_amoled_216_c6/display.cpp b/firmware/src/boards/waveshare_amoled_216_c6/display.cpp
new file mode 100644
index 0000000..dfdc9ee
--- /dev/null
+++ b/firmware/src/boards/waveshare_amoled_216_c6/display.cpp
@@ -0,0 +1,77 @@
+#include "../../hal/display_hal.h"
+#include "board.h"
+#include
+#include
+
+// 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;
+}
diff --git a/firmware/src/boards/waveshare_amoled_216_c6/imu.cpp b/firmware/src/boards/waveshare_amoled_216_c6/imu.cpp
new file mode 100644
index 0000000..2b24d57
--- /dev/null
+++ b/firmware/src/boards/waveshare_amoled_216_c6/imu.cpp
@@ -0,0 +1,26 @@
+#include "../../hal/imu_hal.h"
+#include "board.h"
+#include
+#include
+#include
+
+// 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; }
diff --git a/firmware/src/boards/waveshare_amoled_216_c6/input.cpp b/firmware/src/boards/waveshare_amoled_216_c6/input.cpp
new file mode 100644
index 0000000..af51473
--- /dev/null
+++ b/firmware/src/boards/waveshare_amoled_216_c6/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/boards/waveshare_amoled_216_c6/power.cpp b/firmware/src/boards/waveshare_amoled_216_c6/power.cpp
new file mode 100644
index 0000000..395ef05
--- /dev/null
+++ b/firmware/src/boards/waveshare_amoled_216_c6/power.cpp
@@ -0,0 +1,78 @@
+#include "../../hal/power_hal.h"
+#include "board.h"
+#include
+#include
+#include
+
+// 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.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;
+}
diff --git a/firmware/src/boards/waveshare_amoled_216_c6/touch.cpp b/firmware/src/boards/waveshare_amoled_216_c6/touch.cpp
new file mode 100644
index 0000000..8fdeef2
--- /dev/null
+++ b/firmware/src/boards/waveshare_amoled_216_c6/touch.cpp
@@ -0,0 +1,51 @@
+#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);
+ // 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;
+}
diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp
index 3d0fae5..08a5cb2 100644
--- a/firmware/src/main.cpp
+++ b/firmware/src/main.cpp
@@ -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);
diff --git a/firmware/src/splash.cpp b/firmware/src/splash.cpp
index ad42463..c6e4fe6 100644
--- a/firmware/src/splash.cpp
+++ b/firmware/src/splash.cpp
@@ -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;