diff --git a/firmware/platformio.ini b/firmware/platformio.ini index fc23263c3..e816a335a 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -109,3 +109,61 @@ lib_deps = lvgl/lvgl@^9.2.0 bblanchon/ArduinoJson@^7.0.0 h2zero/NimBLE-Arduino@^2.1.1 + + +[env:lilygo_t4s3] +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 +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_LILYGO_T4S3 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DBOARD_HAS_PSRAM + ; SY6970 charger (not AXP) — selects the right driver inside XPowersLib + -DXPOWERS_CHIP_SY6970 + ; 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 = + ; RM690B0 has been in mainline since well before 1.5.6 — pin to the + ; same baseline as the 2.16 env. + moononournation/GFX Library for Arduino@^1.5.6 + ; SensorLib supplies TouchClassCST226 (CST226SE driver) + lewisxhe/SensorLib@^0.2.6 + ; XPowersLib supplies PowersSY6970 when XPOWERS_CHIP_SY6970 is defined + 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/lilygo_t4s3/board.h b/firmware/src/boards/lilygo_t4s3/board.h new file mode 100644 index 000000000..e6b1c1c42 --- /dev/null +++ b/firmware/src/boards/lilygo_t4s3/board.h @@ -0,0 +1,56 @@ +#pragma once + +// LilyGO T4-S3 — 450x600 portrait AMOLED kit. +// RM690B0 + CST226SE touch + SY6970 charger. No IMU, no PMU push-button, +// no second GPIO button (GPIO 18 is the panel TE line). Single physical +// input is the BOOT button; a long hold synthesizes a PWR press so the +// shared screen-cycling path still works. + +#define BOARD_NAME "LilyGO T4-S3" + +// ---- Display geometry ---- +#define LCD_WIDTH 450 +#define LCD_HEIGHT 600 + +// The RM690B0 controller addresses a 482-wide RAM but only the middle +// 450 columns are wired to glass — every draw needs col_offset = 16 so +// pixels land inside the visible viewport. +#define LCD_COL_OFFSET 16 + +// ---- QSPI display pins (RM690B0) ---- +#define LCD_CS 11 +#define LCD_SCLK 15 +#define LCD_SDIO0 14 +#define LCD_SDIO1 10 +#define LCD_SDIO2 16 +#define LCD_SDIO3 12 +#define LCD_RESET 13 +// GPIO 18 is the panel's Tearing-Effect line — reserved, do not reuse. + +// ---- I2C bus (touch + PMU share one bus) ---- +#define IIC_SDA 6 +#define IIC_SCL 7 + +// ---- Touch (CST226SE via SensorLib TouchClassCST226) ---- +#define TP_INT 8 +#define TP_RST 17 +#define CST226_ADDR 0x5A + +// ---- PMU (SY6970 charger; not a fuel gauge) ---- +#define SY6970_ADDR 0x6A +#define PMU_IRQ 5 +#define PMICEN_GPIO 9 // drive HIGH to power the PMU's LDO rails + +// ---- Buttons ---- +#define BTN_BACK_GPIO 0 // BOOT — primary, Space (PTT) +// No second GPIO button on this board. The SY6970 has no user push-button +// either, so screen cycling is mapped to a long press of BOOT — see +// power.cpp. Keep this threshold above a comfortable voice-mode PTT hold. +#define PWR_LONGPRESS_MS 1500 + +// ---- Capability flags ---- +#define BOARD_HAS_SECONDARY_BUTTON 0 +#define BOARD_HAS_ROTATION 0 +#define BOARD_HAS_IMU 0 +#define BOARD_HAS_BATTERY 1 +#define BOARD_HAS_IO_EXPANDER 0 diff --git a/firmware/src/boards/lilygo_t4s3/board_init.cpp b/firmware/src/boards/lilygo_t4s3/board_init.cpp new file mode 100644 index 000000000..a9e798913 --- /dev/null +++ b/firmware/src/boards/lilygo_t4s3/board_init.cpp @@ -0,0 +1,17 @@ +#include "board.h" +#include +#include + +// The panel and touch controller are powered by an LDO rail gated by +// PMICEN. The display init touches I2C-PMU registers and the QSPI lines, +// both of which sit behind that rail — so PMICEN must go HIGH before +// display_hal_init() runs. The 50 ms settling time matches what worked +// reliably during the original bring-up; shorter delays produced +// intermittent panel-init failures from boot. +extern "C" void board_init(void) { + pinMode(PMICEN_GPIO, OUTPUT); + digitalWrite(PMICEN_GPIO, HIGH); + delay(50); + + Wire.begin(IIC_SDA, IIC_SCL); +} diff --git a/firmware/src/boards/lilygo_t4s3/caps.cpp b/firmware/src/boards/lilygo_t4s3/caps.cpp new file mode 100644 index 000000000..61dc59f37 --- /dev/null +++ b/firmware/src/boards/lilygo_t4s3/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/lilygo_t4s3/display.cpp b/firmware/src/boards/lilygo_t4s3/display.cpp new file mode 100644 index 000000000..ae5819923 --- /dev/null +++ b/firmware/src/boards/lilygo_t4s3/display.cpp @@ -0,0 +1,53 @@ +#include "../../hal/display_hal.h" +#include "board.h" +#include +#include + +// RM690B0 native portrait — no rotation buffer needed. + +static Arduino_DataBus* bus = nullptr; +static Arduino_RM690B0* gfx = nullptr; + +void display_hal_init(void) { + bus = new Arduino_ESP32QSPI( + LCD_CS, LCD_SCLK, LCD_SDIO0, LCD_SDIO1, LCD_SDIO2, LCD_SDIO3); + // The visible viewport is the middle 450 columns of a 482-wide controller + // RAM, so every transfer needs col_offset = LCD_COL_OFFSET on both + // sides. Row offset is 0. + gfx = new Arduino_RM690B0( + bus, LCD_RESET, 0 /* rotation */, + LCD_WIDTH, LCD_HEIGHT, + LCD_COL_OFFSET, 0, LCD_COL_OFFSET, 0); +} + +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) return; + gfx->draw16bitRGBBitmap(x, y, (uint16_t*)pixels, w, h); +} + +void display_hal_tick(void) { + // No-op: no IMU-driven rotation on this board. +} + +// RM690B0 expects even-aligned flush regions like the other QSPI AMOLEDs. +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/lilygo_t4s3/imu.cpp b/firmware/src/boards/lilygo_t4s3/imu.cpp new file mode 100644 index 000000000..9f9d6803b --- /dev/null +++ b/firmware/src/boards/lilygo_t4s3/imu.cpp @@ -0,0 +1,7 @@ +#include "../../hal/imu_hal.h" + +// No IMU on the LilyGO T4-S3. Rotation is fixed at 0. + +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/lilygo_t4s3/input.cpp b/firmware/src/boards/lilygo_t4s3/input.cpp new file mode 100644 index 000000000..b93208fe5 --- /dev/null +++ b/firmware/src/boards/lilygo_t4s3/input.cpp @@ -0,0 +1,22 @@ +#include "../../hal/input_hal.h" +#include "board.h" +#include + +// Only the BOOT button is wired as a user input — GPIO 18 carries the +// panel TE signal so the second-button slot stays empty. Screen cycling +// piggybacks on a long press of the same GPIO, synthesized as a PWR +// press inside power.cpp. + +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; + } + return false; +} diff --git a/firmware/src/boards/lilygo_t4s3/power.cpp b/firmware/src/boards/lilygo_t4s3/power.cpp new file mode 100644 index 000000000..68ae16d97 --- /dev/null +++ b/firmware/src/boards/lilygo_t4s3/power.cpp @@ -0,0 +1,102 @@ +#include "../../hal/power_hal.h" +#include "board.h" +#include +#include +#include + +// The SY6970 is a charger IC, not a fuel gauge — no native percent +// reading is available. We approximate from the battery voltage, which is +// rough but acceptable for a desk indicator. Range is calibrated for a +// single-cell LiPo: 4.20 V → 100 %, 3.30 V → 0 %. +// +// PWR-button synthesis: the board has no PMU push-button and no second +// GPIO button, so screen cycling is mapped to a long press of GPIO 0 +// (the same button input.cpp reports as PRIMARY). The two HALs observe +// the same GPIO independently — a brief press is just a Space PTT; a +// hold past PWR_LONGPRESS_MS fires one PWR press event (edge on release) +// without disturbing the in-flight HID press/release semantics. + +#define BATTERY_POLL_MS 2000 +#define CHARGING_POLL_MS 500 +#define PWR_POLL_MS 20 // tight enough to catch a deliberate long press + +static PowersSY6970 pmu; +static bool pmu_ok = false; + +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; + +// Long-press tracker for GPIO 0. +static bool btn_was_down = false; +static uint32_t btn_press_start_ms = 0; +static bool btn_long_consumed = false; + +static int approx_pct_from_mv(uint16_t mv) { + if (mv == 0) return -1; // ADC not yet scanned / no battery + if (mv >= 4200) return 100; + if (mv <= 3300) return 0; + return (int)((mv - 3300) * 100UL / (4200 - 3300)); +} + +void power_hal_init(void) { + // PMICEN is already HIGH from board_init(); the SY6970 talks I2C + // straight away. PowersSY6970 uses init() rather than begin(). + if (!pmu.init(Wire, IIC_SDA, IIC_SCL, SY6970_ADDR)) { + Serial.println("SY6970 init failed"); + return; + } + pmu.enableADCMeasure(); // otherwise getBattVoltage() returns 0 + pmu_ok = true; + Serial.println("SY6970 init OK"); + + cached_charging = pmu.isCharging(); + cached_vbus = pmu.isVbusIn(); + cached_pct = approx_pct_from_mv(pmu.getBattVoltage()); +} + +void power_hal_tick(void) { + uint32_t now = millis(); + + if (pmu_ok) { + 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 = approx_pct_from_mv(pmu.getBattVoltage()); + } + } + + if (now - last_pwr_ms >= PWR_POLL_MS) { + last_pwr_ms = now; + bool down = (digitalRead(BTN_BACK_GPIO) == LOW); + if (down && !btn_was_down) { + btn_press_start_ms = now; + btn_long_consumed = false; + } else if (down && !btn_long_consumed && + now - btn_press_start_ms >= PWR_LONGPRESS_MS) { + pwr_pressed_flag = true; + btn_long_consumed = true; + } + btn_was_down = down; + } +} + +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/lilygo_t4s3/touch.cpp b/firmware/src/boards/lilygo_t4s3/touch.cpp new file mode 100644 index 000000000..68c584375 --- /dev/null +++ b/firmware/src/boards/lilygo_t4s3/touch.cpp @@ -0,0 +1,51 @@ +#include "../../hal/touch_hal.h" +#include "board.h" +#include +#include +#include + +static TouchClassCST226 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, CST226_ADDR, IIC_SDA, IIC_SCL)) { + Serial.println("CST226 init failed"); + return; + } + touch.setMaxCoordinates(LCD_WIDTH, LCD_HEIGHT); + // Empirical swap/mirror for the CST226SE as mounted on the LilyGO + // T4-S3 — touch X is the panel's long axis, Y is the short axis, and + // X is mirrored relative to the display. + touch.setSwapXY(true); + touch.setMirrorXY(true, false); + pinMode(TP_INT, INPUT_PULLUP); + attachInterrupt(TP_INT, touch_isr, FALLING); + Serial.println("CST226 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/ui.cpp b/firmware/src/ui.cpp index 6e1218832..923db6c8f 100644 --- a/firmware/src/ui.cpp +++ b/firmware/src/ui.cpp @@ -84,6 +84,15 @@ static void compute_layout(const BoardCaps& c) { L.bt_credit_2_font = &font_styrene_14; } + // Narrow-panel override: the BT "Address: XX:XX:..." line is the widest + // string the UI ever draws, and at styrene_28 it clips off the right + // margin on panels narrower than ~470 px (e.g. 450-wide portrait kits). + // Tightening this one font keeps the line inside the panel without + // disturbing the rest of the large-layout sizing. + if (L.bt_device_font == &font_styrene_28 && c.width < 470) { + L.bt_device_font = &font_styrene_24; + } + L.content_w = L.scr_w - 2 * L.margin; }