From 97a595ee9943d4563c7b98810d2377a62b45b899 Mon Sep 17 00:00:00 2001 From: djkazic Date: Fri, 19 Jun 2026 00:23:10 -0400 Subject: [PATCH] Add LilyGo T-Watch S3 variant ESP32-S3 + SX1262 companion-radio (BLE) support for the LilyGo T-Watch S3: - AXP2101 PMU brought up via XPowersLib (enables the LoRa, display and sensor power rails) - ST7789 240x240 display and FT5x06/FT6x36 capacitive touch via LovyanGFX - on-device UI driven by touch, as the watch has no navigation buttons Shared changes, both guarded/no-op for existing boards: - DisplayDriver: add virtual getTouch() (default returns false; LGFXDisplay already implements it) - companion ui-new: map touch taps to nav keys when UI_HAS_TOUCH is defined --- boards/lilygo_twatch_s3.json | 41 +++++++++ examples/companion_radio/ui-new/UITask.cpp | 32 +++++++ src/helpers/ui/DisplayDriver.h | 1 + variants/lilygo_twatch_s3/TWatchS3Board.cpp | 64 ++++++++++++++ variants/lilygo_twatch_s3/TWatchS3Board.h | 56 ++++++++++++ variants/lilygo_twatch_s3/TWatchS3Display.h | 94 +++++++++++++++++++++ variants/lilygo_twatch_s3/platformio.ini | 76 +++++++++++++++++ variants/lilygo_twatch_s3/target.cpp | 36 ++++++++ variants/lilygo_twatch_s3/target.h | 26 ++++++ 9 files changed, 426 insertions(+) create mode 100644 boards/lilygo_twatch_s3.json create mode 100644 variants/lilygo_twatch_s3/TWatchS3Board.cpp create mode 100644 variants/lilygo_twatch_s3/TWatchS3Board.h create mode 100644 variants/lilygo_twatch_s3/TWatchS3Display.h create mode 100644 variants/lilygo_twatch_s3/platformio.ini create mode 100644 variants/lilygo_twatch_s3/target.cpp create mode 100644 variants/lilygo_twatch_s3/target.h diff --git a/boards/lilygo_twatch_s3.json b/boards/lilygo_twatch_s3.json new file mode 100644 index 0000000000..8da2bb80f2 --- /dev/null +++ b/boards/lilygo_twatch_s3.json @@ -0,0 +1,41 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_16MB.csv", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "esp32s3" + }, + "connectivity": ["wifi", "bluetooth"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "LilyGo T-Watch S3 (16M Flash 8M PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "require_upload_port": true, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "speed": 921600 + }, + "url": "https://www.lilygo.cc/products/t-watch-s3", + "vendor": "LilyGo" +} diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 7c84201941..8e86d0f666 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -774,6 +774,38 @@ void UITask::loop() { next_backlight_btn_check = millis() + 300; } #endif +#if defined(UI_HAS_TOUCH) + // touch nav: tap left/right/centre -> KEY_PREV/KEY_NEXT/KEY_ENTER, long press -> ENTER + { + static bool touch_was_down = false; + static int touch_down_x = 0, touch_down_y = 0; + static unsigned long touch_down_at = 0; + int tx = 0, ty = 0; + bool touch_now = (_display != NULL) && _display->getTouch(&tx, &ty); + if (touch_now && !touch_was_down) { // touch start + touch_down_x = tx; touch_down_y = ty; + touch_down_at = millis(); + touch_was_down = true; + } else if (!touch_now && touch_was_down) { // touch release -> tap + touch_was_down = false; + unsigned long held = millis() - touch_down_at; + if (!_display->isOn()) { + c = checkDisplayOn(KEY_ENTER); // first tap just wakes the screen + } else if (held >= 800) { + c = handleLongPress(KEY_ENTER); // long press -> ENTER (CLI rescue in first 8s) + } else { + int w = _display->width(); + if (touch_down_x < w / 3) { + c = checkDisplayOn(KEY_PREV); + } else if (touch_down_x > (2 * w) / 3) { + c = checkDisplayOn(KEY_NEXT); + } else { + c = checkDisplayOn(KEY_ENTER); + } + } + } + } +#endif if (c != 0 && curr) { curr->handleInput(c); diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index dcc5fe0318..dbaa480150 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -28,6 +28,7 @@ class DisplayDriver { virtual void drawRect(int x, int y, int w, int h) = 0; virtual void drawXbm(int x, int y, const uint8_t* bits, int w, int h) = 0; virtual uint16_t getTextWidth(const char* str) = 0; + virtual bool getTouch(int* x, int* y) { return false; } // touch panels override this virtual void drawTextCentered(int mid_x, int y, const char* str) { // helper method (override to optimise) int w = getTextWidth(str); setCursor(mid_x - w/2, y); diff --git a/variants/lilygo_twatch_s3/TWatchS3Board.cpp b/variants/lilygo_twatch_s3/TWatchS3Board.cpp new file mode 100644 index 0000000000..ee0498ee92 --- /dev/null +++ b/variants/lilygo_twatch_s3/TWatchS3Board.cpp @@ -0,0 +1,64 @@ +#include +#include "TWatchS3Board.h" + +void TWatchS3Board::begin() { + ESP32Board::begin(); + power_init(); + + esp_reset_reason_t reason = esp_reset_reason(); + if (reason == ESP_RST_DEEPSLEEP) { + long wakeup_source = esp_sleep_get_ext1_wakeup_status(); + if (wakeup_source & (1 << P_LORA_DIO_1)) { + startup_reason = BD_STARTUP_RX_PACKET; + } + rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); + rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + } +} + +bool TWatchS3Board::power_init() { + PMU = new XPowersAXP2101(Wire, PIN_BOARD_SDA, PIN_BOARD_SCL, I2C_PMU_ADD); + if (!PMU->init()) { + MESH_DEBUG_PRINTLN("Warning: Failed to find AXP2101 power management"); + delete PMU; + PMU = NULL; + return false; + } + + PMU->setChargingLedMode(XPOWERS_CHG_LED_CTRL_CHG); + + // Power rails (matches LilyGo / Meshtastic T-Watch S3) + PMU->setPowerChannelVoltage(XPOWERS_ALDO3, 3300); // LoRa radio + PMU->enablePowerOutput(XPOWERS_ALDO3); + PMU->setPowerChannelVoltage(XPOWERS_ALDO2, 3300); // sensors, display, PCF8563 RTC + PMU->enablePowerOutput(XPOWERS_ALDO2); + PMU->setPowerChannelVoltage(XPOWERS_ALDO1, 3300); // 6-axis sensor / display rail + PMU->enablePowerOutput(XPOWERS_ALDO1); + PMU->setPowerChannelVoltage(XPOWERS_BLDO2, 3300); // DRV2605 haptic + PMU->enablePowerOutput(XPOWERS_BLDO2); + PMU->setPowerChannelVoltage(XPOWERS_ALDO4, 3300); // must stay on or the radio loses power + PMU->enablePowerOutput(XPOWERS_ALDO4); + + PMU->disablePowerOutput(XPOWERS_DCDC2); + PMU->disablePowerOutput(XPOWERS_DCDC3); + PMU->disablePowerOutput(XPOWERS_DCDC4); + PMU->disablePowerOutput(XPOWERS_DCDC5); + PMU->disablePowerOutput(XPOWERS_BLDO1); + PMU->disablePowerOutput(XPOWERS_DLDO1); + PMU->disablePowerOutput(XPOWERS_DLDO2); + PMU->disablePowerOutput(XPOWERS_VBACKUP); + + PMU->disableIRQ(XPOWERS_AXP2101_ALL_IRQ); + PMU->clearIrqStatus(); + + PMU->setChargerConstantCurr(XPOWERS_AXP2101_CHG_CUR_500MA); + PMU->setChargeTargetVoltage(XPOWERS_AXP2101_CHG_VOL_4V2); + + PMU->disableTSPinMeasure(); + PMU->enableSystemVoltageMeasure(); + PMU->enableVbusVoltageMeasure(); + PMU->enableBattVoltageMeasure(); + + PMU->setPowerKeyPressOffTime(XPOWERS_POWEROFF_4S); + return true; +} diff --git a/variants/lilygo_twatch_s3/TWatchS3Board.h b/variants/lilygo_twatch_s3/TWatchS3Board.h new file mode 100644 index 0000000000..b68c4b52fc --- /dev/null +++ b/variants/lilygo_twatch_s3/TWatchS3Board.h @@ -0,0 +1,56 @@ +#pragma once + +// Pin mappings must be defined before including ESP32Board.h so begin() brings up +// Wire on the correct I2C pins and sleep() can reference P_LORA_DIO_1. + +// Main I2C bus (AXP2101 PMU, PCF8563 RTC) +#define PIN_BOARD_SDA 10 +#define PIN_BOARD_SCL 11 +#define I2C_PMU_ADD 0x34 + +#ifndef PIN_PMU_IRQ + #define PIN_PMU_IRQ 21 +#endif + +#include +#include +#include "XPowersLib.h" +#include "helpers/ESP32Board.h" +#include + +class TWatchS3Board : public ESP32Board { + XPowersLibInterface* PMU = NULL; + + bool power_init(); + +public: + void begin(); + + void enterDeepSleep(uint32_t secs, int pin_wake_btn) { + esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); + + rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); + rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); + rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); + + if (pin_wake_btn < 0) { + esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); + } else { + esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); + } + + if (secs > 0) { + esp_sleep_enable_timer_wakeup(secs * 1000000); + } + + esp_deep_sleep_start(); + } + + uint16_t getBattMilliVolts() override { + return PMU ? PMU->getBattVoltage() : 0; + } + + const char* getManufacturerName() const override { + return "LilyGo T-Watch S3"; + } +}; diff --git a/variants/lilygo_twatch_s3/TWatchS3Display.h b/variants/lilygo_twatch_s3/TWatchS3Display.h new file mode 100644 index 0000000000..90820f556e --- /dev/null +++ b/variants/lilygo_twatch_s3/TWatchS3Display.h @@ -0,0 +1,94 @@ +#pragma once + +#include + +#define LGFX_USE_V1 +#include + +// ST7789 240x240 on SPI3 + PWM backlight (GPIO 45) + FT5x06/FT6x36 capacitive +// touch on a separate I2C bus (Wire1: SDA 39 / SCL 40). +class LGFX_TWatchS3 : public lgfx::LGFX_Device { + lgfx::Panel_ST7789 _panel_instance; + lgfx::Bus_SPI _bus_instance; + lgfx::Light_PWM _light_instance; + lgfx::Touch_FT5x06 _touch_instance; + +public: + LGFX_TWatchS3(void) { + { + auto cfg = _bus_instance.config(); + cfg.spi_host = SPI3_HOST; + cfg.spi_mode = 0; + cfg.freq_write = 40000000; + cfg.freq_read = 16000000; + cfg.spi_3wire = true; + cfg.use_lock = true; + cfg.dma_channel = SPI_DMA_CH_AUTO; + cfg.pin_sclk = 18; + cfg.pin_mosi = 13; + cfg.pin_miso = -1; + cfg.pin_dc = 38; + _bus_instance.config(cfg); + _panel_instance.setBus(&_bus_instance); + } + + { + auto cfg = _panel_instance.config(); + cfg.pin_cs = 12; + cfg.pin_rst = -1; + cfg.pin_busy = -1; + // ST7789 GRAM is 240x320; memory_height must be 320 so the 80px rotation + // offset is applied (otherwise a strip of the panel shows noise). + cfg.memory_width = 240; + cfg.memory_height = 320; + cfg.panel_width = 240; + cfg.panel_height = 240; + cfg.offset_x = 0; + cfg.offset_y = 0; + cfg.offset_rotation = 1; + cfg.readable = false; + cfg.invert = true; + cfg.rgb_order = false; + cfg.dlen_16bit = false; + cfg.bus_shared = false; + _panel_instance.config(cfg); + } + + { + auto cfg = _light_instance.config(); + cfg.pin_bl = 45; + cfg.invert = false; + cfg.freq = 44100; + cfg.pwm_channel = 7; + _light_instance.config(cfg); + _panel_instance.setLight(&_light_instance); + } + + { + auto cfg = _touch_instance.config(); + cfg.x_min = 0; + cfg.x_max = 239; + cfg.y_min = 0; + cfg.y_max = 239; + cfg.pin_int = 16; + cfg.pin_rst = -1; + cfg.bus_shared = false; + cfg.offset_rotation = 2; // touch IC mounted 180deg vs LCD's horizontal axis + cfg.i2c_port = 1; + cfg.i2c_addr = 0x38; + cfg.pin_sda = 39; + cfg.pin_scl = 40; + cfg.freq = 400000; + _touch_instance.config(cfg); + _panel_instance.setTouch(&_touch_instance); + } + + setPanel(&_panel_instance); + } +}; + +class TWatchS3Display : public LGFXDisplay { + LGFX_TWatchS3 disp; +public: + TWatchS3Display() : LGFXDisplay(240, 240, disp) {} +}; diff --git a/variants/lilygo_twatch_s3/platformio.ini b/variants/lilygo_twatch_s3/platformio.ini new file mode 100644 index 0000000000..085cf4ae1d --- /dev/null +++ b/variants/lilygo_twatch_s3/platformio.ini @@ -0,0 +1,76 @@ +[LilyGo_TWatchS3] +extends = esp32_base +board = lilygo_twatch_s3 +build_flags = + ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/lilygo_twatch_s3 + -D LILYGO_TWATCH_S3 + -D T_WATCH_S3 + -D BOARD_HAS_PSRAM=1 + -D CORE_DEBUG_LEVEL=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + -D ESP32_CPU_FREQ=80 ; 80MHz keeps BLE + USB working while reducing power + ; ---- LoRa SX1262 ---- + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=1.8f + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D P_LORA_NSS=5 + -D P_LORA_DIO_1=9 ; SX1262 IRQ + -D P_LORA_RESET=8 + -D P_LORA_BUSY=7 + -D P_LORA_SCLK=3 + -D P_LORA_MISO=4 + -D P_LORA_MOSI=1 + ; ---- Display (ST7789 240x240) + capacitive touch via LovyanGFX ---- + -D DISPLAY_CLASS=TWatchS3Display + -D UI_HAS_TOUCH=1 + -D PIN_PMU_IRQ=21 + ; ---- No GPS / env sensors on the base watch ---- + -D ENV_INCLUDE_GPS=0 + -D ENV_INCLUDE_AHTX0=0 + -D ENV_INCLUDE_BME280=0 + -D ENV_INCLUDE_BMP280=0 + -D ENV_INCLUDE_SHTC3=0 + -D ENV_INCLUDE_SHT4X=0 + -D ENV_INCLUDE_LPS22HB=0 + -D ENV_INCLUDE_INA3221=0 + -D ENV_INCLUDE_INA219=0 + -D ENV_INCLUDE_INA226=0 + -D ENV_INCLUDE_INA260=0 + -D ENV_INCLUDE_MLX90614=0 + -D ENV_INCLUDE_VL53L0X=0 + -D ENV_INCLUDE_BME680=0 + -D ENV_INCLUDE_BMP085=0 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/lilygo_twatch_s3> + + + + +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + lewisxhe/XPowersLib @ ^0.2.7 + lovyan03/LovyanGFX @ ^1.2.0 + +[env:LilyGo_TWatchS3_companion_radio_ble] +extends = LilyGo_TWatchS3 +build_flags = + ${LilyGo_TWatchS3.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${LilyGo_TWatchS3.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${LilyGo_TWatchS3.lib_deps} + densaugeo/base64 @ ~1.4.0 diff --git a/variants/lilygo_twatch_s3/target.cpp b/variants/lilygo_twatch_s3/target.cpp new file mode 100644 index 0000000000..bd603c950a --- /dev/null +++ b/variants/lilygo_twatch_s3/target.cpp @@ -0,0 +1,36 @@ +#include +#include "target.h" + +TWatchS3Board board; + +static SPIClass spi; +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#if ENV_INCLUDE_GPS + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); + EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else + EnvironmentSensorManager sensors; +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; +#endif + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + + spi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); + return radio.std_init(&spi); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} diff --git a/variants/lilygo_twatch_s3/target.h b/variants/lilygo_twatch_s3/target.h new file mode 100644 index 0000000000..3ad9aeef20 --- /dev/null +++ b/variants/lilygo_twatch_s3/target.h @@ -0,0 +1,26 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include +#ifdef DISPLAY_CLASS + #include "TWatchS3Display.h" +#endif +#include "helpers/sensors/EnvironmentSensorManager.h" +#include "helpers/sensors/MicroNMEALocationProvider.h" + +extern TWatchS3Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity();