From b80184cf965fff633d1bd60ba8bfb3c9a14e92ea Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:28:24 +1000 Subject: [PATCH] initial builds, standalone and ble twatch s3 plus --- boards/lilygo_twatch_s3_plus.json | 41 +++++ examples/companion_radio/MyMesh.cpp | 4 + examples/companion_radio/main.cpp | 82 +++++++++- examples/companion_radio/ui-new/HomeIcons.h | 6 + examples/companion_radio/ui-new/UITask.cpp | 88 ++++++++++- examples/companion_radio/ui-new/UITask.h | 8 +- .../companion_radio/ui-new/VirtualKeyboard.h | 4 +- src/helpers/ui/LGFXDisplay.cpp | 16 +- src/helpers/ui/LGFXDisplay.h | 22 ++- .../lilygo_twatch_s3_plus/CPUPowerManager.h | 113 ++++++++++++++ .../lilygo_twatch_s3_plus/GPSStreamCounter.h | 72 +++++++++ .../TWatchS3PlusBoard.cpp | 79 ++++++++++ .../lilygo_twatch_s3_plus/TWatchS3PlusBoard.h | 55 +++++++ .../TWatchS3PlusDisplay.h | 96 ++++++++++++ variants/lilygo_twatch_s3_plus/platformio.ini | 144 ++++++++++++++++++ variants/lilygo_twatch_s3_plus/target.cpp | 69 +++++++++ variants/lilygo_twatch_s3_plus/target.h | 47 ++++++ variants/lilygo_twatch_s3_plus/variant.h | 79 ++++++++++ 18 files changed, 1007 insertions(+), 18 deletions(-) create mode 100644 boards/lilygo_twatch_s3_plus.json create mode 100644 variants/lilygo_twatch_s3_plus/CPUPowerManager.h create mode 100644 variants/lilygo_twatch_s3_plus/GPSStreamCounter.h create mode 100644 variants/lilygo_twatch_s3_plus/TWatchS3PlusBoard.cpp create mode 100644 variants/lilygo_twatch_s3_plus/TWatchS3PlusBoard.h create mode 100644 variants/lilygo_twatch_s3_plus/TWatchS3PlusDisplay.h create mode 100644 variants/lilygo_twatch_s3_plus/platformio.ini create mode 100644 variants/lilygo_twatch_s3_plus/target.cpp create mode 100644 variants/lilygo_twatch_s3_plus/target.h create mode 100644 variants/lilygo_twatch_s3_plus/variant.h diff --git a/boards/lilygo_twatch_s3_plus.json b/boards/lilygo_twatch_s3_plus.json new file mode 100644 index 00000000..f758afc3 --- /dev/null +++ b/boards/lilygo_twatch_s3_plus.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 Plus (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-plus", + "vendor": "LilyGo" +} \ No newline at end of file diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c2f72d88..a83c98e8 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2426,6 +2426,10 @@ void MyMesh::handleCmdFrame(size_t len) { if (strcmp(sp, "gps") == 0) { _prefs.gps_enabled = (np[0] == '1') ? 1 : 0; savePrefs(); + #if defined(LILYGO_TWATCH_S3_PLUS) + // Apply the BLDO1 GPS rail live so the toggle takes effect now, no reboot + if (_prefs.gps_enabled) board.gpsPowerOn(); else board.gpsPowerOff(); + #endif } else if (strcmp(sp, "gps_interval") == 0) { uint32_t interval_seconds = atoi(np); _prefs.gps_interval = constrain(interval_seconds, 0, 86400); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index e5842eef..1668e714 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -709,10 +709,32 @@ // ============================================================================= // Define MECK_TOUCH_ENABLED for any platform with touch support -#if defined(LilyGo_T5S3_EPaper_Pro) || (defined(LilyGo_TDeck_Pro) && defined(HAS_TOUCHSCREEN)) +#if defined(LilyGo_T5S3_EPaper_Pro) || (defined(LilyGo_TDeck_Pro) && defined(HAS_TOUCHSCREEN)) || defined(LILYGO_TWATCH_S3_PLUS) #define MECK_TOUCH_ENABLED 1 #endif +// --- T-Watch S3 Plus: screen headers for the touch UI --- +// The watch needs the same concrete screen types as the gesture machine casts +// to, but none of the T5S3/T-Deck hardware baggage (GT911, SD, TCA8418 keyboard). +#if defined(LILYGO_TWATCH_S3_PLUS) + #include "TextReaderScreen.h" + #include "NotesScreen.h" + #include "ContactsScreen.h" + #include "ChannelScreen.h" + #include "ChannelPickerScreen.h" + #include "MeckExport.h" + #include "MeckImport.h" + #include "SettingsScreen.h" + #include "RepeaterAdminScreen.h" + #include "DiscoveryScreen.h" + #include "LastHeardScreen.h" + #include "PathEditorScreen.h" + #include "Tracescreen.h" + #include "GamesMenuScreen.h" + #include "SnakeScreen.h" + #include "MinesweeperScreen.h" +#endif + // --- T5S3: GT911 capacitive touch driver --- #if defined(LilyGo_T5S3_EPaper_Pro) #include "TouchDrvGT911.hpp" @@ -879,6 +901,8 @@ #define TOUCH_LONG_PRESS_MS 750 #if defined(LilyGo_T5S3_EPaper_Pro) #define TOUCH_SWIPE_THRESHOLD 60 // T5S3: 960×540 — 60px ≈ 6% of width + #elif defined(LILYGO_TWATCH_S3_PLUS) + #define TOUCH_SWIPE_THRESHOLD 16 // Watch: getTouch() returns 0..119 (240px/UI_ZOOM); 16 is ~13% of width #else #define TOUCH_SWIPE_THRESHOLD 30 // T-Deck Pro: 240×320 — 30px ≈ 12.5% of width #endif @@ -915,6 +939,18 @@ } #elif defined(LilyGo_TDeck_Pro) return touchInput.getPoint(*outX, *outY); + #elif defined(LILYGO_TWATCH_S3_PLUS) + { + // FT6336U is read through the LovyanGFX panel backing the display. + // display.getTouch() returns coordinates already divided by UI_ZOOM. + int tx, ty; + if (display.getTouch(&tx, &ty)) { + *outX = (int16_t)tx; + *outY = (int16_t)ty; + return true; + } + return false; + } #else return false; #endif @@ -928,6 +964,11 @@ #elif defined(LilyGo_TDeck_Pro) float sx = (float)EINK_WIDTH / 128.0f; // 240/128 = 1.875 float sy = (float)EINK_HEIGHT / 128.0f; // 320/128 = 2.5 + #elif defined(LILYGO_TWATCH_S3_PLUS) + // display.getTouch() already divides by UI_ZOOM, so px/py span the + // 240/UI_ZOOM logical canvas (0..119 at UI_ZOOM=2). Scale to 128 virtual. + float sx = (240.0f / UI_ZOOM) / 128.0f; + float sy = (240.0f / UI_ZOOM) / 128.0f; #endif vx = (int)(px / sx); #if defined(LilyGo_TDeck_Pro) @@ -1081,7 +1122,7 @@ static uint32_t _atoi(const char* sp) { /* GLOBAL OBJECTS */ #ifdef DISPLAY_CLASS #include "UITask.h" - #if HAS_GPS && !defined(LILYGO_TECHO_CARD) + #if HAS_GPS && !defined(LILYGO_TECHO_CARD) && !defined(LILYGO_TWATCH_S3_PLUS) #include "MapScreen.h" // After BLE -- PNGdec headers conflict with BLE if included earlier #endif UITask ui_task(&board, &serial_interface); @@ -1683,6 +1724,35 @@ static void lastHeardToggleContact() { return 'q'; } +#if defined(LILYGO_TWATCH_S3_PLUS) + // Watch FIRST page: long-press on a coloured tile opens its screen. + // The grid renders in the logical (display.width()) space; touchToVirtual + // yields 128-space coords, so scale them back to render space to hit-test. + if (ui_task.isOnHomeScreen() && ui_task.isHomeShowingTiles()) { + int vx, vy; + touchToVirtual(x, y, vx, vy); + const int W = display.width(); // logical width (120) + int rvx = vx * W / 128; + int rvy = vy * W / 128; + const int cols = 2, rows = 3; + const int tileW = 56, tileH = 24, gapX = 4, gapY = 3; + const int gridW = tileW * cols + gapX * (cols - 1); // 116 + const int gridX = (W - gridW) / 2; // 2 + int gridY = ui_task.getTileGridVY(); + if (rvx >= gridX && rvx < gridX + gridW && + rvy >= gridY && rvy < gridY + rows * (tileH + gapY)) { + int col = (rvx - gridX) / (tileW + gapX); if (col > 1) col = 1; + int row = (rvy - gridY) / (tileH + gapY); if (row > 2) row = 2; + if (row == 0 && col == 0) { ui_task.gotoChannelPickerScreen(); return 0; } + if (row == 0 && col == 1) { ui_task.gotoContactsScreen(); return 0; } + if (row == 1 && col == 0) { ui_task.gotoSettingsScreen(); return 0; } + if (row == 1 && col == 1) { ui_task.gotoDiscoveryScreen(); return 0; } + if (row == 2 && col == 0) { ui_task.gotoTraceScreen(); return 0; } + // row 2 col 1 = Maps -- TODO subscreen not yet built; no-op for now. + } + return 0; // consume long-press on the tile page + } +#endif // Home screen: long press = activate current page action // (BLE toggle, send advert, hibernate, GPS toggle, etc.) if (ui_task.isOnHomeScreen()) { @@ -2587,6 +2657,9 @@ void setup() { #ifdef PIN_GPS_EN digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE); #endif + #if defined(LILYGO_TWATCH_S3_PLUS) + board.gpsPowerOn(); // GPS power is the AXP2101 BLDO1 rail + #endif #if defined(LilyGo_TDeck_Pro_Max) // Speed up / improve the MAX GPS fix: enable multi-constellation // (GPS + GLONASS + BeiDou) and EASY predicted ephemeris (warm/hot starts @@ -2604,6 +2677,9 @@ void setup() { #if defined(LilyGo_TDeck_Pro_Max) board.gpsPowerOff(); // MAX: GPS power is XL9555-routed, not PIN_GPS_EN #endif + #if defined(LILYGO_TWATCH_S3_PLUS) + board.gpsPowerOff(); // GPS power is the AXP2101 BLDO1 rail + #endif sensors.setSettingValue("gps", "0"); } Serial.printf("GPS: power %s\n", gps_wanted ? "ON" : "OFF"); @@ -2779,7 +2855,7 @@ void loop() { // Map screen: periodically update own GPS position and contact markers #ifdef DISPLAY_CLASS - #if HAS_GPS && !defined(LILYGO_TECHO_CARD) + #if HAS_GPS && !defined(LILYGO_TECHO_CARD) && !defined(LILYGO_TWATCH_S3_PLUS) if (ui_task.isOnMapScreen()) { static unsigned long lastMapUpdate = 0; if (millis() - lastMapUpdate > 30000) { // Every 30 seconds diff --git a/examples/companion_radio/ui-new/HomeIcons.h b/examples/companion_radio/ui-new/HomeIcons.h index 8afc282f..e9c3118f 100644 --- a/examples/companion_radio/ui-new/HomeIcons.h +++ b/examples/companion_radio/ui-new/HomeIcons.h @@ -54,6 +54,12 @@ static const uint8_t icon_alarm[] PROGMEM = { 0x40,0x40, 0x20,0x80, 0x1F,0x00, 0x00,0x00, 0x20,0x40, 0x40,0x20, }; +// Mountain (Maps) +static const uint8_t icon_map[] PROGMEM = { + 0x00,0x00, 0x00,0x00, 0x00,0x00, 0x08,0x00, 0x14,0x00, 0x22,0x40, + 0x41,0xA0, 0x42,0x50, 0x84,0x30, 0xFF,0xF0, 0x00,0x00, 0x00,0x00, +}; + // ➡ Right arrow (Trace Route) static const uint8_t icon_trace[] PROGMEM = { 0x00,0x00, 0x00,0x00, 0x00,0x80, 0x00,0xC0, 0x00,0xE0, 0xFF,0xF0, diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 1d835f9a..b08cce9a 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -16,14 +16,14 @@ #ifdef MECK_WEB_READER #include "WebReaderScreen.h" #endif -#if HAS_GPS && !defined(LILYGO_TECHO_CARD) +#if HAS_GPS && !defined(LILYGO_TECHO_CARD) && !defined(LILYGO_TWATCH_S3_PLUS) #include "MapScreen.h" #endif #include "target.h" #if defined(LilyGo_TDeck_Pro_Max) #include "DRV2605Haptic.h" // haptic motor for "Buzzer (vibrate)" channels #endif -#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_AUDIO_VARIANT) +#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_AUDIO_VARIANT) || defined(LILYGO_TWATCH_S3_PLUS) #include "HomeIcons.h" #endif #if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION) @@ -131,7 +131,11 @@ public: // version info display.setColor(DisplayDriver::LIGHT); +#if defined(LILYGO_TWATCH_S3_PLUS) + display.setTextSize(1); // 240x240: size 2 overflows the 120px virtual width and wraps +#else display.setTextSize(2); +#endif display.drawTextCentered(display.width()/2, 22, _version_info); display.setTextSize(1); @@ -402,11 +406,29 @@ public: #define HOME_HDR_Y 1 #elif defined(LILYGO_TECHO_LITE) #define HOME_HDR_Y 0 +#elif defined(LILYGO_TWATCH_S3_PLUS) + #define HOME_HDR_Y 1 #else #define HOME_HDR_Y -3 #endif display.setCursor(0, HOME_HDR_Y); +#if defined(LILYGO_TWATCH_S3_PLUS) + // Watch: render the name in a very small font so long names fit beside the + // centred clock instead of overrunning it. Colour was set above (GREEN). + ((LGFXDisplay*)&display)->printSmallFont(0, HOME_HDR_Y, filtered_name); +#else display.print(filtered_name); +#endif +#if defined(LILYGO_TWATCH_S3_PLUS) + // P4-style compact MSG count, stacked under the node name (top-left). + { + char msgbuf[16]; + sprintf(msgbuf, "MSG: %d", _task->getUnreadMsgCount()); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(0, HOME_HDR_Y + 9); + display.print(msgbuf); + } +#endif // battery voltage + status icons #ifdef MECK_AUDIO_VARIANT @@ -453,6 +475,8 @@ public: int y = 13; // Below header #elif defined(LilyGo_T5S3_EPaper_Pro) int y = 14; // Closer to header +#elif defined(LILYGO_TWATCH_S3_PLUS) + int y = 18; // below the stacked name + MSG header #else int y = 14; #endif @@ -466,7 +490,7 @@ public: } if (_page == HomePage::FIRST) { -#if defined(LilyGo_T5S3_EPaper_Pro) +#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LILYGO_TWATCH_S3_PLUS) _task->setHomeShowingTiles(true); #endif #if defined(LilyGo_T5S3_EPaper_Pro) @@ -477,13 +501,17 @@ public: #endif #elif defined(LILYGO_TECHO_LITE) int y = 18; // Below page dots +#elif defined(LILYGO_TWATCH_S3_PLUS) + int y = 21; // tiles start below header (MSG shown small in header) #else int y = 20; #endif +#if !defined(LILYGO_TWATCH_S3_PLUS) display.setColor(DisplayDriver::YELLOW); display.setTextSize(2); sprintf(tmp, "MSG: %d", _task->getUnreadMsgCount()); display.drawTextCentered(display.width() / 2, y, tmp); +#endif #if defined(LILYGO_TECHO_LITE) y += 12; // Compact #elif defined(LilyGo_TDeck_Pro_Max) @@ -600,6 +628,47 @@ public: } display.setTextSize(1); +#elif defined(LILYGO_TWATCH_S3_PLUS) + // ----- T-Watch S3 Plus: P4-style coloured tile grid (3x2) ----- + // Border colours approximate the Meck P4 home palette (RGB565): a white + // icon + label on a dark navy fill, each tile a distinct bright border. + // setRawColor() pushes exact RGB565 past the Color enum + watch grey remap. + { + struct Tile { const uint8_t* icon; const char* label; uint16_t color; }; + const Tile tiles[3][2] = { + { {icon_envelope, "Messages", 0x0CB1}, {icon_people, "Contacts", 0xCAA0} }, + { {icon_gear, "Settings", 0x0560}, {icon_search, "Discover", 0xF81F} }, + { {icon_trace, "Trace", 0xF800}, {icon_map, "Maps", 0x231D} }, + }; + const uint16_t TILE_FILL = 0x18C5; // dark navy + + const int cols = 2, rows = 3; + const int tileW = 56, tileH = 24, gapX = 4, gapY = 3; + const int gridW = tileW * cols + gapX * (cols - 1); + const int gridX = (display.width() - gridW) / 2; + const int gridY = y + 2; + _task->setTileGridVY(gridY); + + LGFXDisplay* lcd = (LGFXDisplay*)&display; // watch display is always LGFXDisplay + for (int row = 0; row < rows; row++) { + for (int col = 0; col < cols; col++) { + int tx = gridX + col * (tileW + gapX); + int ty = gridY + row * (tileH + gapY); + lcd->setRawColor(TILE_FILL); + lcd->fillRoundRect(tx, ty, tileW, tileH, 4); + lcd->setRawColor(tiles[row][col].color); + lcd->drawRoundRect(tx, ty, tileW, tileH, 4); + display.setColor(DisplayDriver::LIGHT); + int iconX = tx + (tileW - HOME_ICON_W) / 2; + int iconY = ty + 3; + display.drawXbm(iconX, iconY, tiles[row][col].icon, HOME_ICON_W, HOME_ICON_H); + display.setTextSize(_node_prefs->smallTextSize()); + display.drawTextCentered(tx + tileW / 2, ty + 16, tiles[row][col].label); + } + } + display.setTextSize(1); + } + #else // Non-T5S3: keyboard shortcut menu #if defined(LILYGO_TECHO_LITE) @@ -786,6 +855,9 @@ public: #if defined(LilyGo_T5S3_EPaper_Pro) display.drawTextCentered(display.width() / 2, display.height() - 24, "Tap here for full Last Heard list"); +#elif defined(LILYGO_TWATCH_S3_PLUS) + display.drawTextCentered(display.width() / 2, display.height() - 24, + "Long Press: Full Last Heard List"); #else display.drawTextCentered(display.width() / 2, display.height() - 24, "H: Full Last Heard list"); @@ -844,6 +916,8 @@ public: display.setTextSize(1); #if defined(LilyGo_T5S3_EPaper_Pro) display.drawTextCentered(display.width() / 2, 80, "toggle: " PRESS_LABEL); +#elif defined(LILYGO_TWATCH_S3_PLUS) + display.drawTextCentered(display.width() / 2, 68, "toggle: " PRESS_LABEL); #else display.drawTextCentered(display.width() / 2, 68, "toggle: " PRESS_LABEL); display.drawTextCentered(display.width() / 2, 78, "or press Enter key"); @@ -897,6 +971,8 @@ public: #endif #if defined(LilyGo_T5S3_EPaper_Pro) display.drawTextCentered(display.width() / 2, 64, "advert: " PRESS_LABEL); +#elif defined(LILYGO_TWATCH_S3_PLUS) + display.drawTextCentered(display.width() / 2, 57, "advert: " PRESS_LABEL); #else display.drawTextCentered(display.width() / 2, 57, "advert: " PRESS_LABEL); display.drawTextCentered(display.width() / 2, 67, "or press Enter key"); @@ -1478,7 +1554,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no #ifdef HAS_4G_MODEM sms_screen = new SMSScreen(this, node_prefs); #endif -#if HAS_GPS && !defined(LILYGO_TECHO_CARD) +#if HAS_GPS && !defined(LILYGO_TECHO_CARD) && !defined(LILYGO_TWATCH_S3_PLUS) map_screen = new MapScreen(this); #else map_screen = nullptr; @@ -3326,8 +3402,8 @@ void UITask::gotoWebReader() { #if HAS_GPS void UITask::gotoMapScreen() { - if (!map_screen) return; // Not available on this platform (T-Echo Card) -#if !defined(LILYGO_TECHO_CARD) + if (!map_screen) return; // Not available on this platform (T-Echo Card, T-Watch) +#if !defined(LILYGO_TECHO_CARD) && !defined(LILYGO_TWATCH_S3_PLUS) MapScreen* map = (MapScreen*)map_screen; if (_display != NULL) { map->enter(*_display); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index e673a0bb..68663c85 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -34,7 +34,7 @@ #include "AlarmScreen.h" #endif -#if defined(LilyGo_T5S3_EPaper_Pro) +#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LILYGO_TWATCH_S3_PLUS) #include "VirtualKeyboard.h" #endif @@ -112,7 +112,7 @@ class UITask : public AbstractUITask { UIScreen* curr; bool _homeShowingTiles = false; // Set by HomeScreen render when tile grid is visible int _tileGridVY = 44; // Virtual Y of tile grid top (updated each render) -#if defined(LilyGo_T5S3_EPaper_Pro) +#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LILYGO_TWATCH_S3_PLUS) UIScreen* lock_screen; // Lock screen (big clock + battery + unread) UIScreen* _screenBeforeLock = nullptr; bool _locked = false; @@ -274,12 +274,12 @@ public: bool isOnSnakeScreen() const { return curr == snake_screen; } bool isOnMinesweeperScreen() const { return curr == minesweeper_screen; } bool isOnMapScreen() const { return curr == map_screen; } -#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) +#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) || defined(LILYGO_TWATCH_S3_PLUS) bool isLocked() const { return _locked; } void lockScreen(); void unlockScreen(); #endif -#if defined(LilyGo_T5S3_EPaper_Pro) +#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LILYGO_TWATCH_S3_PLUS) bool isVKBActive() const { return _vkbActive; } unsigned long vkbOpenedAt() const { return _vkbOpenedAt; } VirtualKeyboard& getVKB() { return _vkb; } diff --git a/examples/companion_radio/ui-new/VirtualKeyboard.h b/examples/companion_radio/ui-new/VirtualKeyboard.h index 39d7ed55..49b5379f 100644 --- a/examples/companion_radio/ui-new/VirtualKeyboard.h +++ b/examples/companion_radio/ui-new/VirtualKeyboard.h @@ -12,7 +12,7 @@ // if (keyboard.status() == VKB_SUBMITTED) { ... keyboard.getText() ... } // ============================================================================= -#if defined(LilyGo_T5S3_EPaper_Pro) +#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LILYGO_TWATCH_S3_PLUS) #ifndef VIRTUAL_KEYBOARD_H #define VIRTUAL_KEYBOARD_H @@ -536,4 +536,4 @@ private: }; #endif // VIRTUAL_KEYBOARD_H -#endif // LilyGo_T5S3_EPaper_Pro \ No newline at end of file +#endif // LilyGo_T5S3_EPaper_Pro || LILYGO_TWATCH_S3_PLUS \ No newline at end of file diff --git a/src/helpers/ui/LGFXDisplay.cpp b/src/helpers/ui/LGFXDisplay.cpp index a53cbc62..2b718e1b 100644 --- a/src/helpers/ui/LGFXDisplay.cpp +++ b/src/helpers/ui/LGFXDisplay.cpp @@ -56,16 +56,28 @@ void LGFXDisplay::setColor(Color c) { _color = TFT_WHITE; break; case RED: +#if defined(LILYGO_TWATCH_S3_PLUS) + _color = 0x7BEF; // dark grey -- the watch UI uses a grey/white/black palette +#else _color = TFT_RED; +#endif break; case GREEN: +#if defined(LILYGO_TWATCH_S3_PLUS) + _color = 0xC618; // light grey +#else _color = TFT_GREEN; +#endif break; case BLUE: _color = TFT_BLUE; break; case YELLOW: +#if defined(LILYGO_TWATCH_S3_PLUS) + _color = TFT_WHITE; +#else _color = TFT_YELLOW; +#endif break; case ORANGE: _color = TFT_ORANGE; @@ -113,7 +125,7 @@ void LGFXDisplay::endFrame() { bool LGFXDisplay::getTouch(int *x, int *y) { lgfx::v1::touch_point_t point; - display->getTouch(&point); + if (!display->getTouch(&point)) return false; if (UI_ZOOM != 1) { *x = point.x / UI_ZOOM; *y = point.y / UI_ZOOM; @@ -121,5 +133,5 @@ bool LGFXDisplay::getTouch(int *x, int *y) { *x = point.x; *y = point.y; } - return (*x >= 0) && (*y >= 0); + return true; } \ No newline at end of file diff --git a/src/helpers/ui/LGFXDisplay.h b/src/helpers/ui/LGFXDisplay.h index ad7212ec..f1f77b03 100644 --- a/src/helpers/ui/LGFXDisplay.h +++ b/src/helpers/ui/LGFXDisplay.h @@ -28,6 +28,26 @@ public: void startFrame(Color bkg = DARK) override; void setTextSize(int sz) override; void setColor(Color c) override; +#if defined(LILYGO_TWATCH_S3_PLUS) + // Set an exact RGB565 colour, bypassing the Color enum + watch grey remap. + // Used by the watch P4-style tile grid for per-tile border/fill colours. + void setRawColor(uint16_t c) { _color = c; } + // Rounded-rect helpers for the P4-style tile grid (LovyanGFX buffer). + void fillRoundRect(int x, int y, int w, int h, int r) { buffer.fillRoundRect(x, y, w, h, r, _color); } + void drawRoundRect(int x, int y, int w, int h, int r) { buffer.drawRoundRect(x, y, w, h, r, _color); } + // Render a string in a very small font at (x,y) so long node names fit + // beside the centred clock, then restore the default GLCD font + size so + // later text is unaffected. Uses the colour set via setColor()/setRawColor(). + void printSmallFont(int x, int y, const char* str) { + buffer.setFont(&fonts::TomThumb); + buffer.setTextColor(_color); + buffer.setTextSize(1); + buffer.setCursor(x, y); + buffer.print(str); + buffer.setFont(&fonts::Font0); // restore default 6x8 GLCD font + buffer.setTextSize(1); + } +#endif void setCursor(int x, int y) override; void print(const char* str) override; void fillRect(int x, int y, int w, int h) override; @@ -36,4 +56,4 @@ public: uint16_t getTextWidth(const char* str) override; void endFrame() override; virtual bool getTouch(int *x, int *y); -}; +}; \ No newline at end of file diff --git a/variants/lilygo_twatch_s3_plus/CPUPowerManager.h b/variants/lilygo_twatch_s3_plus/CPUPowerManager.h new file mode 100644 index 00000000..444a90bd --- /dev/null +++ b/variants/lilygo_twatch_s3_plus/CPUPowerManager.h @@ -0,0 +1,113 @@ +#pragma once + +#include + +// CPU Frequency Scaling for ESP32-S3 +// +// Typical current draw (CPU only, rough): +// 240 MHz ~70-80 mA +// 160 MHz ~50-60 mA +// 80 MHz ~30-40 mA +// 40 MHz ~15-20 mA (low-power / lock screen mode) +// +// SPI peripherals and UART use their own clock dividers from the APB clock, +// so LoRa, e-ink, and GPS serial all work fine at 80MHz and 40MHz. + +#ifdef ESP32 + +#ifndef CPU_FREQ_IDLE +#define CPU_FREQ_IDLE 80 // MHz — normal mesh listening +#endif + +#ifndef CPU_FREQ_BOOST +#define CPU_FREQ_BOOST 240 // MHz — heavy processing +#endif + +#ifndef CPU_FREQ_LOW_POWER +#define CPU_FREQ_LOW_POWER 80 // MHz — lock screen / idle standby (40 MHz breaks I2C) +#endif + +#ifndef CPU_BOOST_TIMEOUT_MS +#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds +#endif + +class CPUPowerManager { +public: + CPUPowerManager() : _boosted(false), _lowPower(false), _boost_started(0) {} + + void begin() { + setCpuFrequencyMhz(CPU_FREQ_IDLE); + _boosted = false; + _lowPower = false; + MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE); + } + + void loop() { + if (_boosted && (millis() - _boost_started >= CPU_BOOST_TIMEOUT_MS)) { + // Return to low-power if locked, otherwise normal idle + if (_lowPower) { + setCpuFrequencyMhz(CPU_FREQ_LOW_POWER); + MESH_DEBUG_PRINTLN("CPU power: boost expired, returning to low-power %d MHz", CPU_FREQ_LOW_POWER); + } else { + setCpuFrequencyMhz(CPU_FREQ_IDLE); + MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE); + } + _boosted = false; + } + } + + void setBoost() { + if (!_boosted) { + setCpuFrequencyMhz(CPU_FREQ_BOOST); + _boosted = true; + MESH_DEBUG_PRINTLN("CPU power: boosted to %d MHz", CPU_FREQ_BOOST); + } + _boost_started = millis(); + } + + void setIdle() { + if (_boosted) { + setCpuFrequencyMhz(CPU_FREQ_IDLE); + _boosted = false; + MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE); + } + if (_lowPower) { + _lowPower = false; + } + } + + // Low-power mode — drops CPU to 40 MHz for lock screen standby. + // If currently boosted, the boost timeout will return to 40 MHz + // instead of 80 MHz. + void setLowPower() { + _lowPower = true; + if (!_boosted) { + setCpuFrequencyMhz(CPU_FREQ_LOW_POWER); + MESH_DEBUG_PRINTLN("CPU power: low-power at %d MHz", CPU_FREQ_LOW_POWER); + } + // If boosted, the loop() timeout will drop to low-power instead of idle + } + + // Exit low-power mode — returns to normal idle (80 MHz). + // If currently boosted, the boost timeout will return to idle + // instead of low-power. + void clearLowPower() { + _lowPower = false; + if (!_boosted) { + setCpuFrequencyMhz(CPU_FREQ_IDLE); + MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz (low-power cleared)", CPU_FREQ_IDLE); + } + // If boosted, the loop() timeout will drop to idle as normal + } + + bool isBoosted() const { return _boosted; } + bool isLowPower() const { return _lowPower; } + uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); } + +private: + bool _boosted; + bool _lowPower; + unsigned long _boost_started; +}; + +#endif // ESP32 \ No newline at end of file diff --git a/variants/lilygo_twatch_s3_plus/GPSStreamCounter.h b/variants/lilygo_twatch_s3_plus/GPSStreamCounter.h new file mode 100644 index 00000000..5992fb71 --- /dev/null +++ b/variants/lilygo_twatch_s3_plus/GPSStreamCounter.h @@ -0,0 +1,72 @@ +#pragma once + +#include + +// Transparent Stream wrapper that counts NMEA sentences (newline-delimited) +// flowing from the GPS serial port to the MicroNMEA parser. +// +// Usage: Instead of MicroNMEALocationProvider gps(Serial2, &rtc_clock); +// Use: GPSStreamCounter gpsStream(Serial2); +// MicroNMEALocationProvider gps(gpsStream, &rtc_clock); +// +// Every read() call passes through to the underlying stream; when a '\n' +// is seen the sentence counter increments. This lets the UI display a +// live "nmea" count so users can confirm the baud rate is correct and +// the GPS module is actually sending data. + +class GPSStreamCounter : public Stream { +public: + GPSStreamCounter(Stream& inner) + : _inner(inner), _sentences(0), _sentences_snapshot(0), + _last_snapshot(0), _sentences_per_sec(0) {} + + // --- Stream read interface (passes through) --- + int available() override { return _inner.available(); } + int peek() override { return _inner.peek(); } + + int read() override { + int c = _inner.read(); + if (c == '\n') { + _sentences++; + } + return c; + } + + // --- Stream write interface (pass through for NMEA commands if needed) --- + size_t write(uint8_t b) override { return _inner.write(b); } + + // --- Sentence counting API --- + + // Total sentences received since boot (or last reset) + uint32_t getSentenceCount() const { return _sentences; } + + // Sentences received per second (updated each time you call it, + // with a 1-second rolling window) + uint16_t getSentencesPerSec() { + unsigned long now = millis(); + unsigned long elapsed = now - _last_snapshot; + if (elapsed >= 1000) { + uint32_t delta = _sentences - _sentences_snapshot; + // Scale to per-second if interval wasn't exactly 1000ms + _sentences_per_sec = (uint16_t)((delta * 1000UL) / elapsed); + _sentences_snapshot = _sentences; + _last_snapshot = now; + } + return _sentences_per_sec; + } + + // Reset all counters (e.g. when GPS hardware power cycles) + void resetCounters() { + _sentences = 0; + _sentences_snapshot = 0; + _sentences_per_sec = 0; + _last_snapshot = millis(); + } + +private: + Stream& _inner; + volatile uint32_t _sentences; + uint32_t _sentences_snapshot; + unsigned long _last_snapshot; + uint16_t _sentences_per_sec; +}; \ No newline at end of file diff --git a/variants/lilygo_twatch_s3_plus/TWatchS3PlusBoard.cpp b/variants/lilygo_twatch_s3_plus/TWatchS3PlusBoard.cpp new file mode 100644 index 00000000..deb8ee92 --- /dev/null +++ b/variants/lilygo_twatch_s3_plus/TWatchS3PlusBoard.cpp @@ -0,0 +1,79 @@ +#include +#include "TWatchS3PlusBoard.h" + +void TWatchS3PlusBoard::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 TWatchS3PlusBoard::power_init() { + PMU = new XPowersAXP2101(Wire, PIN_BOARD_SDA, PIN_BOARD_SCL, I2C_ADDR_PMU); + 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 per the T-Watch S3 Plus PowerManage table: + // ALDO2 = display backlight, ALDO3 = display + touch, + // ALDO4 = LoRa, BLDO1 = GNSS, BLDO2 = DRV2605, ALDO1 = unused. + PMU->setPowerChannelVoltage(XPOWERS_ALDO4, 3300); // LoRa radio + PMU->enablePowerOutput(XPOWERS_ALDO4); + PMU->setPowerChannelVoltage(XPOWERS_ALDO3, 3300); // display + touch + PMU->enablePowerOutput(XPOWERS_ALDO3); + PMU->setPowerChannelVoltage(XPOWERS_ALDO2, 3300); // display backlight + PMU->enablePowerOutput(XPOWERS_ALDO2); + PMU->setPowerChannelVoltage(XPOWERS_BLDO2, 3300); // DRV2605 haptic + PMU->enablePowerOutput(XPOWERS_BLDO2); + // GNSS (MIA-M10Q) on BLDO1 -- set the rail voltage but leave it OFF at boot. + // It is powered on demand via gpsPowerOn() when gps_enabled is set. + PMU->setPowerChannelVoltage(XPOWERS_BLDO1, 3300); + PMU->disablePowerOutput(XPOWERS_BLDO1); + + PMU->disablePowerOutput(XPOWERS_DCDC2); + PMU->disablePowerOutput(XPOWERS_DCDC3); + PMU->disablePowerOutput(XPOWERS_DCDC4); + PMU->disablePowerOutput(XPOWERS_DCDC5); + PMU->disablePowerOutput(XPOWERS_ALDO1); // unused on the Plus + 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_125MA); + PMU->setChargeTargetVoltage(XPOWERS_AXP2101_CHG_VOL_4V2); + + PMU->disableTSPinMeasure(); + PMU->enableSystemVoltageMeasure(); + PMU->enableVbusVoltageMeasure(); + PMU->enableBattVoltageMeasure(); + + PMU->setPowerKeyPressOffTime(XPOWERS_POWEROFF_4S); + return true; +} + +void TWatchS3PlusBoard::gpsPowerOn() { + if (PMU) { + PMU->enablePowerOutput(XPOWERS_BLDO1); + delay(100); // allow the module to boot before we expect NMEA + } +} + +void TWatchS3PlusBoard::gpsPowerOff() { + if (PMU) PMU->disablePowerOutput(XPOWERS_BLDO1); +} \ No newline at end of file diff --git a/variants/lilygo_twatch_s3_plus/TWatchS3PlusBoard.h b/variants/lilygo_twatch_s3_plus/TWatchS3PlusBoard.h new file mode 100644 index 00000000..0772261b --- /dev/null +++ b/variants/lilygo_twatch_s3_plus/TWatchS3PlusBoard.h @@ -0,0 +1,55 @@ +#pragma once + +#include "variant.h" // Board-specific pin definitions (I2C, user btn, addresses) + +#include +#include +#include "XPowersLib.h" +#include "helpers/ESP32Board.h" +#include + +// LilyGo T-Watch S3 Plus board. +// +// Power is managed by an AXP2101 PMU on the main I2C bus. The PMU power rails +// (per the T-Watch S3 Plus PowerManage table) are brought up in power_init(). +class TWatchS3PlusBoard : 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((1ULL << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); + } else { + esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1) | (1ULL << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); + } + + if (secs > 0) { + esp_sleep_enable_timer_wakeup(secs * 1000000ULL); + } + + esp_deep_sleep_start(); + } + + uint16_t getBattMilliVolts() override { + return PMU ? PMU->getBattVoltage() : 0; + } + + // GPS power is the AXP2101 BLDO1 rail. Off at boot; toggled at runtime via + // the gps_enabled pref (boot) and the "gps on/off" CLI command (live). + void gpsPowerOn(); + void gpsPowerOff(); + + const char* getManufacturerName() const override { + return "LilyGo T-Watch S3 Plus"; + } +}; \ No newline at end of file diff --git a/variants/lilygo_twatch_s3_plus/TWatchS3PlusDisplay.h b/variants/lilygo_twatch_s3_plus/TWatchS3PlusDisplay.h new file mode 100644 index 00000000..4af6c007 --- /dev/null +++ b/variants/lilygo_twatch_s3_plus/TWatchS3PlusDisplay.h @@ -0,0 +1,96 @@ +#pragma once + +// LovyanGFX display + touch for the LilyGo T-Watch S3 Plus. +// ST7789V3 240x240 on SPI3 + PWM backlight (GPIO45) + FT6336U capacitive touch +// on a separate I2C bus (Wire1: SDA 39 / SCL 40, INT 16, no RST pin). +// +// LGFXDisplay.h pulls in LovyanGFX (it defines LGFX_USE_V1 and includes +// LovyanGFX.hpp), so we do not redefine those here. + +#include + +class LGFX_TWatchS3Plus : 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_TWatchS3Plus(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 the LCD 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 TWatchS3PlusDisplay : public LGFXDisplay { + LGFX_TWatchS3Plus disp; +public: + TWatchS3PlusDisplay() : LGFXDisplay(240, 240, disp) {} +}; \ No newline at end of file diff --git a/variants/lilygo_twatch_s3_plus/platformio.ini b/variants/lilygo_twatch_s3_plus/platformio.ini new file mode 100644 index 00000000..8f0d81af --- /dev/null +++ b/variants/lilygo_twatch_s3_plus/platformio.ini @@ -0,0 +1,144 @@ +[LilyGo_TWatchS3Plus] +extends = esp32_base +extra_scripts = post:merge_firmware.py +board = lilygo_twatch_s3_plus +board_build.flash_mode = qio +board_build.f_flash = 80000000L +board_build.arduino.memory_type = qio_opi +board_upload.flash_size = 16MB +build_flags = + ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/lilygo_twatch_s3_plus + -D LILYGO_TWATCH_S3_PLUS + -D BOARD_HAS_PSRAM=1 + -D CORE_DEBUG_LEVEL=1 + -D FORMAT_SPIFFS_IF_FAILED=1 + -D FORMAT_LITTLEFS_IF_FAILED=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + ; ---- LoRa SX1262 ---- + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D SX126X_DIO2_AS_RF_SWITCH + -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 + -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) + FT6336U touch via LovyanGFX ---- + -D DISPLAY_CLASS=TWatchS3PlusDisplay + -D UI_ZOOM=2 + ; ---- Misc ---- + -D AUTO_SHUTDOWN_MILLIVOLTS=2800 + -D ARDUINO_LOOP_STACK_SIZE=32768 + ; ---- GPS (u-blox MIA-M10Q on Serial2) ---- + ; Rail-managed: BLDO1 is off at boot and gated by the gps_enabled pref + ; (defaults to 0) plus the "gps on/off" CLI command. + ; HAS_GPS is a build flag (not only variant.h) so it is defined globally when + ; UITask.h is parsed -- the gotoMapScreen() declaration is behind #if HAS_GPS, + ; and UITask.h is included before target.h/variant.h in the .cpp TUs. + ; PIN_GPS_RX/PIN_GPS_TX feed the shared initBasicGPS() Serial1 path and mirror + ; the GPS_RX_PIN/GPS_TX_PIN values in variant.h (used by the Serial2 provider). + -D HAS_GPS=1 + -D ENV_INCLUDE_GPS=1 + -D ENV_SKIP_GPS_DETECT=1 + -D PIN_GPS_RX=41 + -D PIN_GPS_TX=42 + -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_plus> + + +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + lovyan03/LovyanGFX @ ^1.2.0 + lewisxhe/XPowersLib @ ^0.2.7 + adafruit/Adafruit GFX Library @ ^1.11.0 + bitbank2/PNGdec @ ^1.0.1 + WebServer + DNSServer + Update + +; --------------------------------------------------------------------------- +; Phase 1: standalone (no BLE companion), touch + display + lock screen. +; MAX_CONTACTS=2000 -- contact + sort arrays allocated in PSRAM via +; BaseChatMesh::initContacts(). GPS is compiled in but its BLDO1 rail stays +; off until toggled on ("gps on"); maps, tiles and the on-screen keyboard are +; deferred to later phases. +; --------------------------------------------------------------------------- +[env:meck_twatch_standalone] +extends = LilyGo_TWatchS3Plus +build_flags = + ${LilyGo_TWatchS3Plus.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=2000 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=1 + -D FIRMWARE_VERSION='"Meck TWatch v0.1"' +build_src_filter = ${LilyGo_TWatchS3Plus.build_src_filter} + + + - + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> + + +lib_deps = + ${LilyGo_TWatchS3Plus.lib_deps} + densaugeo/base64 @ ~1.4.0 +lib_ignore = + AsyncTCP + ESPAsyncWebServer + ESP32 BLE Arduino + +; --------------------------------------------------------------------------- +; BLE companion build -- same touch UI as the standalone, plus the BLE serial +; interface so a phone app can connect on occasion. Differs from standalone: +; BLE_PIN_CODE set, OFFLINE_QUEUE_SIZE raised, SerialBLEInterface.cpp compiled +; in (no longer excluded), and the BLE Arduino lib no longer in lib_ignore. +; Contacts stay PSRAM-allocated via initContacts(), covering the BLE stack's +; internal-SRAM use. ESP32_CPU_FREQ=80 sets the boot clock low; note that +; CPUPowerManager already governs the idle clock to 80 MHz at runtime. +; Flash: pio run -e meck_twatch_ble -t upload +; --------------------------------------------------------------------------- +[env:meck_twatch_ble] +extends = LilyGo_TWatchS3Plus +build_flags = + ${LilyGo_TWatchS3Plus.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=2000 + -D MAX_GROUP_CHANNELS=20 + -D BLE_PIN_CODE=123456 + -D OFFLINE_QUEUE_SIZE=256 + -D ESP32_CPU_FREQ=80 + -D FIRMWARE_VERSION='"Meck TWatch BLE v0.1"' +build_src_filter = ${LilyGo_TWatchS3Plus.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> + + +lib_deps = + ${LilyGo_TWatchS3Plus.lib_deps} + densaugeo/base64 @ ~1.4.0 +lib_ignore = + AsyncTCP + ESPAsyncWebServer \ No newline at end of file diff --git a/variants/lilygo_twatch_s3_plus/target.cpp b/variants/lilygo_twatch_s3_plus/target.cpp new file mode 100644 index 00000000..45fcd591 --- /dev/null +++ b/variants/lilygo_twatch_s3_plus/target.cpp @@ -0,0 +1,69 @@ +#include +#include "variant.h" +#include "target.h" + +TWatchS3PlusBoard board; + +// LoRa SX1262 on its own SPI bus (the display uses SPI3_HOST separately). +static SPIClass loraSpi; +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, loraSpi); + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +// GPS: u-blox MIA-M10Q on Serial2 (the GPS pins are reclaimed from Serial1 to +// Serial2 in main.cpp setup(), matching the Meck T-Deck Pro pattern). The +// BLDO1 rail that powers the module is off at boot and gated by gps_enabled. +#if HAS_GPS + GPSStreamCounter gpsStream(Serial2); + MicroNMEALocationProvider gps(gpsStream, &rtc_clock); + EnvironmentSensorManager sensors(gps); +#else + SensorManager sensors; +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +bool radio_init() { + // NOTE: board.begin() is called by main.cpp setup() before radio_init(); + // Wire is already initialised there with the correct pins. + fallback_clock.begin(); + rtc_clock.begin(Wire); + + loraSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS); + return radio.std_init(&loraSpi); +} + +uint32_t radio_get_rng_seed() { + return radio.random(0x7FFFFFFF); +} + +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) { + radio.setFrequency(freq); + radio.setSpreadingFactor(sf); + radio.setBandwidth(bw); + radio.setCodingRate(cr); + + // Longer preamble for low SF improves reliability -- each symbol is shorter + // at low SF, so more symbols are needed for reliable detection. + uint16_t preamble = (sf <= 8) ? 32 : 16; + radio.setPreambleLength(preamble); +} + +void radio_set_tx_power(uint8_t dbm) { + radio.setOutputPower(dbm); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} + +void radio_reset_agc() { + radio.setRxBoostedGainMode(true); +} \ No newline at end of file diff --git a/variants/lilygo_twatch_s3_plus/target.h b/variants/lilygo_twatch_s3_plus/target.h new file mode 100644 index 00000000..da37c025 --- /dev/null +++ b/variants/lilygo_twatch_s3_plus/target.h @@ -0,0 +1,47 @@ +#pragma once + +// Include variant.h first to ensure all board-specific defines are available +#include "variant.h" + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include + +#ifdef DISPLAY_CLASS + #include + #include +#endif + +#if HAS_GPS + #include "helpers/sensors/EnvironmentSensorManager.h" + #include "helpers/sensors/MicroNMEALocationProvider.h" + #include "GPSStreamCounter.h" +#else + #include +#endif + +extern TWatchS3PlusBoard board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; + +#if HAS_GPS + extern GPSStreamCounter gpsStream; + extern EnvironmentSensorManager sensors; +#else + extern SensorManager sensors; +#endif + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +uint32_t radio_get_rng_seed(); +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr); +void radio_set_tx_power(uint8_t dbm); +mesh::LocalIdentity radio_new_identity(); +void radio_reset_agc(); \ No newline at end of file diff --git a/variants/lilygo_twatch_s3_plus/variant.h b/variants/lilygo_twatch_s3_plus/variant.h new file mode 100644 index 00000000..0084821d --- /dev/null +++ b/variants/lilygo_twatch_s3_plus/variant.h @@ -0,0 +1,79 @@ +#pragma once + +// ============================================================================= +// LilyGo T-Watch S3 Plus - Board-level pin definitions +// Source: LilyGo hardware doc (lilygo-t-watch-s3-plus.md) and the working +// MeshCore companion build. +// +// NOTE: LoRa (P_LORA_*) pins and the SX126x radio parameters are supplied as +// -D build flags in this variant's platformio.ini, matching the Meck T-Deck +// Pro convention. The display SPI/backlight and FT6336U touch pins live inline +// in TWatchS3PlusDisplay.h because LovyanGFX needs them in its panel config. +// ============================================================================= + +// ----------------------------------------------------------------------------- +// Main I2C bus (shared): AXP2101 PMU, PCF8563 RTC, BMA423 accel, DRV2605 haptic +// The FT6336U touch panel is on a SEPARATE bus (Wire1), configured in the +// display class -- it is not on this bus. +// ----------------------------------------------------------------------------- +#define I2C_SDA 10 +#define I2C_SCL 11 + +// Aliases for ESP32Board base class compatibility +#define PIN_BOARD_SDA I2C_SDA +#define PIN_BOARD_SCL I2C_SCL + +// ----------------------------------------------------------------------------- +// I2C device addresses (7-bit) +// ----------------------------------------------------------------------------- +#define I2C_ADDR_PMU 0x34 // AXP2101 power management +#define I2C_ADDR_RTC 0x51 // PCF8563 real-time clock +#define I2C_ADDR_ACCEL 0x19 // BMA423 accelerometer +#define I2C_ADDR_HAPTIC 0x5A // DRV2605 haptic driver +#define I2C_ADDR_TOUCH 0x38 // FT6336U capacitive touch (on Wire1) + +// ----------------------------------------------------------------------------- +// Interrupt / control pins +// ----------------------------------------------------------------------------- +#ifndef PIN_PMU_IRQ + #define PIN_PMU_IRQ 21 // AXP2101 interrupt +#endif +#define PIN_RTC_IRQ 17 // PCF8563 interrupt +#define PIN_ACCEL_IRQ 14 // BMA423 interrupt + +// User button -- GPIO0 "Custom Button" on the T-Watch S3 Plus (idles HIGH, +// active LOW), the same arrangement Meck uses on the T-Deck Pro. +#define PIN_USER_BTN 0 + +// ----------------------------------------------------------------------------- +// Display dimensions (ST7789V3 IPS, 240x240) +// ----------------------------------------------------------------------------- +#define LCD_HOR_SIZE 240 +#define LCD_VER_SIZE 240 + +// ----------------------------------------------------------------------------- +// Storage +// ----------------------------------------------------------------------------- +// The T-Watch S3 Plus has no SD card slot. Notes/Reader/Epub screens reference +// SDCARD_CS unconditionally, so it must be defined for them to compile. -1 is a +// safe no-op on ESP32 (digitalWrite/pinMode reject out-of-range pins), and SD +// mounts will simply fail at runtime. Persistent storage arrives in Phase 3 via +// a LittleFS partition. +#define SDCARD_CS -1 + +// ----------------------------------------------------------------------------- +// GPS (u-blox MIA-M10Q on Serial2) +// Pin values mirror the working MeshCore companion build. Power is on the +// AXP2101 BLDO1 rail and is gated at runtime via the gps_enabled pref + +// board.gpsPowerOn()/gpsPowerOff() -- it is NOT a GPIO enable line, so +// PIN_GPS_EN is deliberately left undefined (the main.cpp PIN_GPS_EN blocks +// are skipped and the watch branch toggles BLDO1 instead). +// +// NOTE: GPS RX/TX wiring labelling is ambiguous between the LilyGo doc and the +// MeshCore build. These values match the verified MeshCore config; if the +// module never acquires, swapping GPS_RX_PIN/GPS_TX_PIN is the first thing to try. +// ----------------------------------------------------------------------------- +#define HAS_GPS 1 +#define GPS_BAUDRATE 38400 +#define GPS_RX_PIN 41 +#define GPS_TX_PIN 42 \ No newline at end of file