Remove T-Watch code from repo

This commit is contained in:
pelgraine
2026-07-19 11:55:31 +10:00
parent 1d03b64e92
commit e447b17f37
49 changed files with 33 additions and 7863 deletions
-54
View File
@@ -1,54 +0,0 @@
{
"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"
}
-41
View File
@@ -1,41 +0,0 @@
{
"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"
}
+2 -18
View File
@@ -12,7 +12,7 @@
#include "ModemManager.h" // Serial CLI modem commands
#endif
#if defined(LilyGo_TDeck_Pro_Max) || defined(LILYGO_TWATCH_S3)
#if defined(LilyGo_TDeck_Pro_Max)
#include "DRV2605Haptic.h" // inline haptic driver for the 'buzz' CLI test command
#endif
@@ -1427,20 +1427,8 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
// auto-discovery overwrite it. Skip the base class call entirely — ACKs
// embedded in path responses will still be delivered via separate ACK packets.
if (contact.flags & CONTACT_FLAG_CUSTOM_PATH) {
#if defined(MECK_TWATCH)
// Watch: keep the manually set path (don't let discovery overwrite it), but
// STILL deliver an embedded login/response reply riding on this path packet
// -- otherwise logins to custom-path contacts silently time out. Gated to
// the watch so other builds' admin flow is unchanged.
MESH_DEBUG_PRINTLN("onContactPathRecv: custom-path %s, keeping path, delivering response (type=%d len=%d)", contact.name, extra_type, extra_len);
if (extra_type == PAYLOAD_TYPE_RESPONSE && extra_len > 0) {
onContactResponse(contact, extra, extra_len);
}
return false;
#else
MESH_DEBUG_PRINTLN("onContactPathRecv: skipping path update for custom-path contact %s", contact.name);
return false;
#endif
}
return BaseChatMesh::onContactPathRecv(contact, in_path, in_path_len, out_path, out_path_len, extra_type, extra, extra_len);
}
@@ -2561,10 +2549,6 @@ void MyMesh::handleCmdFrame(size_t len) {
if (strcmp(sp, "gps") == 0) {
_prefs.gps_enabled = (np[0] == '1') ? 1 : 0;
savePrefs();
#if defined(MECK_TWATCH)
// 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);
@@ -3748,7 +3732,7 @@ void MyMesh::checkCLIRescueCmd() {
}
}
#if defined(LilyGo_TDeck_Pro_Max) || defined(LILYGO_TWATCH_S3)
#if defined(LilyGo_TDeck_Pro_Max)
else if (strncmp(cli_command, "buzz", 4) == 0 &&
(cli_command[4] == '\0' || cli_command[4] == ' ')) {
// Fire a DRV2605 library-1 effect to confirm the motor works.
+1 -1
View File
@@ -109,7 +109,7 @@ struct NodePrefs { // persisted to file
// upward → fillRect must start above baseline to cover ascenders.
// T5S3: always 0 (both sizes use baseline fonts with highlight at y).
inline int smallHighlightOff() const {
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
#if defined(LilyGo_T5S3_EPaper_Pro)
return 0;
#else
// Custom 7pt fonts at textSize 0 use GFXfont (baseline rendering), not built-in.
+5 -403
View File
@@ -709,31 +709,13 @@
// =============================================================================
// Define MECK_TOUCH_ENABLED for any platform with touch support
#if defined(LilyGo_T5S3_EPaper_Pro) || (defined(LilyGo_TDeck_Pro) && defined(HAS_TOUCHSCREEN)) || defined(MECK_TWATCH)
#if defined(LilyGo_T5S3_EPaper_Pro) || (defined(LilyGo_TDeck_Pro) && defined(HAS_TOUCHSCREEN))
#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(MECK_TWATCH)
#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)
@@ -901,8 +883,6 @@
#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(MECK_TWATCH)
#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
@@ -939,18 +919,6 @@
}
#elif defined(LilyGo_TDeck_Pro)
return touchInput.getPoint(*outX, *outY);
#elif defined(MECK_TWATCH)
{
// 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
@@ -964,11 +932,6 @@
#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(MECK_TWATCH)
// 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)
@@ -1058,9 +1021,6 @@ static uint32_t _atoi(const char* sp) {
DataStore store(LittleFS, rtc_clock);
#elif defined(ESP32)
#include <SPIFFS.h>
#if defined(MECK_TWATCH)
#include <LittleFS.h> // watch: dedicated 'maps' tiles partition
#endif
DataStore store(SPIFFS, rtc_clock);
#endif
@@ -1125,13 +1085,7 @@ static uint32_t _atoi(const char* sp) {
/* GLOBAL OBJECTS */
#ifdef DISPLAY_CLASS
#include "UITask.h"
#if defined(MECK_TWATCH)
#include "WatchNotesScreen.h" // After UITask.h -- needs NodePrefs
#include "WatchChannelConfigScreen.h" // After UITask.h -- needs NodePrefs, the_mesh
#endif
#if defined(MECK_TWATCH) && HAS_GPS
#include "WatchMapScreen.h" // After BLE -- PNGdec headers conflict with BLE if included earlier
#elif HAS_GPS && !defined(LILYGO_TECHO_CARD)
#if HAS_GPS && !defined(LILYGO_TECHO_CARD)
#include "MapScreen.h" // After BLE -- PNGdec headers conflict with BLE if included earlier
#endif
UITask ui_task(&board, &serial_interface);
@@ -1348,15 +1302,7 @@ static void lastHeardToggleContact() {
// Snake screen: tap = Enter (start / restart)
if (ui_task.isOnSnakeScreen()) {
#if defined(MECK_TWATCH)
// Watch: tap starts/restarts, or steers by tapping the up/down/left/right
// zone relative to the board centre while playing.
SnakeScreen* sk = (SnakeScreen*)ui_task.getSnakeScreen();
if (sk) sk->handleTap(x, y);
return 0;
#else
return '\r';
#endif
}
// Minesweeper screen: tap = Enter (reveal cell / start / restart)
@@ -1364,49 +1310,6 @@ static void lastHeardToggleContact() {
return '\r';
}
#if defined(MECK_TWATCH) && HAS_GPS
// Map screen: footer +/- buttons zoom; a tap in the map area recenters on
// GPS. (A top-strip tap is already handled above -> home.)
if (ui_task.isOnMapScreen()) {
WatchMapScreen* wms = (WatchMapScreen*)ui_task.getMapScreen();
if (wms) wms->handleTap(x, y);
return 0;
}
#endif
#if defined(MECK_TWATCH)
// Notes screen: list rows, view scroll zones and the footer are all tap
// targets, in the same logical coords as the alarm screen.
if (ui_task.isOnWatchNotesScreen()) {
WatchNotesScreen* wn = (WatchNotesScreen*)ui_task.getWatchNotesScreen();
if (wn) wn->handleTap(x, y);
return 0;
}
// Channel config screen: list rows, detail action rows and the footer are
// all tap targets, in the same logical coords as the alarm/notes screens.
if (ui_task.isOnWatchChannelConfigScreen()) {
WatchChannelConfigScreen* wc = (WatchChannelConfigScreen*)ui_task.getWatchChannelConfigScreen();
if (wc) wc->handleTap(x, y);
return 0;
}
#endif
#ifdef TWATCH_COMPOSE_ENABLED
// TWatch channel screen: touch is handled entirely in the screen's own
// poll() (ticker select, hop-overlay dismiss, compose bar). Do not
// synthesise keys here -- the hop-path overlay must open only on the
// physical button (PWR on the S3 / boot on the Plus), whose KEY_ENTER /
// KEY_NEXT arrive via a separate inject path.
if (ui_task.isOnTWatchChannelScreen()) {
return 0;
}
#endif
#if defined(MECK_TWATCH)
// Watch: tiles open on long-press and pages change on swipe, so a plain tap
// on any home page does nothing -- skip the T5S3 tile hit-test below (wrong
// geometry for the 2-column watch grid) and the left/right page cycling.
if (ui_task.isOnHomeScreen()) {
return 0;
}
#endif
// Home screen FIRST page: tile taps (virtual coordinate hit test)
if (ui_task.isOnHomeScreen() && ui_task.isHomeShowingTiles()) {
const int tileW = 40, tileH = 22, gapX = 1, gapY = 1;
@@ -1576,81 +1479,6 @@ static void lastHeardToggleContact() {
if (ui_task.isOnContactsScreen()) {
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
if (cs) {
#if defined(MECK_TWATCH)
if (cs->isActionOverlayOpen()) {
int btn = cs->actionOverlayButtonAtVY(vy);
if (btn == 0) {
// Open -- route by contact type (mirrors the pre-overlay long-press open)
cs->closeActionOverlay();
int idx = cs->getSelectedContactIdx();
uint8_t ctype = cs->getSelectedContactType();
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
if (ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
return 0;
}
ui_task.openTWatchKeyboard(1, idx); // TWKB_DM
return 0;
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
ui_task.gotoRepeaterAdmin(idx);
return 0;
} else if (idx >= 0 && ctype == ADV_TYPE_ROOM) {
ui_task.gotoRepeaterAdmin(idx);
return 0;
} else if (idx >= 0 && ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
return 0;
}
ui_task.forceRefresh();
return 0;
} else if (btn == 1) {
// Favourite -- flip flags bit 0 on the selected contact and persist
int idx = cs->getSelectedContactIdx();
if (idx >= 0) {
ContactInfo tmp;
if (the_mesh.getContactByIdx(idx, tmp)) {
ContactInfo* cp = the_mesh.lookupContactByPubKey(tmp.id.pub_key, PUB_KEY_SIZE);
if (cp) {
cp->flags ^= 0x01;
the_mesh.saveContacts();
ui_task.showAlert((cp->flags & 0x01) ? "Favourited" : "Unfavourited", 1500);
}
}
}
cs->closeActionOverlay();
cs->invalidateCache();
ui_task.forceRefresh();
return 0;
} else if (btn == 2) {
// Delete -- remove the selected contact and persist
int idx = cs->getSelectedContactIdx();
if (idx >= 0) {
ContactInfo c;
if (the_mesh.getContactByIdx(idx, c)) {
if (the_mesh.removeContact(c)) {
the_mesh.saveContacts();
ui_task.showAlert("Contact deleted", 1500);
}
}
}
cs->closeActionOverlay();
cs->invalidateCache();
ui_task.forceRefresh();
return 0;
} else {
// Tap outside the buttons -- dismiss
cs->closeActionOverlay();
ui_task.forceRefresh();
return 0;
}
}
#endif
int result = cs->selectRowAtVY(vy);
if (result == 1) {
ui_task.forceRefresh();
@@ -1682,27 +1510,6 @@ static void lastHeardToggleContact() {
return 0;
}
#if defined(MECK_TWATCH)
// Trace screen (watch): tap to select row, tap same to activate. Mode row
// toggles 1/2-byte; Type Path opens the keyboard (handleInput -> wantsKeyboard).
// Guarded to the watch: selectRowAtVY uses the watch's 128-space coords, and
// T5S3/T-Deck keep their existing boot-button/keyboard trace handling.
if (ui_task.isOnTraceScreen()) {
TraceScreen* ts = (TraceScreen*)ui_task.getTraceScreen();
if (ts) {
int result = ts->selectRowAtVY(vy);
if (result == 1) {
ui_task.forceRefresh();
return 0;
}
if (result == 2) {
if (ts->isOnModeRow()) return 'd'; // toggle 1-byte / 2-byte
return KEY_ENTER; // Type Path / Add / Remove / Run / Exit / hop
}
}
return 0;
}
#endif
// Discovery screen: tap to select, tap same to add
if (ui_task.isOnDiscoveryScreen()) {
@@ -1738,16 +1545,6 @@ static void lastHeardToggleContact() {
// Settings screen: tap to select row, tap same row to activate
if (ui_task.isOnSettingsScreen()) {
SettingsScreen* ss = (SettingsScreen*)ui_task.getSettingsScreen();
#if defined(MECK_TWATCH)
// Watch: while editing UTC offset, path hash size, or TX power, tap the
// upper half to increment and the lower half to decrement. Long-press saves.
if (ss && ss->isEditing() &&
(ss->getCurrentRowType() == ROW_UTC_OFFSET ||
ss->getCurrentRowType() == ROW_PATH_HASH_SIZE ||
ss->getCurrentRowType() == ROW_TX_POWER)) {
return (vy < 64) ? 'w' : 's';
}
#endif
if (ss && !ss->isEditing()) {
int result = ss->selectRowAtVY(vy);
if (result == 1) {
@@ -1765,27 +1562,6 @@ static void lastHeardToggleContact() {
if (ui_task.isOnSMSScreen()) return 0;
#endif
#if defined(MECK_TWATCH)
// Repeater admin (watch): tap selects a menu row (tap again = activate);
// param entry opens the keyboard for the value.
if (ui_task.isOnRepeaterAdmin()) {
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen();
if (admin) {
RepeaterAdminScreen::AdminState astate = admin->getState();
if (astate == RepeaterAdminScreen::STATE_CATEGORY_MENU ||
astate == RepeaterAdminScreen::STATE_COMMAND_MENU) {
int result = admin->selectRowAtVY(vy);
if (result == 1) { ui_task.forceRefresh(); return 0; }
if (result == 2) return KEY_ENTER; // tapped current row -- activate
return 0;
}
if (astate == RepeaterAdminScreen::STATE_PARAM_ENTRY) {
ui_task.openTWatchKeyboard(3, 0); // tap opens keyboard for the value
return 0;
}
}
}
#endif
// All other screens: tap = select
return KEY_ENTER;
@@ -1800,14 +1576,6 @@ static void lastHeardToggleContact() {
if (ui_task.isOnSMSScreen()) return 0;
#endif
#if defined(MECK_TWATCH) && HAS_GPS
// Map screen: swipe pans the viewport (logical-pixel delta)
if (ui_task.isOnMapScreen()) {
WatchMapScreen* wms = (WatchMapScreen*)ui_task.getMapScreen();
if (wms) wms->panByPixels(dx, dy);
return 0;
}
#endif
// Snake screen: swipes control direction
if (ui_task.isOnSnakeScreen()) {
@@ -1907,11 +1675,6 @@ static void lastHeardToggleContact() {
if (ui_task.isOnSMSScreen()) return 0;
#endif
#ifdef TWATCH_COMPOSE_ENABLED
// TWatch channel screen: no synthesised keys (see mapTouchTap) -- a long
// press on the header/messages must not open the hop-path overlay.
if (ui_task.isOnTWatchChannelScreen()) return 0;
#endif
// Snake screen: long press exits to games menu
if (ui_task.isOnSnakeScreen()) {
@@ -1928,56 +1691,6 @@ static void lastHeardToggleContact() {
return 'q';
}
#if defined(MECK_TWATCH)
// 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 = 4;
const int tileW = 56, tileH = 24, gapX = 4, gapY = 2;
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 > 3) row = 3;
if (row == 0 && col == 0) { ui_task.openTWatchPicker(); 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; }
#if defined(LILYGO_TWATCH_S3)
// No GNSS on the plain S3, so this slot is the games launcher.
if (row == 2 && col == 1) { ui_task.gotoGamesMenu(); return 0; }
#elif HAS_GPS
if (row == 2 && col == 1) {
// Maps: mark the tile FS ready (detectZoomRange in enter() needs it)
// before opening; GPS centre + markers are populated in the main loop.
WatchMapScreen* wms = (WatchMapScreen*)ui_task.getMapScreen();
if (wms) wms->setMapsReady(LittleFS.exists("/tiles"));
ui_task.gotoMapScreen();
return 0;
}
#endif
if (row == 3 && col == 0) { ui_task.gotoNotesScreen(); return 0; }
if (row == 3 && col == 1) { ui_task.gotoStepsScreen(); return 0; }
}
return 0; // consume long-press on the tile page
}
#endif
#if defined(MECK_TWATCH)
// Steps screen: long press opens the 7-day history subscreen.
// (On the S3 the PWR key's short press also delivers KEY_ENTER here.)
if (ui_task.isOnStepsScreen()) {
return (char)KEY_ENTER;
}
#endif
// Home screen: long press = activate current page action
// (BLE toggle, send advert, hibernate, GPS toggle, etc.)
@@ -2113,13 +1826,6 @@ static void lastHeardToggleContact() {
}
}
return 0;
#elif defined(MECK_TWATCH)
// Watch: long press raises the contact action overlay
// ([Open] [Favourite] [Delete]). The tap handler dispatches the choice;
// "Open" reproduces the type-aware DM/admin routing this branch used to do.
cs->openActionOverlay();
ui_task.forceRefresh();
return 0;
#else
// T-Deck Pro: long press enters select mode
cs->enterSelectMode();
@@ -2165,22 +1871,10 @@ static void lastHeardToggleContact() {
#if defined(LilyGo_T5S3_EPaper_Pro)
ui_task.showVirtualKeyboard(VKB_ADMIN_PASSWORD, "Admin Password", "", 32);
return 0;
#elif defined(MECK_TWATCH)
ui_task.openTWatchKeyboard(2, 0); // TWKB_ADMIN_PASSWORD
return 0;
#else
return KEY_ENTER; // T-Deck Pro: keyboard handles password entry
#endif
}
#if defined(MECK_TWATCH)
// Watch: keyboard only for param entry; menu/other states activate the
// highlighted item (no raw CLI keyboard).
if (astate == RepeaterAdminScreen::STATE_PARAM_ENTRY) {
ui_task.openTWatchKeyboard(3, 0); // param value entry
return 0;
}
return KEY_ENTER;
#endif
}
}
@@ -2197,24 +1891,9 @@ static void lastHeardToggleContact() {
if (ui_task.isOnSettingsScreen()) {
SettingsScreen* ss = (SettingsScreen*)ui_task.getSettingsScreen();
if (ss) {
#if defined(MECK_TWATCH)
// Watch: long-press while editing UTC offset, path hash size, or TX
// power saves the value.
if (ss->isEditing() &&
(ss->getCurrentRowType() == ROW_UTC_OFFSET ||
ss->getCurrentRowType() == ROW_PATH_HASH_SIZE ||
ss->getCurrentRowType() == ROW_TX_POWER)) {
return '\r';
}
#endif
if (ss->isEditing()) {
return 0; // Consume — don't interfere with active edit mode
}
#if defined(MECK_TWATCH)
// Aim activation at the row under the finger, not just the current
// cursor, so a single long-press opens/edits the pressed row.
{ int vx, vy; touchToVirtual(x, y, vx, vy); ss->selectRowAtVY(vy); }
#endif
if (ss->isOnDeletableChannel()) {
return 'x'; // Long press on channel row → delete
}
@@ -2446,19 +2125,6 @@ void setup() {
}
MESH_DEBUG_PRINTLN("setup() - SPIFFS.begin() done");
#if defined(MECK_TWATCH)
// Mount the dedicated 'maps' partition as LittleFS (map tiles live here,
// kept separate from the SPIFFS DataStore that holds identity/contacts).
// formatOnFail=true so a freshly flashed (raw) maps partition is formatted
// and immediately usable for ESPConnect tile uploads.
if (LittleFS.begin(true, "/maps", 10, "maps")) {
Serial.printf("maps: LittleFS mounted (%u KB total, %u KB used)\n",
(unsigned)(LittleFS.totalBytes() / 1024),
(unsigned)(LittleFS.usedBytes() / 1024));
} else {
Serial.println("maps: LittleFS mount FAILED");
}
#endif
// ---------------------------------------------------------------------------
// Early SD card init -- needed BEFORE the_mesh.begin() so we can restore
@@ -2904,15 +2570,6 @@ void setup() {
Serial.println("setup() - SD features initialized");
}
#endif
#if defined(MECK_TWATCH)
// Watch: load persisted channel-message history from LittleFS (no SD).
{
ChannelScreen* chanScr = (ChannelScreen*)ui_task.getChannelScreen();
if (chanScr && chanScr->loadFromLittleFS()) {
MESH_DEBUG_PRINTLN("setup() - Message history loaded from LittleFS");
}
}
#endif
// Check if node name is still the default hex prefix (first 4 bytes of pub key)
// If so, launch onboarding wizard to set name and radio preset
// ---------------------------------------------------------------------------
@@ -2940,9 +2597,6 @@ void setup() {
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
#endif
#if defined(MECK_TWATCH)
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
@@ -2960,9 +2614,6 @@ void setup() {
#if defined(LilyGo_TDeck_Pro_Max)
board.gpsPowerOff(); // MAX: GPS power is XL9555-routed, not PIN_GPS_EN
#endif
#if defined(MECK_TWATCH)
board.gpsPowerOff(); // GPS power is the AXP2101 BLDO1 rail
#endif
sensors.setSettingValue("gps", "0");
}
Serial.printf("GPS: power %s\n", gps_wanted ? "ON" : "OFF");
@@ -3034,14 +2685,6 @@ void otaResumeRadio() {
#endif
void loop() {
#if defined(MECK_TWATCH)
// Watch: flush pending channel-message history to LittleFS on a 30s debounce
// (saveToSD marks it dirty on each message; at most one ~57 KB write / 30s).
{
ChannelScreen* chanScr = (ChannelScreen*)ui_task.getChannelScreen();
if (chanScr) chanScr->flushMessagesIfDue(30000UL);
}
#endif
// T-Echo Card: lazy Codec2 init from shallow stack context.
// codec2_create needs ~3KB stack for FFT/trig init. The loop task
// has only 4KB total. Calling from render() (deep call chain) overflows.
@@ -3146,7 +2789,7 @@ void loop() {
// Map screen: periodically update own GPS position and contact markers
#ifdef DISPLAY_CLASS
#if HAS_GPS && !defined(LILYGO_TECHO_CARD) && !defined(MECK_TWATCH)
#if HAS_GPS && !defined(LILYGO_TECHO_CARD)
if (ui_task.isOnMapScreen()) {
static unsigned long lastMapUpdate = 0;
if (millis() - lastMapUpdate > 30000) { // Every 30 seconds
@@ -3173,41 +2816,6 @@ void loop() {
#endif
#endif
#if defined(MECK_TWATCH) && HAS_GPS
// Watch map screen: on open, centre on GPS and populate contact markers; then
// refresh own position + markers periodically while it stays open.
{
static bool mapWasOpen = false;
static unsigned long lastMapUpdate = 0;
if (ui_task.isOnMapScreen()) {
WatchMapScreen* wms = (WatchMapScreen*)ui_task.getMapScreen();
if (wms) {
bool justOpened = !mapWasOpen;
mapWasOpen = true;
if (justOpened || (millis() - lastMapUpdate > 30000)) {
lastMapUpdate = millis();
if (justOpened) {
wms->setGPSPosition(sensors.node_lat, sensors.node_lon);
} else {
wms->updateGPSPosition(sensors.node_lat, sensors.node_lon);
}
wms->clearMarkers();
ContactsIterator it = the_mesh.startContactsIterator();
ContactInfo ci;
while (it.hasNext(&the_mesh, ci)) {
if (ci.gps_lat != 0 || ci.gps_lon != 0) {
double lat = ((double)ci.gps_lat) / 1000000.0;
double lon = ((double)ci.gps_lon) / 1000000.0;
wms->addMarker(lat, lon, ci.name, ci.type);
}
}
}
}
} else {
mapWasOpen = false;
}
}
#endif
// CPU frequency auto-timeout back to idle
cpuPower.loop();
@@ -3216,7 +2824,7 @@ void loop() {
// is active. The mesh radio has its own FIFO so packets are buffered;
// 50 ms yield means the loop still runs 20×/sec which is more than enough
// to drain the radio FIFO before overflow.
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) || defined(MECK_TWATCH)
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
{
static bool wasLocked = false;
bool nowLocked = ui_task.isLocked();
@@ -4031,12 +3639,6 @@ void loop() {
// Channel screen: horizontal swipe opens picker instead of cycling
if (ui_task.isOnChannelScreen() && (c == 'a' || c == 'd')) {
ui_task.gotoChannelPickerScreen();
#if defined(MECK_TWATCH)
} else if (ui_task.isOnChannelScreen() && (c == 'w' || c == 's')) {
// Watch: a vertical swipe pages the message view (one page/swipe).
ChannelScreen* cs = (ChannelScreen*)ui_task.getChannelScreen();
if (cs) { cs->pageScroll(c == 'w'); ui_task.forceRefresh(); }
#endif
} else {
ui_task.injectKey(c);
}
@@ -4655,7 +4257,7 @@ void loop() {
// The RTOS idle task executes WFI (wait-for-interrupt) during delay(),
// dramatically reducing CPU power draw. 50 ms gives 20 loop cycles/sec
// which is ample for LoRa packet reception (radio has hardware FIFO).
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) || defined(MECK_TWATCH)
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
if (ui_task.isLocked()) {
delay(50);
}
@@ -11,9 +11,6 @@
#if defined(HAS_SDCARD) && defined(ESP32)
#include <SD.h>
#endif
#if defined(MECK_TWATCH)
#include <LittleFS.h> // watch has no SD -- message history persists to LittleFS
#endif
// DM send status codes (session only). Guarded so AbstractUITask.h can carry
// the same definitions without a clash.
@@ -107,9 +104,6 @@ private:
int _msgsPerPage; // Messages that fit on screen
uint8_t _viewChannelIdx; // Which channel we're currently viewing
bool _sdReady; // SD card is available for persistence
#if defined(MECK_TWATCH)
unsigned long _msgDirtySince = 0; // watch: pending LittleFS write (0 = clean)
#endif
bool _showPathOverlay; // Show path detail overlay for last received msg
int _pathScrollPos; // Scroll offset within path overlay hop list
int _pathHopsVisible; // Hops that fit on screen (set during render)
@@ -223,58 +217,7 @@ public:
// Sanitize emoji: replace UTF-8 emoji sequences with single-byte escape codes
// The text already contains "Sender: message" format
#if defined(MECK_TWATCH)
// Watch build: drop emoji entirely instead of storing sprite escape bytes.
// The watch UI has no sprite rendering and no room for emoji at 120x120
// virtual. Non-emoji UTF-8 (accented letters etc.) passes through
// unchanged, the same as emojiSanitize does.
{
const uint8_t* s = (const uint8_t*)text;
int srcLen = (int)strlen(text);
int si = 0, di = 0;
while (si < srcLen && di < CHANNEL_MSG_TEXT_LEN - 1) {
uint8_t b = s[si];
if (b < 0x80) { msg->text[di++] = (char)b; si++; continue; }
int consumed;
uint32_t cp = emojiDecodeUtf8(s + si, srcLen - si, &consumed);
if (cp == 0xFE0F) { si += consumed; continue; } // variation selector
if (cp == 0xFFFD) { si += consumed; continue; } // invalid UTF-8 -- skip
bool isEmoji = false;
for (int e = 0; e < EMOJI_COUNT; e++) {
if (EMOJI_CODEPOINTS[e].cp == cp) {
if (EMOJI_CODEPOINTS[e].cp2 != 0) {
// two-codepoint emoji: only a match when the pair matches
int consumed2;
if (si + consumed < srcLen) {
uint32_t cp2 = emojiDecodeUtf8(s + si + consumed, srcLen - si - consumed, &consumed2);
if (cp2 == EMOJI_CODEPOINTS[e].cp2) {
si += consumed + consumed2;
isEmoji = true;
break;
}
}
continue;
}
si += consumed;
isEmoji = true;
break;
}
}
if (!isEmoji) {
for (int a = 0; a < EMOJI_ALIAS_COUNT; a++) {
if (EMOJI_ALIASES[a].cp == cp) { si += consumed; isEmoji = true; break; }
}
}
if (isEmoji) continue; // dropped
if (di + consumed >= CHANNEL_MSG_TEXT_LEN) break;
for (int k = 0; k < consumed; k++) msg->text[di++] = (char)s[si + k];
si += consumed;
}
msg->text[di] = 0;
}
#else
emojiSanitize(text, msg->text, CHANNEL_MSG_TEXT_LEN);
#endif
if (_msgCount < CHANNEL_MSG_HISTORY_SIZE) {
_msgCount++;
@@ -570,13 +513,6 @@ public:
// Save the entire message buffer to SD card.
// File: /meshcore/messages.bin (~50 KB for 300 messages)
void saveToSD() {
#if defined(MECK_TWATCH)
// Watch: defer to the LittleFS flush cadence -- a ~57 KB write on every
// message would wear the flash. Timestamp the pending write; the main loop
// flushes it (see flushMessagesIfDue), and shutdown flushes directly.
if (_msgDirtySince == 0) _msgDirtySince = millis();
return;
#endif
#if defined(HAS_SDCARD) && defined(ESP32)
if (!_sdReady) return;
@@ -619,93 +555,6 @@ public:
#endif
}
#if defined(MECK_TWATCH)
// --- Watch message-history persistence (LittleFS; the watch has no SD) ------
// Flush the ring to LittleFS. Called on an interval from the main loop and
// directly on shutdown, so traffic produces at most one write per interval.
void flushMessagesNow() {
if (!LittleFS.exists("/meshcore")) {
LittleFS.mkdir("/meshcore");
}
File f = LittleFS.open(MSG_FILE_PATH, "w", true);
if (!f) {
Serial.println("ChannelScreen: LittleFS save failed - can't open file");
return;
}
MsgFileHeader hdr;
hdr.magic = MSG_FILE_MAGIC;
hdr.version = MSG_FILE_VERSION;
hdr.capacity = CHANNEL_MSG_HISTORY_SIZE;
hdr.count = (uint16_t)_msgCount;
hdr.newestIdx = (int16_t)_newestIdx;
f.write((uint8_t*)&hdr, sizeof(hdr));
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
MsgFileRecord rec;
rec.timestamp = _messages[i].timestamp;
rec.path_len = _messages[i].path_len;
rec.channel_idx = _messages[i].channel_idx;
rec.valid = _messages[i].valid ? 1 : 0;
rec.snr = _messages[i].snr;
rec.dm_peer_hash = _messages[i].dm_peer_hash;
memcpy(rec.path, _messages[i].path, MSG_PATH_MAX);
memcpy(rec.text, _messages[i].text, CHANNEL_MSG_TEXT_LEN);
f.write((uint8_t*)&rec, sizeof(rec));
}
f.close();
_msgDirtySince = 0;
}
// Flush only if a write is pending and the debounce interval has elapsed.
void flushMessagesIfDue(unsigned long intervalMs) {
if (_msgDirtySince && millis() - _msgDirtySince >= intervalMs) flushMessagesNow();
}
// Load the ring from LittleFS at boot. Returns true if any messages loaded.
bool loadFromLittleFS() {
if (!LittleFS.exists(MSG_FILE_PATH)) {
Serial.println("ChannelScreen: no saved messages on LittleFS");
return false;
}
File f = LittleFS.open(MSG_FILE_PATH, "r");
if (!f) {
Serial.println("ChannelScreen: LittleFS load failed - can't open file");
return false;
}
MsgFileHeader hdr;
if (f.read((uint8_t*)&hdr, sizeof(hdr)) != sizeof(hdr)) { f.close(); return false; }
if (hdr.magic != MSG_FILE_MAGIC) { f.close(); return false; }
if (hdr.version != MSG_FILE_VERSION) { f.close(); return false; }
if (hdr.capacity != CHANNEL_MSG_HISTORY_SIZE) { f.close(); return false; }
int loaded = 0;
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
MsgFileRecord rec;
if (f.read((uint8_t*)&rec, sizeof(rec)) != sizeof(rec)) break;
_messages[i].timestamp = rec.timestamp;
_messages[i].path_len = rec.path_len;
_messages[i].channel_idx = rec.channel_idx;
_messages[i].valid = (rec.valid != 0);
_messages[i].snr = rec.snr;
_messages[i].dm_peer_hash = rec.dm_peer_hash;
memcpy(_messages[i].path, rec.path, MSG_PATH_MAX);
memcpy(_messages[i].text, rec.text, CHANNEL_MSG_TEXT_LEN);
_messages[i].scope_idx = 0xFF;
_messages[i].send_ref = 0;
_messages[i].dm_status = DM_SEND_NONE;
_messages[i].dm_attempt = 0;
_messages[i].dm_total = 0;
if (_messages[i].valid) loaded++;
}
_msgCount = (int)hdr.count;
_newestIdx = (int)hdr.newestIdx;
_scrollPos = 0;
if (_newestIdx < -1 || _newestIdx >= CHANNEL_MSG_HISTORY_SIZE) _newestIdx = -1;
if (_msgCount < 0 || _msgCount > CHANNEL_MSG_HISTORY_SIZE) _msgCount = loaded;
f.close();
Serial.printf("ChannelScreen: Loaded %d messages from LittleFS (count=%d, newest=%d)\n",
loaded, _msgCount, _newestIdx);
return loaded > 0;
}
#endif
// Load message buffer from SD card. Returns true if messages were loaded.
bool loadFromSD() {
@@ -4,9 +4,6 @@
#include <helpers/ui/DisplayDriver.h>
#include <MeshCore.h>
#if defined(MECK_TWATCH)
#include <helpers/ui/LGFXDisplay.h> // blue rounded-rect overlay primitives (watch LCD)
#endif
// Timestamps before this (Jan 1 2026 UTC) are treated as invalid/unsynced
#define EPOCH_2026 1735689600UL
@@ -57,17 +54,6 @@ private:
bool _selectMode;
uint8_t* _selectedBits; // Bitfield: 1 bit per MAX_CONTACTS raw index
#if defined(MECK_TWATCH)
// --- Action overlay state (watch) ---
// Long-press raises a small [Open] [Favourite] [Delete] overlay. Buttons are
// three full-width bands defined in the 128-space touch grid; render() maps
// them to the 120-space display via display.height().
bool _actionOverlay = false;
static const int OVL_BTN_H = 26;
static const int OVL_BTN0_TOP = 21; // (128 - (3*26 + 2*4)) / 2
static const int OVL_BTN1_TOP = 51; // 21 + 26 + 4
static const int OVL_BTN2_TOP = 81; // 51 + 26 + 4
#endif
// --- helpers ---
@@ -196,9 +182,6 @@ public:
void resetScroll() {
_scrollPos = 0;
_cacheValid = false;
#if defined(MECK_TWATCH)
_actionOverlay = false; // never carry a stale overlay across screen entry
#endif
}
FilterMode getFilter() const { return _filter; }
@@ -307,21 +290,6 @@ public:
return true;
}
#if defined(MECK_TWATCH)
// --- Action overlay (watch) ---
void openActionOverlay() { _actionOverlay = true; }
void closeActionOverlay() { _actionOverlay = false; }
bool isActionOverlayOpen() const { return _actionOverlay; }
// Hit-test a 128-space touch Y against the overlay buttons.
// Returns 0=Open, 1=Favourite, 2=Delete, -1=miss (tap outside dismisses).
int actionOverlayButtonAtVY(int vy) const {
if (vy >= OVL_BTN0_TOP && vy < OVL_BTN0_TOP + OVL_BTN_H) return 0;
if (vy >= OVL_BTN1_TOP && vy < OVL_BTN1_TOP + OVL_BTN_H) return 1;
if (vy >= OVL_BTN2_TOP && vy < OVL_BTN2_TOP + OVL_BTN_H) return 2;
return -1;
}
#endif
int render(DisplayDriver& display) override {
if (!_cacheValid) rebuildCache();
@@ -336,11 +304,7 @@ public:
int selCount = getSelectedCount();
snprintf(tmp, sizeof(tmp), "%d Selected [%s]", selCount, filterLabel(_filter));
} else {
#if defined(MECK_TWATCH)
snprintf(tmp, sizeof(tmp), "Contacts[%s]", filterLabel(_filter));
#else
snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter));
#endif
}
display.print(tmp);
@@ -505,11 +469,6 @@ public:
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
#elif defined(MECK_TWATCH)
display.setCursor(0, footerY);
if (!_selectMode) {
display.print("Long Press: Enter");
}
#elif defined(LILYGO_TECHO_LITE)
display.setCursor(0, footerY);
if (_selectMode) {
@@ -535,36 +494,6 @@ public:
}
#endif
#if defined(MECK_TWATCH)
// Action overlay -- drawn last so it sits on top of the contact list.
if (_actionOverlay) {
const int H = display.height(); // 120 on the watch
const int W = display.width(); // 120
int y0 = OVL_BTN0_TOP * H / 128;
int y1 = OVL_BTN1_TOP * H / 128;
int y2 = OVL_BTN2_TOP * H / 128;
int bh = OVL_BTN_H * H / 128;
int bx = 6;
int bw = W - 12;
// Dark backdrop to hide the contact list behind the overlay
display.setColor(DisplayDriver::DARK);
display.fillRect(bx - 2, y0 - 4, bw + 4, (y2 + bh) - y0 + 8);
// Blue rounded buttons with white centred labels, matching the watch's
// Notes/config style (0x231D = the home-tile blue, 0xFFFF = white).
LGFXDisplay* d = (LGFXDisplay*)&display;
d->setRawColor(0x231D);
d->drawRoundRect(bx, y0, bw, bh, 3);
d->drawRoundRect(bx, y1, bw, bh, 3);
d->drawRoundRect(bx, y2, bw, bh, 3);
d->setRawColor(0xFFFF);
display.drawTextCentered(W / 2, y0 + (bh - 8) / 2, "Open");
display.drawTextCentered(W / 2, y1 + (bh - 8) / 2, "Favourite");
display.drawTextCentered(W / 2, y2 + (bh - 8) / 2, "Delete");
}
#endif
return 5000; // e-ink: next render after 5s
}
@@ -103,14 +103,6 @@ public:
display.setColor(DisplayDriver::LIGHT);
display.setCursor(4, 28);
display.print(active ? "Listening for adverts..." : "No nodes found");
#if defined(MECK_TWATCH)
display.setCursor(4, 48);
display.print("Hold: Rescan");
display.setCursor(4, 58);
display.print("Tap: Select");
display.setCursor(4, 68);
display.print("Tap Again: Add");
#else
if (!active) {
display.setCursor(4, 38);
#if defined(LilyGo_T5S3_EPaper_Pro)
@@ -119,7 +111,6 @@ public:
display.print("F: Scan again Q: Back");
#endif
}
#endif
} else {
// Center visible window around selected item
int maxVisible = (maxY - headerHeight) / lineHeight;
@@ -208,12 +199,6 @@ public:
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
display.print(mid);
const char* right = "Hold:Rescan";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#elif defined(MECK_TWATCH)
display.print("Tap:Slct");
const char* right = "Hold:Rescan";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
@@ -136,7 +136,7 @@ public:
// --- Footer ---
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setTextSize(0);
display.drawTextCentered(display.width() / 2, display.height() - 8, "Tap to select");
display.setTextSize(1);
@@ -42,16 +42,6 @@ class UITask;
#define MINE_FTR 14
#define MINE_OFFX_ADJ 2
#define MINE_OFFY_ADJ 5
#elif defined(MECK_TWATCH)
// Watch LCD (120x120): denser grid than the 9x9 default, 8px cells so the
// numbers render like the T5S3 (size-0 font). 14*8=112 wide, 11*8=88 tall.
#define MINE_GRID_W 14
#define MINE_GRID_H 11
#define MINE_COUNT 22
#define MINE_CELL_W 8
#define MINE_CELL_H 8
#define MINE_HDR 14
#define MINE_FTR 14
#else
#define MINE_GRID_W 9
#define MINE_GRID_H 9
@@ -77,9 +67,6 @@ class UITask;
#elif defined(LilyGo_T5S3_EPaper_Pro)
#define MINE_TEXT_SIZE 0
#define MINE_TEXT_DY 1
#elif defined(MECK_TWATCH)
#define MINE_TEXT_SIZE 0
#define MINE_TEXT_DY 1
#else
#define MINE_TEXT_SIZE 1
#define MINE_TEXT_DY 3
@@ -413,7 +400,7 @@ public:
display.drawTextCentered(cx, y, "Minesweeper");
y += 16;
display.setColor(DisplayDriver::GREEN);
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(cx, y, "Swipe to move cursor");
y += 11;
display.drawTextCentered(cx, y, "Tap to reveal");
@@ -432,7 +419,7 @@ public:
snprintf(info, sizeof(info), "%dx%d grid, %d mines", MINE_GRID_W, MINE_GRID_H, MINE_COUNT);
display.drawTextCentered(cx, y, info);
y += 16;
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(cx, y, "Tap to start");
#else
display.drawTextCentered(cx, y, "Press Enter to start");
@@ -440,7 +427,7 @@ public:
// Footer
display.setColor(DisplayDriver::LIGHT);
#if !defined(LilyGo_T5S3_EPaper_Pro) && !defined(MECK_TWATCH)
#if !defined(LilyGo_T5S3_EPaper_Pro)
int fy = display.height() - 12;
display.drawRect(0, fy - 2, display.width(), 1);
display.setCursor(2, fy);
@@ -57,9 +57,6 @@ private:
bool _dirty; // Path has been modified
bool _wantExit; // Set by Save & Exit — caller should navigate back
#if defined(MECK_TWATCH)
bool _wantKeyboard = false; // Set by Add hop -- caller opens the path keyboard
#endif
bool _directLocked; // True = path is explicitly set to direct (0 hops, locked)
// --- helpers ---
@@ -192,9 +189,6 @@ public:
_repScroll = 0;
_dirty = false;
_wantExit = false;
#if defined(MECK_TWATCH)
_wantKeyboard = false;
#endif
_directLocked = false;
// Load contact info
@@ -287,7 +281,7 @@ public:
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineH);
#else
display.fillRect(0, y + 5, display.width(), lineH);
@@ -389,12 +383,6 @@ public:
const char* right = "Hold:Select";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#elif defined(MECK_TWATCH)
display.setCursor(0, footerY);
display.print("Tap:Slct");
const char* right = "Hold:Entr";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#else
display.setCursor(0, footerY);
display.print("Q:Bk W/S:Nav");
@@ -495,60 +483,6 @@ public:
return 5000;
}
#if defined(MECK_TWATCH)
// Watch: replace the whole path from a typed string of comma-separated hex
// hops. Each hop is _bytesPerHop bytes (2*_bytesPerHop hex chars), matching
// the current mode toggle -- e.g. 1B "36,21,dd", 2B "3601,2198,dddd", 3B
// "360184,2198a7,dddddd". Returns NULL on success, or an error message (the
// existing path is left unchanged on any error). UITask is only forward
// declared here, so the caller shows the alert rather than this method.
const char* applyComposedPath(const char* str) {
if (str == nullptr) return "Empty path";
const int expectChars = _bytesPerHop * 2;
uint8_t tmp[MAX_PATH_SIZE];
int nbytes = 0, hops = 0;
const char* p = str;
while (*p) {
char tok[16];
int tlen = 0;
while (*p && *p != ',') {
if (*p != ' ') {
if (tlen >= (int)sizeof(tok) - 1) return "Hop too long";
tok[tlen++] = *p;
}
p++;
}
if (*p == ',') p++;
if (tlen == 0) continue; // tolerate stray/trailing commas
if (tlen != expectChars) return "Wrong hop length";
if (hops >= 8) return "Max 8 hops";
for (int b = 0; b < _bytesPerHop; b++) {
int hi = hexNibble(tok[b * 2]);
int lo = hexNibble(tok[b * 2 + 1]);
if (hi < 0 || lo < 0) return "Bad hex";
if (nbytes >= MAX_PATH_SIZE) return "Path too long";
tmp[nbytes++] = (uint8_t)((hi << 4) | lo);
}
hops++;
}
if (hops == 0) return "Empty path";
memset(_pathBuf, 0, sizeof(_pathBuf));
memcpy(_pathBuf, tmp, nbytes);
_hopCount = hops;
_pathLen = encodePath();
_directLocked = false;
_dirty = true;
_menuCount = buildMenuCount();
_menuSel = 0;
return nullptr;
}
static int hexNibble(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
#endif
bool handleInput(char c) override {
if (_state == STATE_PICK_HOP) {
@@ -613,18 +547,11 @@ public:
case MENU_ADD_HOP:
_directLocked = false;
#if defined(MECK_TWATCH)
// Watch: request the on-screen keyboard to type the whole path as
// comma-separated hex hops. main loop polls wantsKeyboard() and opens
// it (UITask is only forward declared here, so we can't call it).
_wantKeyboard = true;
#else
// Enter picker mode -- adding a hop clears direct lock
buildRepeaterList();
_repSel = 0;
_repScroll = 0;
_state = STATE_PICK_HOP;
#endif
return true;
case MENU_SET_DIRECT:
@@ -774,11 +701,6 @@ public:
EditorState getState() const { return _state; }
bool isDirty() const { return _dirty; }
bool wantsExit() const { return _wantExit; }
#if defined(MECK_TWATCH)
bool wantsKeyboard() const { return _wantKeyboard; }
void clearWantKeyboard() { _wantKeyboard = false; }
int getContactIdx() const { return _contactIdx; }
#endif
private:
void switchMode(int newBytesPerHop) {
@@ -595,11 +595,6 @@ public:
// --- Header ---
display.setTextSize(1);
#if defined(MECK_TWATCH)
// Clip long lines at the screen edge instead of wrapping them onto the row
// below (restored before returning so other screens are unaffected).
((LGFXDisplay*)&display)->setTextWrap(false);
#endif
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
const char* hdrPrefix = (_state == STATE_PASSWORD_ENTRY || _state == STATE_LOGGING_IN)
@@ -644,8 +639,6 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Exit");
renderFooterRight(display, footerY, "Hold:Type");
#elif defined(MECK_TWATCH)
display.print("Long Press: login");
#else
display.print("Sh+Del:Exit");
renderFooterRight(display, footerY, "Ent:Login");
@@ -665,8 +658,6 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Exit");
renderFooterMidRight(display, footerY, "Back:Exit", "Tap:Open", "Swipe:Sel");
#elif defined(MECK_TWATCH)
display.print("Long Press: Select");
#else
display.print("Q:Exit");
renderFooterMidRight(display, footerY, "Q:Exit", "Ent:Open", "W/S:Sel");
@@ -677,8 +668,6 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Back");
renderFooterMidRight(display, footerY, "Back:Back", "Tap:Run", "Swipe:Sel");
#elif defined(MECK_TWATCH)
display.print("Long Press: Run");
#else
display.print("Q:Back");
renderFooterMidRight(display, footerY, "Q:Back", "Ent:Run", "W/S:Sel");
@@ -689,8 +678,6 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Cancel");
renderFooterRight(display, footerY, "Tap:Send");
#elif defined(MECK_TWATCH)
display.print("Tap: Enter value");
#else
display.print("Sh+Del:Cancel");
renderFooterRight(display, footerY, "Ent:Send");
@@ -723,9 +710,6 @@ public:
break;
}
#if defined(MECK_TWATCH)
((LGFXDisplay*)&display)->setTextWrap(true); // restore default before returning
#endif
if (_state == STATE_LOGGING_IN || _state == STATE_COMMAND_PENDING) return 30000; // static text; poll()/callbacks force refresh on state change
if (_state == STATE_PASSWORD_ENTRY && _lastCharAt > 0 && (millis() - _lastCharAt) < 800) {
@@ -1125,11 +1109,6 @@ private:
void renderResponse(DisplayDriver& display, int y, int bodyHeight) {
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
#if defined(MECK_TWATCH)
// Watch: clip long response lines at the screen edge instead of wrapping
// them onto the row below.
((LGFXDisplay*)&display)->setTextWrap(false);
#endif
display.setColor((_state == STATE_ERROR) ? DisplayDriver::YELLOW : DisplayDriver::LIGHT);
@@ -1167,9 +1146,6 @@ private:
}
display.setTextSize(1);
#if defined(MECK_TWATCH)
((LGFXDisplay*)&display)->setTextWrap(true); // restore default
#endif
}
bool handleResponseInput(char c) {
@@ -140,10 +140,6 @@ public:
int render(DisplayDriver& display) override {
int count = the_mesh.getRxLogCount();
#if defined(MECK_TWATCH)
// Watch: no keyboard or touch scroll, so cycle through entries automatically.
if (count > 1) { _scrollPos++; if (_scrollPos >= count) _scrollPos = 0; }
#endif
if (_scrollPos < 0) _scrollPos = 0;
if (_scrollPos > count - 1) _scrollPos = (count > 0) ? count - 1 : 0;
@@ -157,11 +153,7 @@ public:
display.drawRect(0, 11, display.width(), 1);
int headerHeight = 14;
#if defined(MECK_TWATCH)
int footerHeight = 2; // no footer on the watch
#else
int footerHeight = 14;
#endif
int maxY = display.height() - footerHeight;
int y = headerHeight;
@@ -188,7 +180,6 @@ public:
display.setTextSize(1);
// === Footer ===
#if !defined(MECK_TWATCH)
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
@@ -198,13 +189,8 @@ public:
#else
display.print("Q:Bk W/S:Scroll");
#endif
#endif
#if defined(MECK_TWATCH)
return 3000; // watch: advance to the next packet every 3s
#else
return 5000; // refresh every 5s to pick up newly received packets
#endif
}
bool handleInput(char c) override {
@@ -2,11 +2,6 @@
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#if defined(MECK_TWATCH)
#include <helpers/ui/LGFXDisplay.h> // setTextWrap/getCursorX for the row ticker
// Row ticker scroll speed -- matches TW_TICKER_MS_PER_PX in TWatchComposeScreens.h.
#define SETTINGS_TICKER_MS_PER_PX 20
#endif
#include <helpers/ChannelDetails.h>
#include <helpers/TransportKeyStore.h>
#include <MeshCore.h>
@@ -347,14 +342,6 @@ private:
// T5S3: signal UITask to open VKB when entering text edit mode
bool _needsTextVKB;
bool _wantsWatchChannels; // Watch: hand off to WatchChannelConfigScreen (polled by UITask)
#if defined(MECK_TWATCH)
// Selected-row ticker (marquee for rows wider than the screen), mirroring
// TWatchChannelScreen: 20 ms/px, 24 px gap between the looping copies.
int _tickerRow = -1; // row index currently anchored
unsigned long _tickerStartMs = 0; // animation anchor
int _tickerTextW = 0; // measured on pass 0 of the previous frame
bool _tickerActive = false;
#endif
// 4G modem state (runtime cache of config)
#ifdef HAS_4G_MODEM
@@ -454,9 +441,6 @@ private:
void rebuildRows() {
_numRows = 0;
#if defined(MECK_TWATCH)
_tickerRow = -1; // row indices are about to change; re-anchor the ticker
#endif
if (_subScreen == SUB_CONTACTS) {
// --- Contacts sub-screen: only contact-related rows ---
@@ -516,18 +500,14 @@ private:
addRow(ROW_BACKLIGHT_BRIGHTNESS);
addRow(ROW_KB_BACKLIGHT);
#endif
#if !defined(MECK_TWATCH)
addRow(ROW_MSG_NOTIFY);
#endif
#if HAS_GPS
addRow(ROW_GPS_BAUD);
#endif
addRow(ROW_PATH_HASH_SIZE);
addRow(ROW_DEFAULT_SCOPE);
#if !defined(MECK_TWATCH)
addRow(ROW_DARK_MODE);
#endif
#if !defined(LILYGO_TECHO_LITE) && !defined(MECK_TWATCH)
#if !defined(LILYGO_TECHO_LITE)
addRow(ROW_LARGE_FONT);
addRow(ROW_FONT_STYLE);
#endif
@@ -1830,13 +1810,6 @@ public:
int y = headerH;
#if defined(MECK_TWATCH)
// Watch: rows longer than the 120px line (e.g. "GPS Baud: Default (9600)")
// must clip at the screen edge, not wrap onto the row below and overprint
// it. Restored after the loop -- other screens rely on wrap being on.
((LGFXDisplay*)&display)->setTextWrap(false);
_tickerActive = false;
#endif
for (int i = _scrollTop; i < endIdx && y + lineHeight <= maxY; i++) {
bool selected = (i == _cursor);
@@ -1857,31 +1830,7 @@ public:
display.setColor(DisplayDriver::LIGHT);
}
#if defined(MECK_TWATCH)
// Selected-row ticker, mirroring TWatchChannelScreen::drawTicker. The
// row text is only known inside the switch below, so the marquee's
// seamless second copy is drawn by running the row's case twice, one
// period apart. Width is measured on pass 0 via getCursorX, so a newly
// selected row shows its first frame unscrolled and scrolls from the
// next frame on.
int rowStartX = 0;
int passes = 1;
if (selected) {
if (_tickerRow != i) { _tickerRow = i; _tickerStartMs = millis(); _tickerTextW = 0; }
if (_tickerTextW > display.width() - sbW - 4) {
int period = _tickerTextW + 24;
int off = (int)(((millis() - _tickerStartMs) / SETTINGS_TICKER_MS_PER_PX) % (unsigned long)period);
rowStartX = 2 - off;
passes = 2;
_tickerActive = true;
}
}
int rowTextW = 0;
for (int tickerPass = 0; tickerPass < passes; tickerPass++) {
display.setCursor(rowStartX + tickerPass * (_tickerTextW + 24), y);
#else
display.setCursor(0, y);
#endif
switch (_rows[i].type) {
case ROW_NAME:
@@ -2369,22 +2318,10 @@ public:
#endif
}
#if defined(MECK_TWATCH)
if (selected && tickerPass == 0) {
// getCursorX() is unusable here: LGFXDisplay::print() calls buffer.println(),
// whose newline resets the cursor X to 0. Measure the string directly.
rowTextW = display.getTextWidth(tmp);
}
} // ticker pass loop
if (selected) _tickerTextW = rowTextW;
#endif
y += lineHeight;
}
#if defined(MECK_TWATCH)
((LGFXDisplay*)&display)->setTextWrap(true);
#endif
// Scrollbar (track + proportional thumb), mirroring the notif-sound picker.
if (showScrollbar) {
@@ -3005,17 +2942,9 @@ public:
} else if (_subScreen != SUB_NONE) {
display.print("Q:Back");
} else {
#if defined(MECK_TWATCH)
display.print("Hold:Edit");
#else
display.print("Q:Bk");
#endif
}
#if defined(MECK_TWATCH)
const char* r = (_subScreen == SUB_NONE) ? "Tap:Cycle" : "Tap/Ent:Edit";
#else
const char* r = "Tap/Ent:Edit";
#endif
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
display.print(r);
}
@@ -3029,13 +2958,6 @@ public:
return 200; // 200ms — fast enough for web server responsiveness
}
#endif
#if defined(MECK_TWATCH)
// Marquee running, or a wide row was just selected (its width was measured
// this frame): refresh fast so scrolling starts promptly instead of after
// the 1s idle interval.
if (_tickerActive ||
(_tickerRow == _cursor && _tickerTextW > display.width() - 10)) return 60;
#endif
return _editMode != EDIT_NONE ? 700 : 1000;
}
@@ -3870,18 +3792,12 @@ public:
Serial.println("Settings: entered Contacts sub-screen");
break;
case ROW_CHANNELS_SUBMENU:
#if defined(MECK_TWATCH)
// Watch: the desktop sub-screen's per-channel actions are keyboard
// hotkeys; hand off to the standalone WatchChannelConfigScreen.
_wantsWatchChannels = true; // UITask::loop() polls and opens it
#else
_savedTopCursor = _cursor;
_subScreen = SUB_CHANNELS;
_cursor = 0;
_scrollTop = 0;
rebuildRows();
Serial.println("Settings: entered Channels sub-screen");
#endif
break;
case ROW_RXLOG:
+3 -13
View File
@@ -37,11 +37,7 @@ class UITask;
#define SNAKE_HI_VERSION 1
// Tick cadence: the watch LCD has no e-ink refresh limit, so run a touch faster.
#if defined(MECK_TWATCH)
#define SNAKE_TICK_MS 250
#else
#define SNAKE_TICK_MS 500
#endif
class SnakeScreen : public UIScreen {
public:
@@ -403,9 +399,7 @@ public:
display.drawTextCentered(cx, y, "Classic Snake");
y += 14;
display.setColor(DisplayDriver::GREEN);
#if defined(MECK_TWATCH)
display.drawTextCentered(cx, y, "Tap to steer");
#elif defined(LilyGo_T5S3_EPaper_Pro)
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(cx, y, "Swipe to steer");
#else
display.drawTextCentered(cx, y, "W/S/A/D to steer");
@@ -440,7 +434,7 @@ public:
}
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(cx, y, "Tap to start");
#else
display.drawTextCentered(cx, y, "Press Enter to start");
@@ -493,16 +487,12 @@ public:
ty += 12;
}
display.setColor(DisplayDriver::GREEN);
#if defined(MECK_TWATCH)
display.drawTextCentered(cx, ty, "Tap to retry");
#else
display.drawTextCentered(cx, ty, "Enter:Retry Sh+Del:Back");
#endif
}
}
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
#if defined(LilyGo_T5S3_EPaper_Pro)
char footBuf[32];
snprintf(footBuf, sizeof(footBuf), "Score: %d", _score);
display.setTextSize(0);
@@ -91,9 +91,6 @@ private:
int _resultScroll;
bool _wantExit;
#if defined(MECK_TWATCH)
bool _wantKeyboard; // Watch: Type Path tapped -> UITask opens the on-screen keyboard
#endif
// --- Menu helpers (STATE_BUILD) ---
// Menu layout:
@@ -321,50 +318,6 @@ public:
return _state == STATE_BUILD && menuItemAt(_menuSel) == MENU_PATH_SIZE;
}
#if defined(MECK_TWATCH)
bool wantsKeyboard() const { return _wantKeyboard; }
void clearWantKeyboard() { _wantKeyboard = false; }
// Tap-to-select row mapping for the watch (mirrors PathEditor/Discovery).
// vy is in the 128-tall virtual touch space. Returns:
// 0 = miss, 1 = moved the cursor, 2 = tapped the already-selected row.
int selectRowAtVY(int vy) {
if (_state == STATE_BUILD) return selectBuildRowAtVY(vy);
if (_state == STATE_PICK_HOP) return selectPickerRowAtVY(vy);
return 0;
}
int selectBuildRowAtVY(int vy) {
int menuCount = buildMenuCount();
if (menuCount == 0) return 0;
const int headerH = 14, footerH = 14, lineH = 11;
const int bodyTop = headerH; // renderBuild draws row 0 at y=14
if (vy < bodyTop || vy >= 128 - footerH) return 0;
int maxVisible = (128 - headerH - footerH) / lineH;
if (maxVisible < 1) maxVisible = 1;
// Replicate renderBuild's scroll window so a tap maps to the drawn row.
int scrollTop = 0;
if (_menuSel >= scrollTop + maxVisible) scrollTop = _menuSel - maxVisible + 1;
if (_menuSel < scrollTop) scrollTop = _menuSel;
int tappedRow = scrollTop + (vy - bodyTop) / lineH;
if (tappedRow < 0 || tappedRow >= menuCount) return 0;
if (tappedRow == _menuSel) return 2;
_menuSel = tappedRow;
return 1;
}
int selectPickerRowAtVY(int vy) {
if (_repCount == 0) return 0;
const int headerH = 14, footerH = 14, lineH = 11;
const int bodyTop = headerH; // renderPicker draws row 0 at y=14
if (vy < bodyTop || vy >= 128 - footerH) return 0;
int tappedRow = _repScroll + (vy - bodyTop) / lineH;
if (tappedRow < 0 || tappedRow >= _repCount) return 0;
if (tappedRow == _repSel) return 2;
_repSel = tappedRow;
return 1;
}
#endif
// Returns the current path formatted as a comma-separated string, suitable
// for pre-populating an external text editor (e.g. the T5S3 virtual keyboard).
@@ -396,9 +349,6 @@ public:
_repSel = 0;
_repScroll = 0;
_wantExit = false;
#if defined(MECK_TWATCH)
_wantKeyboard = false;
#endif
_resultScroll = 0;
memset(_pathBuf, 0, sizeof(_pathBuf));
memset(&_result, 0, sizeof(_result));
@@ -518,8 +468,6 @@ private:
} else {
#if defined(LilyGo_T5S3_EPaper_Pro)
snprintf(tmp, sizeof(tmp), "%c Type Path: [Long press]", prefix);
#elif defined(MECK_TWATCH)
snprintf(tmp, sizeof(tmp), "%c Type Path: [Tap]", prefix);
#else
snprintf(tmp, sizeof(tmp), "%c Type Path: [Press Enter]", prefix);
#endif
@@ -591,8 +539,6 @@ private:
} else {
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Exit Tap:Sel");
#elif defined(MECK_TWATCH)
display.print("Tap:Sel Hold:Go");
#else
display.print("Q:Exit W/S:Nav Ent:Sel");
#endif
@@ -656,8 +602,6 @@ private:
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Back Tap:Add");
#elif defined(MECK_TWATCH)
display.print("Tap:Sel Hold:Add");
#else
display.print("Q:Back W/S:Scroll Ent:Add");
#endif
@@ -810,8 +754,6 @@ private:
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Back Tap:New Trace");
#elif defined(MECK_TWATCH)
display.print("Hold: New Trace");
#else
display.print("Q:Back Ent:New Trace");
#endif
@@ -908,14 +850,8 @@ private:
switch (item) {
case MENU_TYPE_PATH:
pathToEditBuf();
#if defined(MECK_TWATCH)
// Watch: no physical keys -- request the on-screen keyboard. The main
// loop polls wantsKeyboard() and opens it, pre-filled with the path.
_wantKeyboard = true;
#else
// Enter inline edit mode -- pre-fill with current path if any
_editing = true;
#endif
return true;
case MENU_ADD_HOP:
File diff suppressed because it is too large Load Diff
+4 -62
View File
@@ -34,7 +34,7 @@
#include "AlarmScreen.h"
#endif
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
#if defined(LilyGo_T5S3_EPaper_Pro)
#include "VirtualKeyboard.h"
#endif
@@ -109,40 +109,11 @@ class UITask : public AbstractUITask {
UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required)
#endif
UIScreen* map_screen; // Map tile screen (GPS + SD card tiles)
#ifdef TWATCH_COMPOSE_ENABLED
UIScreen* tw_picker;
UIScreen* tw_channel;
UIScreen* tw_keyboard;
#endif
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) || defined(MECK_TWATCH)
#if defined(LilyGo_T5S3_EPaper_Pro)
UIScreen* lock_screen; // Lock screen (big clock + battery + unread)
#if defined(MECK_TWATCH)
UIScreen* steps_screen; // Steps screen (big daily step count)
UIScreen* steps_history_screen; // Steps subscreen: today + the previous 6 days
// Step state. The BMA423 feature engine is re-flashed by bma423_write_config_file()
// inside SensorBMA423::begin() on every boot, which zeroes the step register. So
// the raw count cannot be used as an absolute; deltas are accumulated instead and
// the totals are persisted. _lastRaw is seeded from the chip on the first read
// rather than from the file, which is correct whether or not the count survived.
int32_t _lastStepDay = 0; // local day-of-epoch that _todaySteps belongs to
uint32_t _todaySteps = 0; // accumulated steps for _lastStepDay
uint32_t _stepHistory[6] = {0}; // [0] = yesterday ... [5] = six days ago
uint32_t _lastRaw = 0; // last raw BMA423 count seen (runtime only)
bool _stepsSeeded = false; // _lastRaw has been seeded from the chip
unsigned long _stepsDirtySince = 0; // millis of oldest unsaved change; 0 = clean
void loadSteps();
void saveSteps();
void updateSteps(); // called every loop()
void rollStepDays(int32_t newDay);
void shiftStepHistory(uint32_t completedDay);
#endif
#if defined(MECK_TWATCH)
UIScreen* watch_notes_screen; // LittleFS note pad (shared NotesScreen needs SD)
UIScreen* watch_channel_cfg_screen; // Per-channel region/notif/delete (touch UI)
#endif
UIScreen* _screenBeforeLock = nullptr;
bool _locked = false;
unsigned long _lastInputMillis = 0; // Auto-lock idle tracking
@@ -218,10 +189,6 @@ public:
void gotoHomeScreen();
void gotoChannelScreen(bool resetDmView = true); // Navigate to channel message screen
void gotoChannelPickerScreen(); // Navigate to channel picker (bubble/list)
#ifdef TWATCH_COMPOSE_ENABLED
void openTWatchPicker();
void openTWatchKeyboard(int purpose, int contextIdx);
#endif
void gotoDMTab(); // Navigate directly to DM tab on channel screen
void gotoDMConversation(const char* contactName, int contactIdx = -1, uint8_t perms = 0);
void gotoContactsScreen(); // Navigate to contacts list
@@ -244,12 +211,6 @@ public:
void gotoGamesMenu(); // Navigate to games launcher menu
void gotoSnakeScreen(); // Navigate to snake game
void gotoMinesweeperScreen(); // Navigate to minesweeper game
#if defined(MECK_TWATCH)
void gotoStepsScreen(); // Navigate to the step counter screen
void gotoStepsHistoryScreen(); // Navigate to the 7-day step history
uint32_t getTodaySteps(); // Steps accumulated today
uint32_t getStepHistory(int daysAgo); // 0 = today, 1..6 = previous days
#endif
#if HAS_GPS
void gotoMapScreen(); // Navigate to map tile screen
#endif
@@ -311,25 +272,14 @@ public:
bool isOnTraceScreen() const { return curr == trace_screen; }
bool isOnGamesMenu() const { return curr == games_menu_screen; }
bool isOnSnakeScreen() const { return curr == snake_screen; }
#if defined(MECK_TWATCH)
bool isOnStepsScreen() const { return curr == steps_screen; }
bool isOnStepsHistoryScreen() const { return curr == steps_history_screen; }
#endif
#if defined(MECK_TWATCH)
bool isOnWatchNotesScreen() const { return curr == watch_notes_screen; }
bool isOnWatchChannelConfigScreen() const { return curr == watch_channel_cfg_screen; }
#endif
#ifdef TWATCH_COMPOSE_ENABLED
bool isOnTWatchChannelScreen() const { return curr == tw_channel; }
#endif
bool isOnMinesweeperScreen() const { return curr == minesweeper_screen; }
bool isOnMapScreen() const { return curr == map_screen; }
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) || defined(MECK_TWATCH)
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
bool isLocked() const { return _locked; }
void lockScreen();
void unlockScreen();
#endif
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
#if defined(LilyGo_T5S3_EPaper_Pro)
bool isVKBActive() const { return _vkbActive; }
unsigned long vkbOpenedAt() const { return _vkbOpenedAt; }
VirtualKeyboard& getVKB() { return _vkb; }
@@ -414,14 +364,6 @@ public:
UIScreen* getTraceScreen() const { return trace_screen; }
UIScreen* getGamesMenuScreen() const { return games_menu_screen; }
UIScreen* getSnakeScreen() const { return snake_screen; }
#if defined(MECK_TWATCH)
UIScreen* getStepsScreen() const { return steps_screen; }
UIScreen* getStepsHistoryScreen() const { return steps_history_screen; }
#endif
#if defined(MECK_TWATCH)
UIScreen* getWatchNotesScreen() const { return watch_notes_screen; }
UIScreen* getWatchChannelConfigScreen() const { return watch_channel_cfg_screen; }
#endif
UIScreen* getMinesweeperScreen() const { return minesweeper_screen; }
UIScreen* getMapScreen() const { return map_screen; }
#ifdef MECK_WEB_READER
@@ -12,7 +12,7 @@
// if (keyboard.status() == VKB_SUBMITTED) { ... keyboard.getText() ... }
// =============================================================================
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
#if defined(LilyGo_T5S3_EPaper_Pro)
#ifndef VIRTUAL_KEYBOARD_H
#define VIRTUAL_KEYBOARD_H
@@ -536,4 +536,4 @@ private:
};
#endif // VIRTUAL_KEYBOARD_H
#endif // LilyGo_T5S3_EPaper_Pro || MECK_TWATCH
#endif // LilyGo_T5S3_EPaper_Pro
@@ -1,382 +0,0 @@
#pragma once
// =============================================================================
// WatchChannelConfigScreen -- per-channel settings for the T-Watch S3 / S3 Plus.
//
// The SettingsScreen SUB_CHANNELS sub-screen drives every per-channel action
// from physical-keyboard hotkeys on the selected row (Ent: edit region scope,
// N: cycle notifications, T: tone, X/Hold: delete), none of which exist on the
// touch-only watch. This standalone screen replaces it on MECK_TWATCH builds,
// following the WatchAlarmScreen / WatchNotesScreen conventions.
//
// LIST mode shows each channel with its region scope tag and notification
// state; tap a row to select it, tap again (or PWR/Enter) to open it. DETAIL
// mode offers: Region (opens the watch keyboard via TWKB_SCOPE, pre-filled
// with the current scope), Clear region (reverts to the device default),
// Notify (tap-cycles All -> Mentions -> Off, mirroring the desktop order),
// and Delete via the button (channel 1+ only): a short press of the PWR key
// (S3) or the boot button (S3 Plus) arms it, a second press within 3s
// confirms. The tone picker is deliberately absent -- no audio path.
//
// Exit: the footer Back returns to the Settings screen (via a flag polled by
// UITask::loop); the universal top-strip tap still goes home.
//
// Geometry: 240x240 physical, UI_ZOOM=2, so display.width()/height() = 120.
// =============================================================================
// NOTE on include order: mirrors WatchNotesScreen.h -- this header uses
// NodePrefs (NOTIF_* constants, channel_notif[]) and the_mesh, which the two
// call sites (UITask.cpp, main.cpp) already have in scope before including it.
#include <Arduino.h>
#include <string.h>
#include <MeshCore.h>
#include <helpers/ChannelDetails.h>
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/LGFXDisplay.h>
class UITask;
#ifndef MAX_GROUP_CHANNELS
#define MAX_GROUP_CHANNELS 20
#endif
#define WCC_ROWS 4 // list rows per page
class WatchChannelConfigScreen : public UIScreen {
public:
enum Mode { LIST, DETAIL };
private:
UITask* _task;
NodePrefs* _node_prefs;
uint8_t _chIdx[MAX_GROUP_CHANNELS]; // populated channel slots, in order
int _numCh = 0;
Mode _mode = LIST;
int _sel = 0; // selected list row (index into _chIdx)
int _scrollTop = 0;
int _detail = -1; // channel slot open in DETAIL (value from _chIdx)
bool _confirmDelete = false;
unsigned long _confirmAt = 0;
// Keyboard / exit handshake -- UITask::loop() polls these (UITask is only
// forward declared here, so this screen cannot call it directly).
bool _wantKeyboard = false;
bool _wantExit = false;
char _editScope[31]; // pre-fill text handed to the keyboard
// Layout (logical pixels)
static const int HEADER_H = 14;
static const int ROW_H = 20;
static const int FOOTER_Y = 100;
void rebuildList() {
// Scan ALL slots -- the companion app may write non-contiguously, and
// gaps can appear after deletion (same scan as SettingsScreen).
_numCh = 0;
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
_chIdx[_numCh++] = i;
}
}
if (_sel >= _numCh) _sel = _numCh > 0 ? _numCh - 1 : 0;
if (_scrollTop > _sel) _scrollTop = _sel;
}
const char* notifLabel(uint8_t idx) const {
uint8_t n = _node_prefs->channel_notif[idx];
return (n == NOTIF_NONE) ? "Off" : (n == NOTIF_MENTIONS) ? "@" : "All";
}
// Replicates SettingsScreen::deleteChannel (kept in sync manually). Note:
// channel_notif[] is not shifted during compaction -- this matches the
// desktop behaviour exactly.
void deleteChannel(uint8_t idx) {
ChannelDetails empty;
memset(&empty, 0, sizeof(empty));
int total = 0;
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
total = i + 1;
}
}
for (int i = idx; i < total - 1; i++) {
ChannelDetails next;
if (the_mesh.getChannel(i + 1, next)) {
the_mesh.setChannel(i, next);
}
}
the_mesh.setChannel(total - 1, empty);
the_mesh.saveChannels();
Serial.printf("WatchChannelCfg: Deleted channel at idx %d, compacted %d channels\n", idx, total);
}
void ensureVisible() {
if (_sel < _scrollTop) _scrollTop = _sel;
if (_sel >= _scrollTop + WCC_ROWS) _scrollTop = _sel - WCC_ROWS + 1;
}
public:
WatchChannelConfigScreen(UITask* task, NodePrefs* node_prefs)
: _task(task), _node_prefs(node_prefs) {
_editScope[0] = 0;
}
void enter() {
rebuildList();
_mode = LIST;
_detail = -1;
_confirmDelete = false;
_wantKeyboard = false;
_wantExit = false;
}
// --- handshake (polled from UITask::loop) ---
bool wantsKeyboard() const { return _wantKeyboard; }
void clearWantKeyboard() { _wantKeyboard = false; }
bool wantsExit() const { return _wantExit; }
void clearWantExit() { _wantExit = false; }
const char* getEditText() const { return _editScope; }
// Save the keyboard result as the open channel's region scope. An empty
// string clears it back to the device default (same as Clear). Returns
// NULL on success, else an error message.
const char* applyComposedScope(const char* txt) {
if (_detail < 0) return "No channel open";
ChannelDetails ch;
if (!the_mesh.getChannel((uint8_t)_detail, ch)) return "Channel gone";
if (txt == nullptr) txt = "";
strncpy(ch.scope_name, txt, sizeof(ch.scope_name));
ch.scope_name[30] = '\0';
the_mesh.setChannel((uint8_t)_detail, ch);
the_mesh.saveChannels();
Serial.printf("WatchChannelCfg: Channel %d scope set to '%s'\n",
_detail, ch.scope_name[0] ? ch.scope_name : "(device default)");
return nullptr;
}
// ---------------------------------------------------------------------------
// Rendering. 120x120 logical.
// ---------------------------------------------------------------------------
private:
void renderList(DisplayDriver& display) {
LGFXDisplay* d = (LGFXDisplay*)&display;
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 4, "Channels");
if (_numCh == 0) {
d->setRawColor(0x7BEF);
display.drawTextCentered(display.width() / 2, 48, "No channels");
}
char buf[40];
int visible = _numCh - _scrollTop;
if (visible > WCC_ROWS) visible = WCC_ROWS;
for (int i = 0; i < visible; i++) {
uint8_t idx = _chIdx[_scrollTop + i];
int y = HEADER_H + i * ROW_H;
ChannelDetails ch;
if (!the_mesh.getChannel(idx, ch)) continue;
if (_scrollTop + i == _sel) {
d->setRawColor(0x231D); // same blue as the home tile
d->drawRoundRect(1, y, display.width() - 2, ROW_H - 2, 3);
}
d->setRawColor(0xFFFF);
char nameBuf[15];
strncpy(nameBuf, ch.name, sizeof(nameBuf) - 1);
nameBuf[sizeof(nameBuf) - 1] = 0;
display.setCursor(5, y + 2);
display.print(nameBuf);
snprintf(buf, sizeof(buf), "[%s] %s",
ch.scope_name[0] ? ch.scope_name : "*", notifLabel(idx));
d->setRawColor(0x7BEF);
d->printSmallFont(5, y + 13, buf);
}
// Footer: Back (left) and, when the list overflows a page, More (right).
d->setRawColor(0x231D);
d->drawRoundRect(1, FOOTER_Y, 58, 18, 3);
d->setRawColor(0xFFFF);
d->printSmallFont(20, FOOTER_Y + 8, "Back");
if (_numCh > WCC_ROWS) {
d->setRawColor(0x231D);
d->drawRoundRect(61, FOOTER_Y, 58, 18, 3);
d->setRawColor(0xC618);
d->printSmallFont(78, FOOTER_Y + 8, "More");
}
}
void renderDetail(DisplayDriver& display) {
LGFXDisplay* d = (LGFXDisplay*)&display;
char buf[44];
ChannelDetails ch;
if (_detail < 0 || !the_mesh.getChannel((uint8_t)_detail, ch)) {
_mode = LIST;
return;
}
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
char nameBuf[18];
strncpy(nameBuf, ch.name, sizeof(nameBuf) - 1);
nameBuf[sizeof(nameBuf) - 1] = 0;
display.drawTextCentered(display.width() / 2, 3, nameBuf);
// Region row -- opens the keyboard.
d->setRawColor(0x231D);
d->drawRoundRect(1, 16, display.width() - 2, 18, 3);
snprintf(buf, sizeof(buf), "Region: %s",
ch.scope_name[0] ? ch.scope_name : "(default)");
d->setRawColor(0xFFFF);
d->printSmallFont(6, 24, buf);
// Clear-region row -- dim when there is nothing to clear.
d->setRawColor(ch.scope_name[0] ? 0x231D : 0x18C3);
d->drawRoundRect(1, 36, display.width() - 2, 18, 3);
d->setRawColor(ch.scope_name[0] ? 0xC618 : 0x4208);
d->printSmallFont(6, 44, "Clear region (use default)");
// Notify row -- tap cycles All -> Mentions -> Off.
d->setRawColor(0x231D);
d->drawRoundRect(1, 56, display.width() - 2, 18, 3);
uint8_t n = _node_prefs->channel_notif[(uint8_t)_detail];
snprintf(buf, sizeof(buf), "Notify: %s",
(n == NOTIF_NONE) ? "Off" : (n == NOTIF_MENTIONS) ? "Mentions" : "All");
d->setRawColor(0xFFFF);
d->printSmallFont(6, 64, buf);
// Delete row -- status only; deletion is driven by the button (see
// handleInput). Channel 0 (public) cannot be deleted, same as desktop.
bool canDel = (_detail > 0);
d->setRawColor(canDel ? 0x231D : 0x18C3);
d->drawRoundRect(1, 76, display.width() - 2, 18, 3);
d->setRawColor(canDel ? (_confirmDelete ? 0xF800 : 0xC618) : 0x4208);
d->printSmallFont(6, 84, !canDel ? "Delete (public ch)"
: _confirmDelete ? "Delete? press again" : "Button: delete");
// Footer: Back to the list.
d->setRawColor(0x231D);
d->drawRoundRect(1, FOOTER_Y, 58, 18, 3);
d->setRawColor(0xFFFF);
d->printSmallFont(20, FOOTER_Y + 8, "Back");
}
public:
int render(DisplayDriver& display) override {
if (_confirmDelete && millis() - _confirmAt > 3000) _confirmDelete = false;
switch (_mode) {
case LIST: renderList(display); return 500;
case DETAIL: renderDetail(display); return 500;
}
return 500;
}
// Physical tap in logical coordinates. The top-strip home tap is handled by
// main.cpp before this is called. Returns true if the tap was consumed.
bool handleTap(int lx, int ly) {
if (_mode == LIST) {
if (ly >= FOOTER_Y) {
if (lx < 60) { // Back -> Settings
_wantExit = true;
} else if (_numCh > WCC_ROWS) { // More: next page (wraps)
_scrollTop += WCC_ROWS;
if (_scrollTop >= _numCh) _scrollTop = 0;
_sel = _scrollTop;
}
return true;
}
if (ly >= HEADER_H) {
int i = (ly - HEADER_H) / ROW_H;
int idx = _scrollTop + i;
if (i < WCC_ROWS && idx < _numCh) {
if (idx == _sel) { // second tap on the row: open it
_detail = _chIdx[idx];
_mode = DETAIL;
_confirmDelete = false;
} else {
_sel = idx; // first tap: select
ensureVisible();
}
return true;
}
}
return false;
}
// DETAIL
if (ly >= FOOTER_Y) { // Back -> list
_confirmDelete = false;
_mode = LIST;
rebuildList();
return true;
}
ChannelDetails ch;
if (_detail < 0 || !the_mesh.getChannel((uint8_t)_detail, ch)) {
_mode = LIST;
return true;
}
if (ly >= 16 && ly < 34) { // Region -> keyboard, pre-filled
strncpy(_editScope, ch.scope_name, sizeof(_editScope));
_editScope[30] = '\0';
_wantKeyboard = true;
return true;
}
if (ly >= 36 && ly < 54) { // Clear region
if (ch.scope_name[0]) {
ch.scope_name[0] = '\0';
the_mesh.setChannel((uint8_t)_detail, ch);
the_mesh.saveChannels();
Serial.printf("WatchChannelCfg: Channel %d scope cleared\n", _detail);
}
return true;
}
if (ly >= 56 && ly < 74) { // Notify: cycle All -> @ -> Off
uint8_t cur = _node_prefs->channel_notif[(uint8_t)_detail];
_node_prefs->channel_notif[(uint8_t)_detail] = (cur + 1) % 3;
the_mesh.savePrefs();
return true;
}
return true; // swallow other taps (incl. delete row)
}
bool handleInput(char c) override {
// The PMU short press (S3) arrives as KEY_ENTER; the S3 Plus boot button
// arrives as KEY_NEXT.
if (_mode == LIST && c == KEY_ENTER) {
if (_sel < _numCh) {
_detail = _chIdx[_sel];
_mode = DETAIL;
_confirmDelete = false;
}
return true;
}
if (_mode == DETAIL && (c == KEY_ENTER || c == KEY_NEXT)) {
// Button click deletes the open channel (channel 0 is protected):
// first press arms, a second press within 3s confirms. Back is the
// touch footer.
if (_detail > 0) {
if (_confirmDelete) {
deleteChannel((uint8_t)_detail);
_confirmDelete = false;
_detail = -1;
rebuildList();
_mode = LIST;
} else {
_confirmDelete = true;
_confirmAt = millis();
}
}
return true;
}
return false;
}
};
@@ -1,703 +0,0 @@
#pragma once
// WatchMapScreen -- T-Watch S3 Plus map screen.
//
// Renders standard OSM "slippy map" B&W PNG tiles from the LittleFS "maps"
// partition onto the LovyanGFX sprite buffer. Tiles are stored at
// /tiles/{zoom}/{x}/{y}.png -- the same layout as the e-ink MapScreen -- but
// read via LittleFS instead of SD. The maps partition is mounted at basePath
// "/maps" in main.cpp, and the Arduino VFS prepends that mountpoint
// automatically, so tile paths passed to LittleFS stay relative ("/tiles/..").
//
// This is a separate screen from the e-ink MapScreen: the watch draws into a
// 120x120 logical sprite (UI_ZOOM=2 scales it 2x to the 240x240 panel) via the
// generic DisplayDriver / LGFXDisplay interface, whereas MapScreen is welded to
// GxEPDDisplay's drawPixelRaw path. Coordinate math, tile stitching and the
// PNGdec decode are carried over unchanged; only the pixel writes, colours,
// dimensions, filesystem and input (touch instead of WASD) differ.
#include <Arduino.h>
#include <LittleFS.h>
#include <PNGdec.h>
#undef local // PNGdec's zutil.h defines 'local' as 'static' -- breaks any variable named 'local'
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/LGFXDisplay.h>
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
#define WMAP_TILE_SIZE 256 // Standard OSM tile size in pixels
#define WMAP_DEFAULT_ZOOM 13
#define WMAP_MIN_ZOOM 1
#define WMAP_MAX_ZOOM 17
// PNG file read buffer (PSRAM). Tiles stream row-by-row via PNGdec.
#define WMAP_PNG_BUF_SIZE (65536)
// Tile path relative to the LittleFS "maps" mountpoint ("/maps" is prepended
// by the VFS, so the on-disk file is /maps/tiles/{z}/{x}/{y}.png).
#define WMAP_TILE_ROOT "/tiles"
// Contact type (for label display -- matches AdvertDataHelpers.h)
#ifndef ADV_TYPE_REPEATER
#define ADV_TYPE_REPEATER 2
#endif
// Pan step: fraction of viewport to move per press (unused for touch, kept for
// completeness / any future key routing)
#define WMAP_PAN_FRACTION 4
// Max contact markers (PSRAM-allocated)
#define WMAP_MAX_MARKERS 500
// Footer bar (logical pixels)
#define WMAP_FOOTER_H 16
// B&W tile colours (RGB565; the sprite is 8-bit but LovyanGFX converts)
#define WMAP_BLACK 0x0000
#define WMAP_WHITE 0xFFFF
class UITask;
class WatchMapScreen : public UIScreen {
public:
WatchMapScreen(UITask* task)
: _task(task),
_lgfx(nullptr),
_mapsReady(false),
_needsRedraw(true),
_hasFix(false),
_centerLat(-33.8688), // Default: Sydney
_centerLon(151.2093),
_gpsLat(0.0),
_gpsLon(0.0),
_zoom(WMAP_DEFAULT_ZOOM),
_zoomMin(WMAP_MIN_ZOOM),
_zoomMax(WMAP_MAX_ZOOM),
_pngBuf(nullptr),
_lineBuf(nullptr),
_tileFound(false),
_w(120),
_h(120),
_viewportH(120 - WMAP_FOOTER_H),
_markers(nullptr),
_numMarkers(0)
{
}
~WatchMapScreen() {
if (_pngBuf) { free(_pngBuf); _pngBuf = nullptr; }
if (_lineBuf) { free(_lineBuf); _lineBuf = nullptr; }
if (_markers) { free(_markers); _markers = nullptr; }
}
// Whether the maps LittleFS partition mounted (set from main.cpp on open)
void setMapsReady(bool ready) { _mapsReady = ready; }
// Set initial GPS position (called when opening map -- centers viewport)
void setGPSPosition(double lat, double lon) {
if (lat != 0.0 || lon != 0.0) {
_gpsLat = lat;
_gpsLon = lon;
_centerLat = lat;
_centerLon = lon;
_hasFix = true;
_needsRedraw = true;
}
}
// Update own GPS position without moving viewport (called periodically)
void updateGPSPosition(double lat, double lon) {
if (lat == 0.0 && lon == 0.0) return;
if (lat != _gpsLat || lon != _gpsLon) {
_gpsLat = lat;
_gpsLon = lon;
_hasFix = true;
_needsRedraw = true;
}
}
// Marker management (call once per contact before/while on map)
void clearMarkers() { _numMarkers = 0; }
void addMarker(double lat, double lon, const char* name = "", uint8_t type = 0) {
if (!_markers) {
_markers = (MapMarker*)ps_calloc(WMAP_MAX_MARKERS, sizeof(MapMarker));
if (!_markers) return;
}
if (_numMarkers >= WMAP_MAX_MARKERS) return;
if (lat == 0.0 && lon == 0.0) return;
_markers[_numMarkers].lat = lat;
_markers[_numMarkers].lon = lon;
_markers[_numMarkers].type = type;
strncpy(_markers[_numMarkers].name, name, sizeof(_markers[0].name) - 1);
_markers[_numMarkers].name[sizeof(_markers[0].name) - 1] = '\0';
_numMarkers++;
}
int getNumMarkers() const { return _numMarkers; }
// Called when navigating to the map screen
void enter(DisplayDriver& display) {
_lgfx = (LGFXDisplay*)&display;
_w = display.width();
_h = display.height();
_viewportH = _h - WMAP_FOOTER_H;
_needsRedraw = true;
if (!_markers) {
_markers = (MapMarker*)ps_calloc(WMAP_MAX_MARKERS, sizeof(MapMarker));
}
if (!_pngBuf) {
_pngBuf = (uint8_t*)ps_malloc(WMAP_PNG_BUF_SIZE);
if (!_pngBuf) _pngBuf = (uint8_t*)malloc(WMAP_PNG_BUF_SIZE);
}
if (!_lineBuf) {
_lineBuf = (uint16_t*)ps_malloc(WMAP_TILE_SIZE * sizeof(uint16_t));
if (!_lineBuf) _lineBuf = (uint16_t*)malloc(WMAP_TILE_SIZE * sizeof(uint16_t));
}
detectZoomRange();
}
// ---- Touch actions (called from main.cpp gesture dispatch) ----
void zoomIn() {
if (_zoom < _zoomMax) { _zoom++; _needsRedraw = true; }
}
void zoomOut() {
if (_zoom > _zoomMin) { _zoom--; _needsRedraw = true; }
}
void recenter() {
if (_hasFix) {
_centerLat = _gpsLat;
_centerLon = _gpsLon;
_needsRedraw = true;
}
}
// Pan the viewport by a logical-pixel delta (swipe). Exact: convert centre to
// global pixel space, offset, convert back.
void panByPixels(int dx, int dy) {
int ctX, ctY, cpX, cpY;
latLonToTileXY(_centerLat, _centerLon, _zoom, ctX, ctY, cpX, cpY);
long globalX = (long)ctX * WMAP_TILE_SIZE + cpX - dx;
long globalY = (long)ctY * WMAP_TILE_SIZE + cpY - dy;
long span = (long)(1 << _zoom) * WMAP_TILE_SIZE;
// Longitude wraps
while (globalX < 0) globalX += span;
while (globalX >= span) globalX -= span;
// Latitude clamps
if (globalY < 0) globalY = 0;
if (globalY > span - 1) globalY = span - 1;
int nTileX = (int)(globalX / WMAP_TILE_SIZE);
int nPixX = (int)(globalX % WMAP_TILE_SIZE);
int nTileY = (int)(globalY / WMAP_TILE_SIZE);
int nPixY = (int)(globalY % WMAP_TILE_SIZE);
tileXYToLatLon(nTileX, nTileY, nPixX, nPixY, _zoom, _centerLat, _centerLon);
_needsRedraw = true;
}
// Handle a discrete tap at logical coords. Footer +/- buttons zoom; a tap in
// the map area recenters on GPS. Returns true if the tap was consumed.
bool handleTap(int x, int y) {
int footerY = _h - WMAP_FOOTER_H;
if (y >= footerY) {
if (inRect(x, y, _plusX, _btnY, _btnW, _btnH)) { zoomIn(); return true; }
if (inRect(x, y, _minusX, _btnY, _btnW, _btnH)) { zoomOut(); return true; }
return false; // footer, but not on a button
}
// Tap in the map area -> recenter on own position
recenter();
return true;
}
// ---- UIScreen interface ----
int render(DisplayDriver& display) override {
_lgfx = (LGFXDisplay*)&display;
_w = display.width();
_h = display.height();
_viewportH = _h - WMAP_FOOTER_H;
if (!_mapsReady) {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(4, 20);
display.print("No map tiles");
renderFooter(display);
return 5000;
}
bool wasRedraw = _needsRedraw;
_needsRedraw = false;
renderMapViewport();
renderContactMarkers();
renderCrosshair();
renderFooter(display);
return wasRedraw ? 500 : 30000;
}
private:
UITask* _task;
LGFXDisplay* _lgfx;
bool _mapsReady;
bool _needsRedraw;
bool _hasFix;
double _centerLat;
double _centerLon;
double _gpsLat;
double _gpsLon;
int _zoom;
int _zoomMin;
int _zoomMax;
uint8_t* _pngBuf;
uint16_t* _lineBuf;
bool _tileFound;
int _w;
int _h;
int _viewportH;
// Footer button rects (set in renderFooter)
int _plusX = 0, _minusX = 0, _btnY = 0, _btnW = 18, _btnH = 14;
PNG _png;
struct MapMarker {
double lat;
double lon;
char name[20];
uint8_t type;
};
MapMarker* _markers;
int _numMarkers;
struct DrawContext {
LGFXDisplay* display;
PNG* png;
int offsetX;
int offsetY;
int viewportH;
int dispW;
uint16_t* lineBuf;
};
DrawContext _drawCtx;
static bool inRect(int px, int py, int x, int y, int w, int h) {
return px >= x && px < x + w && py >= y && py < y + h;
}
// ==========================================================================
// Detect available zoom levels from /tiles/{z}/ directories on LittleFS
// ==========================================================================
void detectZoomRange() {
if (!_mapsReady) return;
_zoomMin = WMAP_MAX_ZOOM;
_zoomMax = WMAP_MIN_ZOOM;
char path[32];
for (int z = WMAP_MIN_ZOOM; z <= WMAP_MAX_ZOOM; z++) {
snprintf(path, sizeof(path), WMAP_TILE_ROOT "/%d", z);
if (LittleFS.exists(path)) {
if (z < _zoomMin) _zoomMin = z;
if (z > _zoomMax) _zoomMax = z;
}
}
if (_zoomMin > _zoomMax) {
_zoomMin = WMAP_MIN_ZOOM;
_zoomMax = WMAP_MAX_ZOOM;
Serial.println("WatchMapScreen: no tile directories found");
} else {
Serial.printf("WatchMapScreen: detected zoom range %d-%d\n", _zoomMin, _zoomMax);
}
if (_zoom > _zoomMax) _zoom = _zoomMax;
if (_zoom < _zoomMin) _zoom = _zoomMin;
}
// ==========================================================================
// Tile coordinate math (Web Mercator / Slippy Map) -- identical to MapScreen
// ==========================================================================
static void latLonToTileXY(double lat, double lon, int zoom,
int& tileX, int& tileY,
int& pixelX, int& pixelY)
{
int n = 1 << zoom;
double x = (lon + 180.0) / 360.0 * n;
tileX = (int)floor(x);
pixelX = (int)((x - tileX) * WMAP_TILE_SIZE);
double latRad = lat * PI / 180.0;
double y = (1.0 - log(tan(latRad) + 1.0 / cos(latRad)) / PI) / 2.0 * n;
tileY = (int)floor(y);
pixelY = (int)((y - tileY) * WMAP_TILE_SIZE);
}
static void tileXYToLatLon(int tileX, int tileY, int pixelX, int pixelY,
int zoom, double& lat, double& lon)
{
int n = 1 << zoom;
double x = tileX + (double)pixelX / WMAP_TILE_SIZE;
double y = tileY + (double)pixelY / WMAP_TILE_SIZE;
lon = x / n * 360.0 - 180.0;
double latRad = atan(sinh(PI * (1.0 - 2.0 * y / n)));
lat = latRad * 180.0 / PI;
}
bool latLonToScreen(double lat, double lon, int& screenX, int& screenY) {
int centerTileX, centerTileY, centerPixelX, centerPixelY;
latLonToTileXY(_centerLat, _centerLon, _zoom,
centerTileX, centerTileY, centerPixelX, centerPixelY);
int targetTileX, targetTileY, targetPixelX, targetPixelY;
latLonToTileXY(lat, lon, _zoom,
targetTileX, targetTileY, targetPixelX, targetPixelY);
int dx = (targetTileX - centerTileX) * WMAP_TILE_SIZE + (targetPixelX - centerPixelX);
int dy = (targetTileY - centerTileY) * WMAP_TILE_SIZE + (targetPixelY - centerPixelY);
screenX = _w / 2 + dx;
screenY = _viewportH / 2 + dy;
return (screenX >= 0 && screenX < _w &&
screenY >= 0 && screenY < _viewportH);
}
// ==========================================================================
// Tile loading and rendering
// ==========================================================================
static void buildTilePath(char* buf, int bufSize, int zoom, int x, int y) {
snprintf(buf, bufSize, WMAP_TILE_ROOT "/%d/%d/%d.png", zoom, x, y);
}
bool loadAndRenderTile(int tileX, int tileY, int screenX, int screenY) {
if (!_pngBuf || !_lineBuf || !_lgfx) return false;
char path[64];
buildTilePath(path, sizeof(path), _zoom, tileX, tileY);
if (!LittleFS.exists(path)) return false;
File f = LittleFS.open(path, FILE_READ);
if (!f) return false;
int fileSize = f.size();
if (fileSize > WMAP_PNG_BUF_SIZE) {
Serial.printf("WatchMapScreen: tile too large: %s (%d bytes)\n", path, fileSize);
f.close();
return false;
}
int bytesRead = f.read(_pngBuf, fileSize);
f.close();
if (bytesRead != fileSize) {
Serial.printf("WatchMapScreen: short read: %s (%d/%d)\n", path, bytesRead, fileSize);
return false;
}
_drawCtx.display = _lgfx;
_drawCtx.png = &_png;
_drawCtx.offsetX = screenX;
_drawCtx.offsetY = screenY;
_drawCtx.viewportH = _viewportH;
_drawCtx.dispW = _w;
_drawCtx.lineBuf = _lineBuf;
int rc = _png.openRAM(_pngBuf, fileSize, pngDrawCallback);
if (rc != PNG_SUCCESS) {
Serial.printf("WatchMapScreen: PNG open failed: %s (rc=%d)\n", path, rc);
return false;
}
rc = _png.decode(&_drawCtx, 0);
_png.close();
if (rc != PNG_SUCCESS) {
Serial.printf("WatchMapScreen: PNG decode failed: %s (rc=%d)\n", path, rc);
return false;
}
return true;
}
// PNGdec scanline callback -- draws one decoded row into the sprite buffer.
static int pngDrawCallback(PNGDRAW* pDraw) {
DrawContext* ctx = (DrawContext*)pDraw->pUser;
if (!ctx || !ctx->display || !ctx->png || !ctx->lineBuf) return 0;
int screenY = ctx->offsetY + pDraw->y;
if (screenY < 0 || screenY >= ctx->viewportH) return 1;
uint16_t lineWidth = pDraw->iWidth;
if (lineWidth > WMAP_TILE_SIZE) lineWidth = WMAP_TILE_SIZE;
ctx->png->getLineAsRGB565(pDraw, ctx->lineBuf, PNG_RGB565_LITTLE_ENDIAN, 0xFFFFFFFF);
for (int x = 0; x < lineWidth; x++) {
int screenX = ctx->offsetX + x;
if (screenX < 0 || screenX >= ctx->dispW) continue;
uint16_t pixel = ctx->lineBuf[x];
uint16_t color = (pixel > 0x7FFF) ? WMAP_WHITE : WMAP_BLACK;
ctx->display->drawPixelRaw(screenX, screenY, color);
}
return 1;
}
// ==========================================================================
// Viewport rendering -- stitch tiles to fill the map area
// ==========================================================================
void renderMapViewport() {
if (!_lgfx) return;
int centerTileX, centerTileY, centerPixelX, centerPixelY;
latLonToTileXY(_centerLat, _centerLon, _zoom,
centerTileX, centerTileY, centerPixelX, centerPixelY);
int viewCenterX = _w / 2;
int viewCenterY = _viewportH / 2;
int baseTileScreenX = viewCenterX - centerPixelX;
int baseTileScreenY = viewCenterY - centerPixelY;
int startDX = 0, startDY = 0;
int endDX = 0, endDY = 0;
while (baseTileScreenX + startDX * WMAP_TILE_SIZE > 0) startDX--;
while (baseTileScreenY + startDY * WMAP_TILE_SIZE > 0) startDY--;
while (baseTileScreenX + (endDX + 1) * WMAP_TILE_SIZE < _w) endDX++;
while (baseTileScreenY + (endDY + 1) * WMAP_TILE_SIZE < _viewportH) endDY++;
int maxTile = (1 << _zoom) - 1;
int loaded = 0, missing = 0;
for (int dy = startDY; dy <= endDY; dy++) {
for (int dx = startDX; dx <= endDX; dx++) {
int tx = centerTileX + dx;
int ty = centerTileY + dy;
if (tx < 0) tx += (1 << _zoom);
if (tx > maxTile) tx -= (1 << _zoom);
if (ty < 0 || ty > maxTile) continue;
int screenX = baseTileScreenX + dx * WMAP_TILE_SIZE;
int screenY = baseTileScreenY + dy * WMAP_TILE_SIZE;
if (loadAndRenderTile(tx, ty, screenX, screenY)) loaded++;
else missing++;
yield();
}
}
Serial.printf("WatchMapScreen: rendered %d tiles, %d missing\n", loaded, missing);
_tileFound = (loaded > 0);
}
// ==========================================================================
// Contact marker overlay
// ==========================================================================
void renderContactMarkers() {
if (!_lgfx || !_markers) return;
for (int i = 0; i < _numMarkers; i++) {
int sx, sy;
if (latLonToScreen(_markers[i].lat, _markers[i].lon, sx, sy)) {
int r = markerRadius();
drawDiamond(sx, sy, r);
if (_markers[i].name[0] != '\0' &&
(_markers[i].type == ADV_TYPE_REPEATER || _zoom >= 14)) {
drawLabel(sx, sy - r - 2, _markers[i].name);
}
}
}
if (_hasFix) {
int sx, sy;
if (latLonToScreen(_gpsLat, _gpsLon, sx, sy)) {
drawOwnPosition(sx, sy);
}
}
}
// Marker radius scaled by zoom (smaller than e-ink -- 120px canvas)
int markerRadius() {
int r = _zoom - 9;
if (r < 2) r = 2;
if (r > 6) r = 6;
return r;
}
void drawDiamond(int cx, int cy, int r) {
// White outline (1px larger than fill)
for (int dy = -(r + 1); dy <= (r + 1); dy++) {
int span = (r + 1) - abs(dy);
int innerSpan = r - abs(dy);
for (int dx = -span; dx <= span; dx++) {
if (abs(dy) <= r && abs(dx) <= innerSpan) continue;
int px = cx + dx, py = cy + dy;
if (px >= 0 && px < _w && py >= 0 && py < _viewportH) {
_lgfx->drawPixelRaw(px, py, WMAP_WHITE);
}
}
}
// Filled black diamond
for (int dy = -r; dy <= r; dy++) {
int span = r - abs(dy);
for (int dx = -span; dx <= span; dx++) {
int px = cx + dx, py = cy + dy;
if (px >= 0 && px < _w && py >= 0 && py < _viewportH) {
_lgfx->drawPixelRaw(px, py, WMAP_BLACK);
}
}
}
}
static int extractAsciiLabel(const char* src, char* dest, int destSize) {
int j = 0;
for (int i = 0; src[i] != '\0' && j < destSize - 1; i++) {
uint8_t ch = (uint8_t)src[i];
if (ch >= 0x20 && ch <= 0x7E) dest[j++] = src[i];
}
dest[j] = '\0';
int start = 0;
while (dest[start] == ' ') start++;
if (start > 0) {
memmove(dest, dest + start, j - start + 1);
j -= start;
}
return j;
}
// Draw a marker label with a white background box, using the normal buffered
// text API (Font0 6x8). Kept short -- the canvas is only 120px wide.
void drawLabel(int cx, int topY, const char* text) {
if (!_lgfx) return;
char clean[16];
int len = extractAsciiLabel(text, clean, sizeof(clean));
if (len == 0) return;
if (len > 10) { len = 10; clean[len] = '\0'; }
int textW = len * 6;
int textH = 8;
int lx = cx - textW / 2;
int ly = topY - textH;
if (lx < 1) lx = 1;
if (lx + textW >= _w - 1) lx = _w - textW - 1;
if (ly < 0) ly = 0;
if (ly + textH >= _viewportH) return;
_lgfx->setColor(DisplayDriver::LIGHT);
_lgfx->fillRect(lx - 1, ly - 1, textW + 2, textH + 2);
_lgfx->setColor(DisplayDriver::DARK);
_lgfx->setTextSize(1);
_lgfx->setCursor(lx, ly);
_lgfx->print(clean);
}
// Own-position marker: bold circle with filled centre dot
void drawOwnPosition(int cx, int cy) {
int r = 6;
// White halo
for (int dy = -(r + 2); dy <= (r + 2); dy++) {
for (int dx = -(r + 2); dx <= (r + 2); dx++) {
if (dx * dx + dy * dy <= (r + 2) * (r + 2)) {
int px = cx + dx, py = cy + dy;
if (px >= 0 && px < _w && py >= 0 && py < _viewportH)
_lgfx->drawPixelRaw(px, py, WMAP_WHITE);
}
}
}
// Black ring
for (int dy = -r; dy <= r; dy++) {
for (int dx = -r; dx <= r; dx++) {
int d2 = dx * dx + dy * dy;
if (d2 >= (r - 2) * (r - 2) && d2 <= r * r) {
int px = cx + dx, py = cy + dy;
if (px >= 0 && px < _w && py >= 0 && py < _viewportH)
_lgfx->drawPixelRaw(px, py, WMAP_BLACK);
}
}
}
// Centre dot
for (int dy = -2; dy <= 2; dy++) {
for (int dx = -2; dx <= 2; dx++) {
if (dx * dx + dy * dy <= 4) {
int px = cx + dx, py = cy + dy;
if (px >= 0 && px < _w && py >= 0 && py < _viewportH)
_lgfx->drawPixelRaw(px, py, WMAP_BLACK);
}
}
}
}
// ==========================================================================
// Crosshair at viewport centre
// ==========================================================================
void renderCrosshair() {
if (!_lgfx) return;
int cx = _w / 2;
int cy = _viewportH / 2;
int len = markerRadius() + 2;
for (int x = cx - len; x <= cx + len; x++) {
if (x >= 0 && x < _w) {
if (cy - 1 >= 0) _lgfx->drawPixelRaw(x, cy - 1, WMAP_WHITE);
if (cy + 1 < _viewportH) _lgfx->drawPixelRaw(x, cy + 1, WMAP_WHITE);
_lgfx->drawPixelRaw(x, cy, WMAP_BLACK);
}
}
for (int y = cy - len; y <= cy + len; y++) {
if (y >= 0 && y < _viewportH) {
if (cx - 1 >= 0) _lgfx->drawPixelRaw(cx - 1, y, WMAP_WHITE);
if (cx + 1 < _w) _lgfx->drawPixelRaw(cx + 1, y, WMAP_WHITE);
_lgfx->drawPixelRaw(cx, y, WMAP_BLACK);
}
}
}
// ==========================================================================
// Footer -- zoom level + on-screen +/- zoom buttons
// ==========================================================================
void renderFooter(DisplayDriver& display) {
int footerY = _h - WMAP_FOOTER_H;
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, footerY - 1, _w, 1);
// Left: zoom level
char left[8];
snprintf(left, sizeof(left), "Z%d", _zoom);
display.setCursor(1, footerY + 4);
display.print(left);
// Right: [-] [+] buttons
_btnW = 18;
_btnH = WMAP_FOOTER_H - 2;
_btnY = footerY + 1;
_plusX = _w - _btnW - 1;
_minusX = _plusX - _btnW - 2;
display.setColor(DisplayDriver::LIGHT);
display.drawRect(_minusX, _btnY, _btnW, _btnH);
display.drawRect(_plusX, _btnY, _btnW, _btnH);
display.setCursor(_minusX + 6, _btnY + 3);
display.print("-");
display.setCursor(_plusX + 5, _btnY + 3);
display.print("+");
}
};
@@ -1,433 +0,0 @@
#pragma once
// =============================================================================
// WatchNotesScreen -- short notes for the LilyGo T-Watch S3 / S3 Plus.
//
// The T-Deck Pro's NotesScreen.h is bound to the SD card (list/read/write/
// rename all via SD.h), which the watches do not have. This is a separate,
// much smaller screen that stores notes as .txt files on the LittleFS "maps"
// partition (the same one WatchAlarmScreen uses for /alarms.cfg).
//
// Notes are composed and edited on the TWatchKeyboardScreen (purpose
// TWKB_NOTE), so a note is capped at the keyboard buffer (133 chars). Editing
// opens the keyboard pre-filled with the note text and saving replaces the
// file. Files longer than the view buffer (e.g. copied on by other means) are
// shown truncated, and editing one truncates it to the keyboard cap.
//
// Filenames are auto timestamps, YYMMDD-HHMMSS.txt in local time, so a plain
// descending name sort is newest-first. If the clock is not set yet the name
// falls back to u<millis>.txt.
//
// LIST: tap a row to select it, tap the selected row again (or press
// PWR/Enter) to open it; the [New] footer button composes a new note.
// VIEW: footer [Back] [Edit] [Del]; Del asks once ("Sure?") before deleting;
// tapping the text area scrolls when the note is longer than one screen.
// The top status strip is handled by main.cpp before this screen and returns
// home, matching the other watch screens.
//
// Geometry: the display is 240x240 physical, UI_ZOOM=2, so display.width()
// and display.height() both report 120 logical pixels.
// =============================================================================
// NOTE on include order: this header uses NodePrefs, which lives at
// examples/companion_radio/NodePrefs.h and is not on this variant's -I path.
// UITask.h pulls it in via "../NodePrefs.h", so WatchNotesScreen.h must always
// be included *after* UITask.h. Both call sites (UITask.cpp, main.cpp) do.
// This mirrors WatchAlarmScreen.h.
#include <Arduino.h>
#include <string.h>
#include <LittleFS.h>
#include <MeshCore.h> // mesh::RTCClock
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/LGFXDisplay.h>
class UITask;
#define WATCH_NOTES_DIR "/notes"
#define WATCH_NOTES_MAX 24 // list cap
#define WATCH_NOTE_NAME_LEN 20 // "YYMMDD-HHMMSS.txt" + NUL
#define WATCH_NOTE_PREVIEW 22 // first-line preview chars kept per entry
#define WATCH_NOTE_BUF 512 // view/edit buffer (on-watch notes are <=133)
#define WATCH_NOTE_COLS 19 // wrap width at textSize(1) on 120px
#define WATCH_NOTE_VIEW_LINES 9 // wrapped lines visible above the footer
class WatchNotesScreen : public UIScreen {
public:
enum Mode { LIST, VIEW };
private:
UITask* _task;
mesh::RTCClock* _rtc;
NodePrefs* _node_prefs;
Mode _mode = LIST;
int _sel = 0; // selected row in LIST
int _scroll = 0; // first visible row in LIST
int _count = 0;
char _names[WATCH_NOTES_MAX][WATCH_NOTE_NAME_LEN];
char _previews[WATCH_NOTES_MAX][WATCH_NOTE_PREVIEW];
// VIEW state
char _curName[WATCH_NOTE_NAME_LEN];
char _noteBuf[WATCH_NOTE_BUF];
int _noteLen = 0;
int _lineCount = 0;
int _viewScroll = 0; // first visible wrapped line
bool _delArmed = false;
// Keyboard handshake (UITask::loop polls these; UITask is only forward
// declared here, so this screen never calls it directly)
bool _wantKeyboard = false;
bool _editPending = false; // true = prefill keyboard with _noteBuf
char _editName[WATCH_NOTE_NAME_LEN];
int32_t localNow() const {
uint32_t utc = _rtc->getCurrentTime();
if (utc < 1700000000) return -1; // clock not set yet
return (int32_t)utc + ((int32_t)_node_prefs->utc_offset_hours * 3600);
}
// days-from-epoch -> civil date (Howard Hinnant's algorithm)
static void civilFromDays(int32_t z, int& y, int& m, int& d) {
z += 719468;
int32_t era = (z >= 0 ? z : z - 146096) / 146097;
uint32_t doe = (uint32_t)(z - era * 146097);
uint32_t yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
int32_t yr = (int32_t)yoe + era * 400;
uint32_t doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
uint32_t mp = (5 * doy + 2) / 153;
d = (int)(doy - (153 * mp + 2) / 5 + 1);
m = (int)(mp < 10 ? mp + 3 : mp - 9);
y = (int)(yr + (m <= 2));
}
void makeName(char* out, size_t outLen) {
int32_t local = localNow();
if (local < 0) {
snprintf(out, outLen, "u%07lu.txt", (unsigned long)(millis() % 10000000UL));
return;
}
int y, mo, d;
civilFromDays(local / 86400, y, mo, d);
int hh = (local / 3600) % 24;
int mi = (local / 60) % 60;
int ss = local % 60;
snprintf(out, outLen, "%02d%02d%02d-%02d%02d%02d.txt",
y % 100, mo, d, hh, mi, ss);
}
// "260714-173245.txt" -> "14/07 17:32"; u-names -> "no clock"
static void dateLabel(const char* name, char* out, size_t outLen) {
if (name[0] == 'u' || strlen(name) < 17) {
snprintf(out, outLen, "no clock");
return;
}
snprintf(out, outLen, "%.2s/%.2s %.2s:%.2s",
name + 4, name + 2, name + 7, name + 9);
}
static const char* leafOf(const char* n) {
const char* s = strrchr(n, '/');
return s ? s + 1 : n;
}
void readPreview(const char* leaf, char* out, size_t outLen) {
char path[40];
snprintf(path, sizeof(path), WATCH_NOTES_DIR "/%s", leaf);
out[0] = 0;
File f = LittleFS.open(path, "r");
if (!f) return;
size_t i = 0;
while (i < outLen - 1 && f.available()) {
int ch = f.read();
if (ch < 0 || ch == '\n' || ch == '\r') break;
out[i++] = (char)ch;
}
out[i] = 0;
f.close();
}
void scanNotes() {
_count = 0;
if (!LittleFS.exists(WATCH_NOTES_DIR)) LittleFS.mkdir(WATCH_NOTES_DIR);
File root = LittleFS.open(WATCH_NOTES_DIR);
if (!root || !root.isDirectory()) return;
File f = root.openNextFile();
while (f && _count < WATCH_NOTES_MAX) {
if (!f.isDirectory()) {
const char* leaf = leafOf(f.name());
size_t n = strlen(leaf);
if (n > 4 && n < WATCH_NOTE_NAME_LEN &&
strcmp(leaf + n - 4, ".txt") == 0) {
// insertion sort, descending name = newest first
int pos = _count;
while (pos > 0 && strcmp(leaf, _names[pos - 1]) > 0) pos--;
for (int k = _count; k > pos; k--) {
memcpy(_names[k], _names[k - 1], WATCH_NOTE_NAME_LEN);
}
strncpy(_names[pos], leaf, WATCH_NOTE_NAME_LEN - 1);
_names[pos][WATCH_NOTE_NAME_LEN - 1] = 0;
_count++;
}
}
f = root.openNextFile();
}
root.close();
for (int i = 0; i < _count; i++) readPreview(_names[i], _previews[i], WATCH_NOTE_PREVIEW);
if (_sel >= _count) _sel = _count ? _count - 1 : 0;
if (_scroll > _sel) _scroll = _sel;
}
void countLines() {
_lineCount = (_noteLen + WATCH_NOTE_COLS - 1) / WATCH_NOTE_COLS;
if (_lineCount < 1) _lineCount = 1;
}
bool loadNote(const char* leaf) {
char path[40];
snprintf(path, sizeof(path), WATCH_NOTES_DIR "/%s", leaf);
File f = LittleFS.open(path, "r");
if (!f) return false;
_noteLen = f.read((uint8_t*)_noteBuf, WATCH_NOTE_BUF - 1);
if (_noteLen < 0) _noteLen = 0;
_noteBuf[_noteLen] = 0;
f.close();
strncpy(_curName, leaf, WATCH_NOTE_NAME_LEN - 1);
_curName[WATCH_NOTE_NAME_LEN - 1] = 0;
countLines();
_viewScroll = 0;
_delArmed = false;
return true;
}
void openSelected() {
if (_sel < 0 || _sel >= _count) return;
if (loadNote(_names[_sel])) _mode = VIEW;
}
void deleteCurrent() {
char path[40];
snprintf(path, sizeof(path), WATCH_NOTES_DIR "/%s", _curName);
LittleFS.remove(path);
_delArmed = false;
_mode = LIST;
scanNotes();
}
// ---------------------------------------------------------------------------
// Rendering. 120x120 logical.
// ---------------------------------------------------------------------------
static const int ROW_Y0 = 18;
static const int ROW_H = 20;
static const int ROWS_VISIBLE = 4;
static const int FOOTER_Y = 104;
void ensureVisible() {
if (_sel < _scroll) _scroll = _sel;
if (_sel >= _scroll + ROWS_VISIBLE) _scroll = _sel - ROWS_VISIBLE + 1;
}
void renderList(DisplayDriver& display) {
LGFXDisplay* d = (LGFXDisplay*)&display;
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 6, "Notes");
if (_count == 0) {
d->setRawColor(0x7BEF);
display.drawTextCentered(display.width() / 2, 50, "No notes yet");
}
char buf[16];
for (int i = 0; i < ROWS_VISIBLE; i++) {
int idx = _scroll + i;
if (idx >= _count) break;
int y = ROW_Y0 + i * ROW_H;
if (idx == _sel) {
d->setRawColor(0x231D); // same blue as the home tile
d->drawRoundRect(1, y, display.width() - 2, ROW_H - 2, 3);
}
d->setRawColor(0xFFFF);
display.setCursor(5, y + 2);
display.print(_previews[idx][0] ? _previews[idx] : "(empty)");
dateLabel(_names[idx], buf, sizeof(buf));
d->setRawColor(0x7BEF);
d->printSmallFont(5, y + 13, buf);
}
d->setRawColor(0x231D);
d->drawRoundRect(31, FOOTER_Y, 58, 14, 3);
d->setRawColor(0xFFFF);
d->printSmallFont(52, FOOTER_Y + 8, "New");
}
void renderView(DisplayDriver& display) {
LGFXDisplay* d = (LGFXDisplay*)&display;
char buf[16];
dateLabel(_curName, buf, sizeof(buf));
d->setRawColor(0x7BEF);
d->printSmallFont(4, 8, buf);
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
int maxScroll = _lineCount - WATCH_NOTE_VIEW_LINES;
if (maxScroll < 0) maxScroll = 0;
if (_viewScroll > maxScroll) _viewScroll = maxScroll;
char line[WATCH_NOTE_COLS + 1];
for (int i = 0; i < WATCH_NOTE_VIEW_LINES; i++) {
int ln = _viewScroll + i;
int off = ln * WATCH_NOTE_COLS;
if (off >= _noteLen) break;
int n = _noteLen - off;
if (n > WATCH_NOTE_COLS) n = WATCH_NOTE_COLS;
memcpy(line, _noteBuf + off, n);
line[n] = 0;
d->setRawColor(0xFFFF);
display.setCursor(2, 14 + i * 10);
display.print(line);
}
// Footer: [Back] [Edit] [Del]
d->setRawColor(0x231D);
d->drawRoundRect(1, FOOTER_Y, 38, 14, 3);
d->drawRoundRect(41, FOOTER_Y, 38, 14, 3);
d->setRawColor(_delArmed ? 0xF800 : 0x231D);
d->drawRoundRect(81, FOOTER_Y, 38, 14, 3);
d->setRawColor(0xC618);
d->printSmallFont(9, FOOTER_Y + 8, "Back");
d->printSmallFont(49, FOOTER_Y + 8, "Edit");
d->setRawColor(_delArmed ? 0xF800 : 0xC618);
d->printSmallFont(87, FOOTER_Y + 8, _delArmed ? "Sure?" : "Del");
}
public:
WatchNotesScreen(UITask* task, mesh::RTCClock* rtc, NodePrefs* node_prefs)
: _task(task), _rtc(rtc), _node_prefs(node_prefs) {
_curName[0] = 0;
_editName[0] = 0;
}
void enter() {
_mode = LIST;
_delArmed = false;
_wantKeyboard = false;
_editPending = false;
scanNotes();
_sel = 0;
_scroll = 0;
}
Mode getMode() const { return _mode; }
// --- Keyboard handshake, polled from UITask::loop ---
bool wantsKeyboard() const { return _wantKeyboard; }
void clearWantKeyboard() { _wantKeyboard = false; }
bool editPending() const { return _editPending; }
const char* getEditText() const { return _noteBuf; }
// Result of a TWKB_NOTE keyboard session. Returns NULL on success or an
// error message for UITask to show; the caller returns to this screen.
const char* applyComposedNote(const char* text) {
if (text == nullptr || text[0] == 0) { _editPending = false; return "Empty note"; }
char name[WATCH_NOTE_NAME_LEN];
if (_editPending && _editName[0]) {
strncpy(name, _editName, sizeof(name) - 1);
name[sizeof(name) - 1] = 0;
} else {
makeName(name, sizeof(name));
}
_editPending = false;
_editName[0] = 0;
char path[40];
snprintf(path, sizeof(path), WATCH_NOTES_DIR "/%s", name);
if (!LittleFS.exists(WATCH_NOTES_DIR)) LittleFS.mkdir(WATCH_NOTES_DIR);
File f = LittleFS.open(path, "w");
if (!f) return "Save failed";
f.print(text);
f.close();
scanNotes();
if (loadNote(name)) _mode = VIEW; else _mode = LIST;
return nullptr;
}
// Physical tap in logical (display.width()) coordinates. Returns true if the
// tap was consumed. The status-bar strip is handled by main.cpp before this.
bool handleTap(int lx, int ly) {
if (_mode == LIST) {
if (ly >= FOOTER_Y) { // footer: New
if (lx >= 31 && lx < 89) {
_editPending = false;
_editName[0] = 0;
_wantKeyboard = true;
}
return true;
}
if (ly >= ROW_Y0) {
int row = (ly - ROW_Y0) / ROW_H;
int idx = _scroll + row;
if (row >= 0 && row < ROWS_VISIBLE && idx < _count) {
if (idx == _sel) openSelected(); // second tap opens
else { _sel = idx; ensureVisible(); } // first tap selects
}
return true;
}
return false;
}
// VIEW
if (ly >= FOOTER_Y) {
if (lx < 40) { _delArmed = false; _mode = LIST; } // Back
else if (lx < 80) { // Edit
_delArmed = false;
strncpy(_editName, _curName, sizeof(_editName) - 1);
_editName[sizeof(_editName) - 1] = 0;
_editPending = true;
_wantKeyboard = true;
} else { // Del
if (_delArmed) deleteCurrent();
else _delArmed = true;
}
return true;
}
_delArmed = false;
if (_lineCount > WATCH_NOTE_VIEW_LINES) { // scroll zones
if (ly < FOOTER_Y / 2) { if (_viewScroll > 0) _viewScroll--; }
else { if (_viewScroll < _lineCount - WATCH_NOTE_VIEW_LINES) _viewScroll++; }
}
return true;
}
bool handleInput(char c) override {
// The PMU short press (S3) / user button arrives as KEY_ENTER.
if (_mode == LIST) {
if (c == KEY_ENTER) { openSelected(); return true; }
return false; // KEY_CANCEL falls through -> home
}
// VIEW
if (c == KEY_ENTER) { // button = Edit, matching the footer
_delArmed = false;
strncpy(_editName, _curName, sizeof(_editName) - 1);
_editName[sizeof(_editName) - 1] = 0;
_editPending = true;
_wantKeyboard = true;
return true;
}
if (c == KEY_CANCEL) { _delArmed = false; _mode = LIST; return true; }
return true; // swallow the rest while viewing
}
int render(DisplayDriver& display) override {
switch (_mode) {
case LIST: renderList(display); return 500;
case VIEW: renderView(display); return 500;
}
return 500;
}
};
-12
View File
@@ -56,28 +56,16 @@ void LGFXDisplay::setColor(Color c) {
_color = TFT_WHITE;
break;
case RED:
#if defined(MECK_TWATCH)
_color = 0x7BEF; // dark grey -- the watch UI uses a grey/white/black palette
#else
_color = TFT_RED;
#endif
break;
case GREEN:
#if defined(MECK_TWATCH)
_color = 0xC618; // light grey
#else
_color = TFT_GREEN;
#endif
break;
case BLUE:
_color = TFT_BLUE;
break;
case YELLOW:
#if defined(MECK_TWATCH)
_color = TFT_WHITE;
#else
_color = TFT_YELLOW;
#endif
break;
case ORANGE:
_color = TFT_ORANGE;
-52
View File
@@ -28,58 +28,6 @@ public:
void startFrame(Color bkg = DARK) override;
void setTextSize(int sz) override;
void setColor(Color c) override;
#if defined(MECK_TWATCH)
// 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; buffer.setTextColor(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); }
// Plot a single pixel at an exact RGB565 colour directly into the sprite
// buffer. Used by WatchMapScreen to draw decoded map tiles, markers and the
// crosshair (parallels GxEPDDisplay::drawPixelRaw on the e-ink boards).
void drawPixelRaw(int x, int y, uint16_t c) { buffer.drawPixel(x, y, c); }
// 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);
}
// Width of a string in the tiny font (the font printSmallFont uses), for
// right-aligning tiny-font header text. Restores the default font after.
uint16_t smallTextWidth(const char* str) {
buffer.setFont(&fonts::TomThumb);
uint16_t w = buffer.textWidth(str);
buffer.setFont(&fonts::Font0);
return w;
}
// Enable/disable horizontal text wrap on the sprite buffer. The compose
// ticker turns wrap off so a long scrolling line is clipped at the screen
// edge instead of wrapping onto the rows below.
void setTextWrap(bool en) { buffer.setTextWrap(en, false); }
// Current print-cursor X on the sprite buffer -- lets a caller measure how
// wide a just-printed row was (used by the settings row ticker).
int getCursorX() { return buffer.getCursorX(); }
// Draw a string centred horizontally at mid_x with its top at y, in
// FreeSansBold 24pt (used for the clock HH:MM), then restore the default
// GLCD font + size so later text is unaffected.
void printClockFont(int mid_x, int y, const char* str) {
buffer.setFont(&fonts::FreeSansBold24pt7b);
buffer.setTextColor(_color);
buffer.setTextSize(1);
int w = buffer.textWidth(str);
buffer.setCursor(mid_x - w / 2, 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;
-113
View File
@@ -1,113 +0,0 @@
#pragma once
#include <Arduino.h>
// 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 80 // MHz -- S3: no map screen, so cap boost to idle to save power
#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
-92
View File
@@ -1,92 +0,0 @@
#pragma once
#include <Arduino.h>
#include <Wire.h>
// Minimal inline DRV2605 haptic driver for the LilyGo T-Watch S3.
//
// Copied from variants/lilygo_tdeck_max/DRV2605Haptic.h so that the MAX build is
// untouched. Register sequence is identical; only the power note differs.
//
// The DRV2605L (schematic sheet 3, U47) sits on the shared Wire bus at I2C 0x5A
// and drives the 0827 coin motor on J11. Unlike the T-Deck Pro MAX, whose motor
// supply is gated by an XL9555 expander pin (board.motorEnable()), the watch
// powers the DRV2605 from the AXP2101 BLDO2 rail, which TWatchS3Board::power_init()
// already brings up at 3300 mV. There is therefore nothing to enable before
// begin(); if it does not ACK, BLDO2 is off or the I2C bus is not up yet.
//
// The register sequence mirrors Adafruit_DRV2605::begin() followed by
// selectLibrary(1) + setMode(INTTRIG) - i.e. exactly what the LilyGo basic.ino
// example does: an ERM motor, open-loop, internal-trigger, effect library 1.
class DRV2605Haptic {
public:
DRV2605Haptic(uint8_t addr = 0x5A, TwoWire* wire = &Wire)
: _addr(addr), _wire(wire) {}
// Returns false if the DRV2605 does not ACK (e.g. BLDO2 still off).
bool begin() {
_wire->beginTransmission(_addr);
if (_wire->endTransmission() != 0) return false;
writeReg(REG_MODE, 0x00); // exit standby, internal trigger
writeReg(REG_RTPIN, 0x00); // no real-time playback input
writeReg(REG_WAVESEQ1, 1); // default effect: strong click
writeReg(REG_WAVESEQ2, 0); // end of sequence
writeReg(REG_OVERDRIVE, 0);
writeReg(REG_SUSTAINPOS, 0);
writeReg(REG_SUSTAINNEG, 0);
writeReg(REG_BREAK, 0);
writeReg(REG_AUDIOMAX, 0x64);
writeReg(REG_FEEDBACK, readReg(REG_FEEDBACK) & 0x7F); // N_ERM_LRA = 0 -> ERM
writeReg(REG_CONTROL3, readReg(REG_CONTROL3) | 0x20); // ERM_OPEN_LOOP = 1
writeReg(REG_LIBRARY, 1); // ERM effect library 1
writeReg(REG_MODE, 0x00); // internal trigger
return true;
}
// Fire a single library effect (1 = strong click, 100%).
void buzz(uint8_t effect = 1) {
writeReg(REG_WAVESEQ1, effect);
writeReg(REG_WAVESEQ2, 0);
writeReg(REG_GO, 1);
}
// Return the DRV2605 to standby (~1.8uA). begin() must be run again before the
// next buzz -- a GO in standby is ignored -- so callers reset their ready flag.
void standby() {
writeReg(REG_MODE, 0x40); // STANDBY = 1, internal trigger
}
private:
static const uint8_t REG_MODE = 0x01;
static const uint8_t REG_RTPIN = 0x02;
static const uint8_t REG_LIBRARY = 0x03;
static const uint8_t REG_WAVESEQ1 = 0x04;
static const uint8_t REG_WAVESEQ2 = 0x05;
static const uint8_t REG_GO = 0x0C;
static const uint8_t REG_OVERDRIVE = 0x0D;
static const uint8_t REG_SUSTAINPOS = 0x0E;
static const uint8_t REG_SUSTAINNEG = 0x0F;
static const uint8_t REG_BREAK = 0x10;
static const uint8_t REG_AUDIOMAX = 0x13;
static const uint8_t REG_FEEDBACK = 0x1A;
static const uint8_t REG_CONTROL3 = 0x1D;
uint8_t _addr;
TwoWire* _wire;
void writeReg(uint8_t reg, uint8_t val) {
_wire->beginTransmission(_addr);
_wire->write(reg);
_wire->write(val);
_wire->endTransmission();
}
uint8_t readReg(uint8_t reg) {
_wire->beginTransmission(_addr);
_wire->write(reg);
_wire->endTransmission(false);
_wire->requestFrom(_addr, (uint8_t)1);
return _wire->available() ? _wire->read() : 0;
}
};
-69
View File
@@ -1,69 +0,0 @@
#pragma once
#include <Arduino.h>
#include <helpers/ui/MomentaryButton.h> // BUTTON_EVENT_* constants
#include "TWatchS3Board.h"
// PMUButton -- a MomentaryButton work-alike for the LilyGo T-Watch S3, whose
// only control is the side PWR key. That key is not a GPIO: schematic sheet 1
// wires SW7 to net PWR_KEY, which lands on AXP2101 pin 30 (PWRON). Presses are
// therefore reported as PMU interrupts and read back over I2C.
//
// It exposes the three methods UITask uses on `user_btn` (begin, isPressed,
// check), so it drops into target.h in place of MomentaryButton.
//
// Event mapping, set up in TWatchS3Board::power_init():
// press < 1s PKEY_SHORT_IRQ -> BUTTON_EVENT_CLICK
// 1s <= press < 6s PKEY_LONG_IRQ masked; no event
// press >= 6s hardware power-off never reaches firmware
//
// BUTTON_EVENT_LONG_PRESS, _DOUBLE_CLICK and _TRIPLE_CLICK are never returned:
// the long hold belongs to the PMU's power-off, and the AXP2101 reports no
// multi-click.
//
// The IRQ line (PIN_PMU_IRQ, GPIO21) is not used. check() polls the PMU's
// latched status registers directly, which removes any dependence on that pin
// being wired, pulled up, or serviced by an ISR. Three I2C register reads every
// POLL_INTERVAL_MS is negligible on a bus already shared with the RTC and
// accelerometer. Note that clearIrqStatus() is write-1-to-clear across all
// three status registers, so an edge landing inside the ~200us between the read
// and the clear is dropped; press and release are ~100ms apart, so this cannot
// swallow a real event.
class PMUButton {
TWatchS3Board& _board;
unsigned long _last_poll = 0;
bool _down = false;
static const unsigned long POLL_INTERVAL_MS = 30;
public:
PMUButton(TWatchS3Board& board) : _board(board) {}
// The PWRON key needs no pin setup; the PMU is configured in power_init(),
// which board.begin() runs before UITask::begin() reaches here.
void begin() {}
// True while the key is held down. Updated by check(), so it is at most
// POLL_INTERVAL_MS stale. UITask uses this to abort a pending shutdown.
bool isPressed() const { return _down; }
int check() {
if (millis() - _last_poll < POLL_INTERVAL_MS) return BUTTON_EVENT_NONE;
_last_poll = millis();
XPowersAXP2101* pmu = _board.getPMU();
if (pmu == NULL) return BUTTON_EVENT_NONE;
pmu->getIrqStatus(); // latch INTSTS1..3 into the driver's buffer
// Apply the edges before the click, so that a poll which catches press,
// release and short-press together still leaves _down false.
if (pmu->isPekeyNegativeIrq()) _down = true; // PWRON is active low: press
if (pmu->isPekeyPositiveIrq()) _down = false; // release
int ev = pmu->isPekeyShortPressIrq() ? BUTTON_EVENT_CLICK : BUTTON_EVENT_NONE;
pmu->clearIrqStatus();
return ev;
}
};
@@ -1,795 +0,0 @@
#pragma once
// =============================================================================
// TWatchComposeScreens — touch keyboard compose system for the LilyGo
// T-Watch S3 Plus (ST7789 240x240, capacitive touch, companion_radio ui-new).
//
// Three UIScreen subclasses, compiled only when TWATCH_COMPOSE_ENABLED is
// defined (companion build only). All drawing and hit-testing is in the
// LGFXDisplay virtual coordinate space, which is 120x120 on this build
// (240px panel / UI_ZOOM=2). getTouch() returns coords already divided by
// UI_ZOOM, so a tap coordinate is directly comparable to draw coordinates.
//
// TWatchChannelPicker -- long-press on the home screen opens this. A single
// tap selects the channel row under the finger; a
// long-press on a row opens it; the back arrow
// (top-left) returns home.
// TWatchChannelScreen -- shows the most recent messages (sent + received)
// for the selected channel, read live from the shared
// ChannelScreen history store. Tap a line to ticker-
// scroll it; tap the compose bar to open the keyboard;
// back arrow returns home.
// TWatchKeyboardScreen — on-screen QWERTY. Bottom-left mode key cycles
// lower -> UPPER -> SYM. Bottom row is mode | space |
// enter | backspace. Enter sends on the channel; back
// arrow returns home.
//
// Each screen reads getTouch() itself in poll() and does its own release-edge
// detection, so actions fire on touch release (finger up) — this keeps the
// held finger from carrying into the next screen after a transition. Send/exit
// are delegated to UITask via the consume/flag pattern; message history and
// unread counts are read from the shared ChannelScreen store.
// =============================================================================
#ifdef TWATCH_COMPOSE_ENABLED
#include <Arduino.h>
#include <string.h>
#include <helpers/ui/LGFXDisplay.h>
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include "ChannelScreen.h" // shared message history store (-I examples/companion_radio/ui-new)
// ---- Tunables ---------------------------------------------------------------
#define TW_LONG_PRESS_MS 600 // hold to select a channel in the picker
#define TW_OUT_BUF_LEN 134 // MeshCore per-channel msg cap (~133) + NUL
#define TW_TICKER_MS_PER_PX 20 // channel-screen ticker scroll speed (ms per pixel)
#define TW_CH_NAME_LEN 32
#define TW_PICKER_MAX 20 // matches MAX_GROUP_CHANNELS
// =============================================================================
// TWatchChannelPicker
// =============================================================================
class TWatchChannelPicker : public UIScreen {
DisplayDriver* _display;
struct Entry { uint8_t idx; char name[TW_CH_NAME_LEN]; };
Entry _channels[TW_PICKER_MAX];
uint8_t _numChannels;
uint8_t _highlighted;
uint8_t _scrollTop;
ChannelScreen* _store; // unread badge source
// touch edge state
bool _touchDown;
int _downX, _downY;
unsigned long _downAt;
// cross-screen flags (UITask polls these)
bool _confirmed;
bool _wantsExit;
static const int HEADER_H = 14;
static const int ROW_H = 14;
int visibleRows() const {
int h = _display ? _display->height() : 120;
int rows = (h - HEADER_H) / ROW_H;
return rows < 1 ? 1 : rows;
}
void ensureVisible() {
int vis = visibleRows();
if (_highlighted < _scrollTop) _scrollTop = _highlighted;
else if (_highlighted >= _scrollTop + vis) _scrollTop = (uint8_t)(_highlighted - vis + 1);
}
void moveUp() {
if (_numChannels == 0) return;
_highlighted = (_highlighted == 0) ? (uint8_t)(_numChannels - 1) : (uint8_t)(_highlighted - 1);
ensureVisible();
}
void moveDown() {
if (_numChannels == 0) return;
_highlighted = (uint8_t)((_highlighted + 1) % _numChannels);
ensureVisible();
}
// Map a draw-space y coordinate to a channel index, or -1 if the tap is
// above the list or on an empty row. getTouch() returns coords already
// divided by UI_ZOOM, so _downY is directly comparable to the row layout.
int rowAtY(int y) const {
if (y < HEADER_H) return -1;
int i = (y - HEADER_H) / ROW_H;
if (i < 0 || i >= visibleRows()) return -1;
int ci = _scrollTop + i;
if (ci >= _numChannels) return -1;
return ci;
}
public:
TWatchChannelPicker(DisplayDriver* display)
: _display(display), _numChannels(0), _highlighted(0), _scrollTop(0),
_store(nullptr),
_touchDown(false), _downX(0), _downY(0), _downAt(0),
_confirmed(false), _wantsExit(false) {
memset(_channels, 0, sizeof(_channels));
}
// Shared history store, used for the per-channel unread badges.
void setStore(ChannelScreen* store) { _store = store; }
// Called by UITask before showing the screen.
void beginChannelSelect() {
_numChannels = 0; _highlighted = 0; _scrollTop = 0;
_touchDown = false; _confirmed = false; _wantsExit = false;
}
void addChannel(uint8_t idx, const char* name) {
if (_numChannels >= TW_PICKER_MAX) return;
_channels[_numChannels].idx = idx;
strncpy(_channels[_numChannels].name, name ? name : "", TW_CH_NAME_LEN - 1);
_channels[_numChannels].name[TW_CH_NAME_LEN - 1] = 0;
_numChannels++;
}
// UITask bridges
bool isConfirmed() const { return _confirmed; }
void acknowledgeConfirm() { _confirmed = false; }
uint8_t getSelectedChannelIdx() const { return _channels[_highlighted].idx; }
const char* getSelectedChannelName() const { return _channels[_highlighted].name; }
bool wantsExit() const { return _wantsExit; }
void acknowledgeExit() { _wantsExit = false; }
bool handleInput(char c) override { return false; } // touch handled in poll()
void poll() override {
if (!_display) return;
int x, y;
bool now = ((LGFXDisplay*)_display)->getTouch(&x, &y);
if (now && !_touchDown) {
_touchDown = true; _downX = x; _downY = y; _downAt = millis();
} else if (!now && _touchDown) {
_touchDown = false;
unsigned long held = millis() - _downAt;
if (_downX < 20 && _downY < HEADER_H) { _wantsExit = true; return; } // back arrow
int row = rowAtY(_downY);
if (row < 0) return; // tap outside the channel list
_highlighted = (uint8_t)row; // single tap selects the tapped row
ensureVisible();
if (held >= TW_LONG_PRESS_MS) _confirmed = true; // long press opens it
}
}
int render(DisplayDriver& display) override {
const int W = display.width();
display.setTextSize(1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, 0);
display.print("<");
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(W / 2, 0, "CHANNELS");
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, HEADER_H - 2, W, 1);
if (_numChannels == 0) {
display.setCursor(2, HEADER_H + 2);
display.print("(no channels)");
return 500;
}
int vis = visibleRows();
int y = HEADER_H;
for (int i = 0; i < vis; i++) {
int ci = _scrollTop + i;
if (ci >= _numChannels) break;
if (ci == _highlighted) {
display.setColor(DisplayDriver::GREEN);
display.fillRect(0, y, W, ROW_H);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
int nameMaxW = W - 6;
int unread = _store ? _store->getUnreadForChannel(_channels[ci].idx) : 0;
if (unread > 0) {
char cnt[8];
snprintf(cnt, sizeof(cnt), "%d", unread > 99 ? 99 : unread);
int cw = display.getTextWidth(cnt);
display.setColor((ci == _highlighted) ? DisplayDriver::DARK : DisplayDriver::BLUE);
display.drawTextRightAlign(W - 3, y + 2, cnt);
display.setColor((ci == _highlighted) ? DisplayDriver::DARK : DisplayDriver::LIGHT);
nameMaxW = W - 6 - cw - 4;
}
display.drawTextEllipsized(3, y + 2, nameMaxW, _channels[ci].name);
y += ROW_H;
}
return 500;
}
};
// =============================================================================
// TWatchChannelScreen
// =============================================================================
class TWatchChannelScreen : public UIScreen {
DisplayDriver* _display;
ChannelScreen* _store; // shared message history store
uint8_t _channelIdx;
char _channelName[TW_CH_NAME_LEN];
char _selfPrefix[TW_CH_NAME_LEN + 2]; // "NodeName: " -- marks sent lines
bool _touchDown;
int _downX, _downY;
bool _wantsCompose;
bool _wantsExit;
int _selectedN; // recency index shown as ticker, -1 = none
unsigned long _tickerStartMs;
const void* _lastNewest; // newest store entry seen last render
uint32_t _lastNewestTs; // (a change dismisses the ticker)
bool _showPath; // hop-path overlay visible
int _pathMsgN; // recency index the overlay describes
static const int HEADER_H = 14;
static const int COMPOSE_BAR_H = 18;
static const int MSG_LINE_H = 11;
static const int MSG_TOP = HEADER_H + 2;
int rowsThatFit() const {
int h = _display ? _display->height() : 120;
int rows = (h - COMPOSE_BAR_H - 1 - MSG_TOP) / MSG_LINE_H;
return rows < 1 ? 1 : rows;
}
// Store messages available for this channel, capped at what fits on screen.
int visibleCount() const {
if (!_store) return 0;
int maxRows = rowsThatFit();
int n = 0;
while (n < maxRows && _store->getChannelMsgByRecency(_channelIdx, n)) n++;
return n;
}
// Sent messages are stored with path_len 0 and a "NodeName: " prefix.
bool isSent(const ChannelScreen::ChannelMessage* m) const {
return m->path_len == 0 && _selfPrefix[0] != 0 &&
strncmp(m->text, _selfPrefix, strlen(_selfPrefix)) == 0;
}
void drawMsgLine(DisplayDriver& display, int y, const char* text, bool sent) {
const int W = display.width();
const int maxW = W - 4;
display.setColor(DisplayDriver::LIGHT);
if (!sent) {
display.drawTextEllipsized(2, y, maxW, text); // incoming: left-aligned
return;
}
if (display.getTextWidth(text) <= maxW) { // sent: right-aligned
display.drawTextRightAlign(W - 2, y, text);
return;
}
char buf[CHANNEL_MSG_TEXT_LEN + 4];
strncpy(buf, text, sizeof(buf) - 4);
buf[sizeof(buf) - 4] = 0;
int ellW = display.getTextWidth("...");
int len = (int)strlen(buf);
while (len > 0 && display.getTextWidth(buf) > maxW - ellW) { buf[--len] = 0; }
strcat(buf, "...");
display.drawTextRightAlign(W - 2, y, buf);
}
void drawTicker(DisplayDriver& display, int y, const char* text, bool sent) {
const int W = display.width();
int textW = display.getTextWidth(text);
display.setColor(DisplayDriver::LIGHT);
if (textW <= W - 4) { // fits -> nothing to scroll, keep alignment
if (sent) display.drawTextRightAlign(W - 2, y, text);
else { display.setCursor(2, y); display.print(text); }
return;
}
int period = textW + 24; // full text width + trailing gap
unsigned long elapsed = millis() - _tickerStartMs;
int off = (int)((elapsed / TW_TICKER_MS_PER_PX) % (unsigned long)period);
// Wrap off while the marquee draws: a long line must clip at the screen
// edge, not wrap onto the rows below. Restored straight after.
((LGFXDisplay*)_display)->setTextWrap(false);
display.setCursor(2 - off, y);
display.print(text);
display.setCursor(2 - off + period, y);
display.print(text);
((LGFXDisplay*)_display)->setTextWrap(true);
}
// Renders the "Heard by" list (repeaters that echoed our sent message + the
// SNR we heard each at) into the already-drawn overlay box. Data comes from
// MyMesh::getHeardBy, keyed by the fingerprint stashed in the sent bubble's
// path[]. Same blue box as the hop-path overlay.
void drawHeardBy(LGFXDisplay* d, const ChannelScreen::ChannelMessage* m,
int bx, int by, int bh) {
uint8_t bph = 0, count = 0;
uint8_t hashes[16];
int8_t snrs[8];
(void)bh;
the_mesh.getHeardBy(m->path, 12, bph, count, hashes, snrs, 8);
char head[28];
snprintf(head, sizeof(head), "Heard by: %d", count); // distinct repeaters
d->setRawColor(0x7BEF);
d->printSmallFont(bx + 5, by + 8, head);
if (count == 0 || bph == 0) {
d->setRawColor(0xFFFF);
d->printSmallFont(bx + 5, by + 19, "No repeater echoes yet");
return;
}
const int maxLines = 5; // 5 repeaters, then a "+N more" 6th line
int shown = (count > maxLines) ? maxLines : count;
int y = by + 19;
for (int i = 0; i < shown; i++) {
const int off = i * bph;
char hex[6];
if (bph == 1) snprintf(hex, sizeof(hex), "%02x", hashes[off]);
else snprintf(hex, sizeof(hex), "%02x%02x", hashes[off], hashes[off + 1]);
char nm[16]; nm[0] = 0;
uint32_t numC = the_mesh.getNumContacts();
ContactInfo c;
for (int pass = 0; pass < 2 && nm[0] == 0; pass++) {
for (uint32_t ci = 0; ci < numC; ci++) {
if (!the_mesh.getContactByIdx(ci, c)) continue;
if (memcmp(c.id.pub_key, &hashes[off], bph) != 0) continue;
if (pass == 0 && c.type != ADV_TYPE_REPEATER) continue;
strncpy(nm, c.name, sizeof(nm) - 1); nm[sizeof(nm) - 1] = 0;
break;
}
}
int8_t s = snrs[i];
char line[40];
snprintf(line, sizeof(line), "(%s) %s %d.%ddB",
hex, nm[0] ? nm : "?", s / 4, ((abs((int)s) % 4) * 10) / 4);
d->setRawColor(0xFFFF);
d->printSmallFont(bx + 5, y, line);
y += 9;
}
if (count > shown) {
d->setRawColor(0xFFFF);
char more[16];
snprintf(more, sizeof(more), "+%d more", count - shown);
d->printSmallFont(bx + 5, y, more);
}
}
// Hop-path overlay for the message at recency _pathMsgN. Drawn last so it
// sits above the message list; toggled by the button (see handleInput),
// dismissed by any tap (see poll).
void drawPathOverlay(DisplayDriver& display) {
const ChannelScreen::ChannelMessage* m =
_store ? _store->getChannelMsgByRecency(_channelIdx, _pathMsgN) : nullptr;
if (!m) { _showPath = false; return; }
LGFXDisplay* d = (LGFXDisplay*)&display;
const int bx = 3, bw = display.width() - 6, by = 18, bh = 76;
d->setRawColor(0x0000);
d->fillRoundRect(bx, by, bw, bh, 4);
d->setRawColor(0x231D);
d->drawRoundRect(bx, by, bw, bh, 4);
// Sent message with a captured fingerprint: show which repeaters echoed it
// ("Heard by") instead of a hop path, which a sent bubble doesn't have.
if (m->path_len == 0 && m->has_fp) {
drawHeardBy(d, m, bx, by, bh);
return;
}
char head[28];
if (m->path_len == 0) {
snprintf(head, sizeof(head), "%s", isSent(m) ? "Sent (no path)" : "Direct (0 hops)");
} else if (m->path_len == 0xFF) {
snprintf(head, sizeof(head), "%s", "Flood (no path)");
} else {
snprintf(head, sizeof(head), "%d hops, %d-byte",
m->path_len & 63, (m->path_len >> 6) + 1);
}
d->setRawColor(0x7BEF);
d->printSmallFont(bx + 5, by + 8, head);
if (m->path_len == 0 || m->path_len == 0xFF) return;
int byteLen = (int)mesh::Packet::getPathByteLenFor(m->path_len);
if (byteLen > MSG_PATH_MAX) byteLen = MSG_PATH_MAX;
int bph = (m->path_len >> 6) + 1;
// Hex hops in the path-editor format ("3601,2198,..."), wrapped
// token-wise across small-font lines.
char line[36];
int ll = 0, y = by + 19;
d->setRawColor(0xFFFF);
for (int b = 0; b < byteLen; b += bph) {
char tok[9];
int tl = 0;
for (int k = 0; k < bph && b + k < byteLen; k++)
tl += snprintf(tok + tl, sizeof(tok) - tl, "%02x", m->path[b + k]);
if (b + bph < byteLen) { tok[tl++] = ','; tok[tl] = 0; }
if (ll + tl > 26) {
line[ll] = 0;
d->printSmallFont(bx + 5, y, line);
y += 9; ll = 0;
if (y > by + bh - 8) { d->printSmallFont(bx + 5, y, "..."); return; }
}
memcpy(line + ll, tok, tl); ll += tl;
}
if (ll > 0) { line[ll] = 0; d->printSmallFont(bx + 5, y, line); }
}
public:
TWatchChannelScreen(DisplayDriver* display)
: _display(display), _store(nullptr), _channelIdx(0),
_touchDown(false), _downX(0), _downY(0),
_wantsCompose(false), _wantsExit(false),
_selectedN(-1), _tickerStartMs(0),
_lastNewest(nullptr), _lastNewestTs(0),
_showPath(false), _pathMsgN(0) {
_channelName[0] = 0;
_selfPrefix[0] = 0;
}
// Shared history store this screen renders from.
void setStore(ChannelScreen* store) { _store = store; }
// Own node name, used to right-align sent lines ("NodeName: text").
void setSelfName(const char* name) {
if (!name || !name[0]) { _selfPrefix[0] = 0; return; }
snprintf(_selfPrefix, sizeof(_selfPrefix), "%s: ", name);
}
// Switch to a channel (called when a channel is selected in the picker).
// Opening a channel marks its messages as read.
void activate(uint8_t idx, const char* name) {
_channelIdx = idx;
strncpy(_channelName, name ? name : "", TW_CH_NAME_LEN - 1);
_channelName[TW_CH_NAME_LEN - 1] = 0;
_touchDown = false; _wantsCompose = false; _wantsExit = false;
_selectedN = -1;
_showPath = false;
_lastNewest = nullptr; _lastNewestTs = 0;
if (_store) _store->markChannelRead(_channelIdx);
}
uint8_t getChannelIdx() const { return _channelIdx; }
const char* getChannelName() const { return _channelName; }
bool wantsCompose() const { return _wantsCompose; }
void acknowledgeCompose() { _wantsCompose = false; }
bool wantsExit() const { return _wantsExit; }
void acknowledgeExit() { _wantsExit = false; }
// Short press of the PWR key (S3, KEY_ENTER) or the boot button (S3 Plus,
// KEY_NEXT): toggle the hop-path overlay for the ticker-selected message
// (the newest one if none is selected). Touch is handled in poll().
bool handleInput(char c) override {
if (c == KEY_ENTER || c == KEY_NEXT) {
if (_showPath) { _showPath = false; return true; }
int n = (_selectedN >= 0) ? _selectedN : 0;
if (_store && _store->getChannelMsgByRecency(_channelIdx, n)) {
_pathMsgN = n;
_showPath = true;
}
return true;
}
return false;
}
void poll() override {
if (!_display) return;
int x, y;
bool now = ((LGFXDisplay*)_display)->getTouch(&x, &y);
if (now && !_touchDown) {
_touchDown = true; _downX = x; _downY = y;
} else if (!now && _touchDown) {
_touchDown = false;
int H = _display->height();
if (_showPath) { _showPath = false; return; } // any tap dismisses the path overlay
if (_downX < 20 && _downY < HEADER_H) { _selectedN = -1; _wantsExit = true; return; } // back arrow
if (_downY >= H - COMPOSE_BAR_H) { _selectedN = -1; _wantsCompose = true; return; } // compose bar
// message area -> tap to open/close ticker
if (_downY >= MSG_TOP && _downY < H - COMPOSE_BAR_H) {
int visualRow = (_downY - MSG_TOP) / MSG_LINE_H;
int count = visibleCount();
if (visualRow >= 0 && visualRow < count) {
int n = (count - 1) - visualRow; // top row = oldest visible
if (_selectedN == n) _selectedN = -1;
else { _selectedN = n; _tickerStartMs = millis(); }
}
}
}
}
int render(DisplayDriver& display) override {
const int W = display.width();
const int H = display.height();
display.setTextSize(1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, 0);
display.print("<");
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(W / 2, 0, _channelName);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, HEADER_H - 2, W, 1);
// A new message (sent or received) shifts the rows, so dismiss any open
// ticker when the newest store entry for this channel changes.
const ChannelScreen::ChannelMessage* newest =
_store ? _store->getChannelMsgByRecency(_channelIdx, 0) : nullptr;
if (newest != _lastNewest || (newest && newest->timestamp != _lastNewestTs)) {
_lastNewest = newest;
_lastNewestTs = newest ? newest->timestamp : 0;
_selectedN = -1;
}
int count = visibleCount();
if (count == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(2, MSG_TOP);
display.print("(no messages)");
} else {
int y = MSG_TOP;
for (int n = count - 1; n >= 0; n--) { // oldest visible (top) -> newest (bottom)
const ChannelScreen::ChannelMessage* m = _store->getChannelMsgByRecency(_channelIdx, n);
if (!m) continue;
bool sent = isSent(m);
if (n == _selectedN) drawTicker(display, y, m->text, sent);
else drawMsgLine(display, y, m->text, sent);
y += MSG_LINE_H;
}
}
// compose bar
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, H - COMPOSE_BAR_H, W, COMPOSE_BAR_H - 1);
display.drawTextEllipsized(3, H - COMPOSE_BAR_H + 4, W - 6, "Tap to compose");
if (_showPath) drawPathOverlay(display);
return (_selectedN >= 0 || _showPath) ? 60 : 500;
}
};
// =============================================================================
// TWatchKeyboardScreen
// =============================================================================
class TWatchKeyboardScreen : public UIScreen {
DisplayDriver* _display;
uint8_t _channelIdx;
public:
enum Purpose { TWKB_CHANNEL, TWKB_DM, TWKB_ADMIN_PASSWORD, TWKB_ADMIN_CLI, TWKB_PATH, TWKB_NOTE, TWKB_SCOPE, TWKB_TRACE_PATH };
private:
Purpose _purpose;
int _contextIdx; // channel idx (channel) or contact idx (DM/admin)
bool _mask; // render composed text as '*' (admin password)
unsigned long _lastCharAt; // when the last char was typed (for reveal window)
static const unsigned long PW_REVEAL_MS = 2000; // show last char this long before masking
char _outBuf[TW_OUT_BUF_LEN];
uint16_t _outLen;
enum Mode { LOWER, UPPER, SYM };
Mode _mode;
bool _touchDown;
int _downX, _downY;
bool _wantsSend;
bool _wantsExit;
static const int MAXLEN = TW_OUT_BUF_LEN - 1; // 133
// layout geometry (120x120 virtual space)
static const int TOPBAR_H = 15;
static const int KEY_W = 12; // 10 * 12 = 120 wide
static const int KEY_H = 26;
static const int GRID_Y = 15; // row0 top; rows at GRID_Y + row*KEY_H
const char* const* layout() const {
static const char* const lower[3] = { "qwertyuiop", "asdfghjkl'", "zxcvbnm,.?" };
static const char* const upper[3] = { "QWERTYUIOP", "ASDFGHJKL\"", "ZXCVBNM<>/" };
static const char* const syms[3] = { "123!@#$%^&", "456()[]{}-", "7890+=_:;|" };
return _mode == SYM ? syms : (_mode == UPPER ? upper : lower);
}
const char* modeLabel() const {
return _mode == SYM ? "SYM" : (_mode == UPPER ? "A-Z" : "a-z");
}
void appendChar(char ch) {
if (_outLen < MAXLEN) { _outBuf[_outLen++] = ch; _outBuf[_outLen] = 0; _lastCharAt = millis(); }
}
void backspace() {
if (_outLen > 0) { _outLen--; _outBuf[_outLen] = 0; _lastCharAt = 0; }
}
void cycleMode() {
_mode = (_mode == LOWER) ? UPPER : (_mode == UPPER ? SYM : LOWER);
}
// bottom row zones: mode 0..24 | space 24..72 | enter 72..96 | backspace 96..120
void handleBottomRow(int x) {
if (x < 24) cycleMode();
else if (x < 72) appendChar(' ');
else if (x < 96) { if (_outLen > 0) _wantsSend = true; }
else backspace();
}
public:
TWatchKeyboardScreen(DisplayDriver* display)
: _display(display), _channelIdx(0),
_purpose(TWKB_CHANNEL), _contextIdx(0), _mask(false), _lastCharAt(0),
_outLen(0), _mode(LOWER),
_touchDown(false), _downX(0), _downY(0),
_wantsSend(false), _wantsExit(false) {
_outBuf[0] = 0;
}
// Enter compose on the given channel (called when the compose bar is tapped).
void activate(uint8_t idx, const char* name) {
(void)name;
_channelIdx = idx;
_purpose = TWKB_CHANNEL;
_contextIdx = idx;
_mask = false;
_outLen = 0; _outBuf[0] = 0;
_mode = LOWER;
_touchDown = false; _wantsSend = false; _wantsExit = false;
}
// Enter text for a non-channel purpose (DM / admin password / admin CLI).
void activateFor(Purpose purpose, int contextIdx) {
_purpose = purpose;
_contextIdx = contextIdx;
_channelIdx = 0;
_mask = (purpose == TWKB_ADMIN_PASSWORD);
_outLen = 0; _outBuf[0] = 0;
_mode = LOWER;
_touchDown = false; _wantsSend = false; _wantsExit = false;
}
Purpose getPurpose() const { return _purpose; }
int getContextIdx() const { return _contextIdx; }
uint8_t getChannelIdx() const { return _channelIdx; }
bool consumeSendRequest(const char** textOut) {
if (!_wantsSend) return false;
_wantsSend = false;
if (textOut) *textOut = _outBuf;
return true;
}
void clearOutBuf() { _outLen = 0; _outBuf[0] = 0; }
// Pre-fill the compose buffer (e.g. editing an existing note). Call after
// activateFor(), which clears the buffer; input is clamped at MAXLEN.
void setInitialText(const char* s) {
_outLen = 0;
if (s) {
while (s[_outLen] && _outLen < MAXLEN) { _outBuf[_outLen] = s[_outLen]; _outLen++; }
}
_outBuf[_outLen] = 0;
}
bool wantsExit() const { return _wantsExit; }
void acknowledgeExit() { _wantsExit = false; }
bool handleInput(char c) override { return false; }
void poll() override {
if (!_display) return;
int x, y;
bool now = ((LGFXDisplay*)_display)->getTouch(&x, &y);
if (now && !_touchDown) {
_touchDown = true; _downX = x; _downY = y;
} else if (!now && _touchDown) {
_touchDown = false;
int x0 = _downX, y0 = _downY;
if (x0 < 20 && y0 < TOPBAR_H) { _wantsExit = true; return; } // back arrow
if (y0 < GRID_Y) return; // top bar (text) area
int row = (y0 - GRID_Y) / KEY_H;
if (row <= 2) {
int col = x0 / KEY_W;
char ch = layout()[row][col];
if (ch) appendChar(ch);
} else {
handleBottomRow(x0); // bottom row
}
}
}
int render(DisplayDriver& display) override {
const int W = display.width();
display.setTextSize(1);
// ---- top bar: back arrow + composed text tail + cursor ----
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, 0);
display.print("<");
display.setColor(DisplayDriver::LIGHT);
{
// Mask the composed text with '*' for the admin password purpose.
char masked[TW_OUT_BUF_LEN];
const char* src = _outBuf;
if (_mask) {
int m = (_outLen < TW_OUT_BUF_LEN - 1) ? _outLen : TW_OUT_BUF_LEN - 1;
for (int i = 0; i < m; i++) masked[i] = '*';
// Reveal the most recently typed char briefly so it can be read.
if (m > 0 && (millis() - _lastCharAt < PW_REVEAL_MS)) masked[m - 1] = _outBuf[m - 1];
masked[m] = 0;
src = masked;
}
const int avail = W - 16 - 4; // room after the back arrow, minus cursor
int len = _outLen;
int fit = 0;
for (int n = 1; n <= len; n++) {
if (display.getTextWidth(src + (len - n)) > avail) break;
fit = n;
}
char tail[TW_OUT_BUF_LEN + 2];
strncpy(tail, src + (len - fit), sizeof(tail) - 2);
tail[sizeof(tail) - 2] = 0;
size_t tl = strlen(tail);
tail[tl] = '_'; tail[tl + 1] = 0;
display.setCursor(16, 2);
display.print(tail);
}
// ---- key grid rows 0..2 ----
const char* const* lay = layout();
for (int row = 0; row < 3; row++) {
int ry = GRID_Y + row * KEY_H;
const char* r = lay[row];
for (int col = 0; col < 10; col++) {
int kx = col * KEY_W;
display.setColor(DisplayDriver::GREEN);
display.drawRect(kx, ry, KEY_W, KEY_H);
char ch[2] = { r[col], 0 };
display.setColor(DisplayDriver::LIGHT);
int tw = display.getTextWidth(ch);
display.setCursor(kx + (KEY_W - tw) / 2, ry + (KEY_H - 8) / 2);
display.print(ch);
}
}
// ---- bottom row: mode | space | enter | backspace ----
int by = GRID_Y + 3 * KEY_H;
display.setColor(DisplayDriver::GREEN);
display.fillRect(0, by, 24, KEY_H);
display.setColor(DisplayDriver::DARK);
{
const char* ml = modeLabel();
int tw = display.getTextWidth(ml);
display.setCursor((24 - tw) / 2, by + (KEY_H - 8) / 2);
display.print(ml);
}
display.setColor(DisplayDriver::LIGHT);
display.drawRect(24, by, 48, KEY_H); // space
display.setColor(DisplayDriver::GREEN);
display.fillRect(72, by, 24, KEY_H);
display.setColor(DisplayDriver::DARK);
{
const char* el = "OK";
int tw = display.getTextWidth(el);
display.setCursor(72 + (24 - tw) / 2, by + (KEY_H - 8) / 2);
display.print(el);
}
display.setColor(DisplayDriver::ORANGE);
display.fillRect(96, by, 24, KEY_H);
display.setColor(DisplayDriver::DARK);
{
const char* bl = "DEL";
int tw = display.getTextWidth(bl);
display.setCursor(96 + (24 - tw) / 2, by + (KEY_H - 8) / 2);
display.print(bl);
}
return 500;
}
};
#endif // TWATCH_COMPOSE_ENABLED
-243
View File
@@ -1,243 +0,0 @@
#include <Arduino.h>
#include "TWatchS3Board.h"
#include <SensorBMA423.hpp>
#include <esp_bt.h> // power-debug: esp_bt_controller_get_status()
volatile bool TWatchS3Board::_tilt_flag = false;
volatile uint32_t TWatchS3Board::_tilt_isr_count = 0; // TEMP diagnostic
// memw forces the _tilt_flag store to retire to SRAM before this ISR
// returns. Without it, tiltFired() in the main loop can read a stale
// value and raise-to-wake is missed. Do not remove.
void IRAM_ATTR TWatchS3Board::onTiltISR() { _tilt_flag = true; _tilt_isr_count++; asm volatile("memw" ::: "memory"); }
// ---- Wrapper-free BMA423 step counter (raw I2C) ----------------------------
// SensorLib's SensorBMA423 step-counter methods do not compile in this build,
// so the step counter is driven directly over I2C. Register/offset/mask values
// are from the Bosch BMA423 driver.
#define BMA423_REG_STEP_CNT_OUT 0x1E // 4-byte little-endian step count output
#define BMA423_REG_FEATURE_CONFIG 0x5E // 64-byte feature config stream
#define BMA423_FEATURE_LEN 64
#define BMA423_STEP_EN_BYTE 0x37 // BMA423_STEP_CNTR_OFFSET(0x36) + 1
#define BMA423_STEP_EN_BIT 0x10 // BMA423_STEP_CNTR_EN_MSK
// Step counter watermark: a 10-bit field (BMA423_STEP_CNTR_WM_MSK = 0x03FF)
// spanning feature_config[0x36] as LSB and the low two bits of [0x37] as MSB.
// It does not collide with the enable bit, which is bit 4 of [0x37] (bit 12 of
// the 16-bit word). Bosch's bma423_step_counter_set_watermark() writes exactly
// these bits; SensorLib's enableStepCounter() calls it with 1, and LilyGo's own
// firmware calls setStepCounterWatermark(1). Meck never set it, which is the
// prime suspect for the undercounting.
#define BMA423_STEP_WM_LSB_BYTE 0x36 // BMA423_STEP_CNTR_OFFSET
#define BMA423_STEP_WM_MSK 0x03FF // BMA423_STEP_CNTR_WM_MSK
#define BMA423_STEP_WM_LEVEL 1 // matches LilyGoLib and the SensorLib example
#define BMA423_REG_POWER_CONF 0x7C // BMA4_POWER_CONF_ADDR
#define BMA423_ADV_PWR_SAVE_BIT 0x01 // BMA4_ADVANCE_POWER_SAVE_MSK
static bool bma423ReadRegs(uint8_t reg, uint8_t* buf, uint8_t len) {
Wire.beginTransmission(I2C_ADDR_ACCEL);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom((int)I2C_ADDR_ACCEL, (int)len) != len) return false;
for (uint8_t i = 0; i < len; i++) buf[i] = Wire.read();
return true;
}
static bool bma423WriteRegs(uint8_t reg, const uint8_t* buf, uint8_t len) {
Wire.beginTransmission(I2C_ADDR_ACCEL);
Wire.write(reg);
for (uint8_t i = 0; i < len; i++) Wire.write(buf[i]);
return Wire.endTransmission() == 0;
}
// Enable the step counter by setting its enable bit in the feature config,
// preserving every other byte (tilt lives at a different offset, 0x3A, so it is
// untouched). The feature config can only be written with advanced-power-save
// disabled, so we bracket the write and restore the prior power state after.
static void bma423EnableStepCounter() {
uint8_t pc;
if (!bma423ReadRegs(BMA423_REG_POWER_CONF, &pc, 1)) return; // save power state
uint8_t off = pc & ~BMA423_ADV_PWR_SAVE_BIT; // disable adv power save
bma423WriteRegs(BMA423_REG_POWER_CONF, &off, 1);
delay(2); // wake from low-power (>=450us)
uint8_t cfg[BMA423_FEATURE_LEN];
if (bma423ReadRegs(BMA423_REG_FEATURE_CONFIG, cfg, BMA423_FEATURE_LEN)) {
// Watermark first, then the enable bit, in a single read-modify-write.
uint16_t wm = ((uint16_t)cfg[BMA423_STEP_EN_BYTE] << 8) | cfg[BMA423_STEP_WM_LSB_BYTE];
wm = (wm & ~BMA423_STEP_WM_MSK) | (BMA423_STEP_WM_LEVEL & BMA423_STEP_WM_MSK);
cfg[BMA423_STEP_WM_LSB_BYTE] = (uint8_t)(wm & 0xFF);
cfg[BMA423_STEP_EN_BYTE] = (uint8_t)((wm >> 8) & 0xFF);
cfg[BMA423_STEP_EN_BYTE] |= BMA423_STEP_EN_BIT;
bma423WriteRegs(BMA423_REG_FEATURE_CONFIG, cfg, BMA423_FEATURE_LEN);
delay(1); // write settle
}
bma423WriteRegs(BMA423_REG_POWER_CONF, &pc, 1); // restore power state
}
void TWatchS3Board::begin() {
ESP32Board::begin();
power_init();
// BMA423 accelerometer (always-on I2C, 0x19): enable the tilt / wrist-raise
// feature and its interrupt (routed to PIN1 -> GPIO14) for raise-to-wake.
_accel = new SensorBMA423();
if (_accel->begin(Wire, I2C_ADDR_ACCEL, PIN_BOARD_SDA, PIN_BOARD_SCL)) {
_accel->setRemapAxes(SensorRemap::BOTTOM_LAYER_TOP_RIGHT_CORNER);
// 100 Hz ODR: SensorLib's BMA423_StepDetector example runs the pedometer at
// 100 Hz, not the 50 Hz used here previously. Everything else already matched
// (NORMAL, FS_2G, OSR2_AVG2, CIC_AVG_MODE). Revert this one literal to 50.0f
// to A/B it against the watermark change above.
_accel->configAccelerometer(OperationMode::NORMAL, AccelFullScaleRange::FS_2G,
100.0f, AccelBandwidth::OSR2_AVG2, AccelPerfMode::CIC_AVG_MODE);
// INT1 pin electrical config: level trigger, active high, push-pull,
// output enabled. INT1_IO_CTRL resets to output-disabled, so without
// this the pin never drives and INPUT_PULLDOWN reads low forever.
_accel->setInterruptPinConfig(InterruptPinMap::PIN1, false, false, true, false);
pinMode(PIN_ACCEL_IRQ, INPUT_PULLDOWN);
// Attach the edge ISR BEFORE enabling the tilt source, so the first
// assertion cannot occur before the handler is armed (a missed first edge
// on a self-clearing line otherwise locks tilt-wake out permanently).
attachInterrupt(digitalPinToInterrupt(PIN_ACCEL_IRQ), onTiltISR, RISING);
_accel->enableTiltDetector(true, true);
// Enable the hardware step counter via raw I2C (SensorLib's wrapper method
// does not compile in this build). It then counts in the BMA423 feature
// engine with no CPU cost, even while the display is off.
bma423EnableStepCounter();
}
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() {
_axp = new XPowersAXP2101(Wire, PIN_BOARD_SDA, PIN_BOARD_SCL, I2C_ADDR_PMU);
PMU = _axp; // same object; see the note in TWatchS3Board.h
if (!PMU->init()) {
MESH_DEBUG_PRINTLN("Warning: Failed to find AXP2101 power management");
delete _axp;
_axp = NULL;
PMU = NULL;
return false;
}
PMU->setChargingLedMode(XPOWERS_CHG_LED_CTRL_CHG);
// Power rails per the T-Watch S3 PowerManage table, cross-checked against the
// schematic (rev 25-03-24):
// ALDO1 = unused, ALDO2 = display backlight,
// ALDO3 = display + touch, ALDO4 = LoRa (schematic net LDO4 -> HPD16B3 VCC),
// BLDO1 = unused (no GNSS), BLDO2 = DRV2605 haptic,
// DLDO1 = MAX98357A speaker amp VDD (schematic sheet 6, net SPK_VDD),
// VBACKUP = MS412FE rechargeable coin cell backing the PCF8563 RTC domain.
//
// LilyGo's hardware doc lists DLDO1 as unused. The schematic disagrees: it is
// the speaker rail. Meck compiles no audio, so it stays off, which fully
// unpowers the amp rather than merely idling it.
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);
PMU->disablePowerOutput(XPOWERS_DCDC2);
PMU->disablePowerOutput(XPOWERS_DCDC3);
PMU->disablePowerOutput(XPOWERS_DCDC4);
PMU->disablePowerOutput(XPOWERS_DCDC5);
PMU->disablePowerOutput(XPOWERS_ALDO1); // unused
PMU->disablePowerOutput(XPOWERS_BLDO1); // GNSS rail on the Plus; unpopulated here
PMU->disablePowerOutput(XPOWERS_DLDO1); // MAX98357A speaker amp -- audio not compiled in
PMU->disablePowerOutput(XPOWERS_DLDO2);
// RTC backup cell. The PCF8563 has a single VDD pin (no separate battery
// input), and the schematic diode-ORs it against the MS412FE on J12, which is
// charged from the AXP2101 BACKUP pin. Leaving this off drains the cell with
// nothing to replenish it. 3300 mV matches LilyGo's own firmware.
// setPowerChannelVoltage/enablePowerOutput on XPOWERS_VBACKUP map onto
// setButtonBatteryChargeVoltage()/enableButtonBatteryCharge().
PMU->setPowerChannelVoltage(XPOWERS_VBACKUP, 3300);
PMU->enablePowerOutput(XPOWERS_VBACKUP);
// PWR key. The side switch (schematic SW7) is wired to PWRON, not a GPIO.
// press < 1s -> PKEY_SHORT_IRQ, consumed by PMUButton as a click
// 1s <= press < 6s -> nothing (PKEY_LONG_IRQ is left masked)
// press >= 6s -> hardware power-off, firmware never sees it
// hold 2s from off -> power-on
// Matches the 2S ON / 6S OFF behaviour printed on LilyGo's own pin diagram.
PMU->setPowerKeyPressOnTime(XPOWERS_POWERON_2S);
PMU->setPowerKeyPressOffTime(XPOWERS_POWEROFF_6S);
_axp->setIrqLevelTime(XPOWERS_AXP2101_IRQ_TIME_1S); // not on XPowersLibInterface
PMU->disableIRQ(XPOWERS_AXP2101_ALL_IRQ);
PMU->clearIrqStatus();
// SHORT gives the click; NEGATIVE/POSITIVE are the press/release edges that
// back PMUButton::isPressed().
PMU->enableIRQ(XPOWERS_AXP2101_PKEY_SHORT_IRQ |
XPOWERS_AXP2101_PKEY_NEGATIVE_IRQ |
XPOWERS_AXP2101_PKEY_POSITIVE_IRQ);
// 150 mA = 0.32C on the 470 mAh cell -- a modest bump over the 125 mA
// vendor default (LilyGoWatchS3.cpp uses 125), well under the 0.5C rule.
PMU->setChargerConstantCurr(XPOWERS_AXP2101_CHG_CUR_150MA);
// The fitted cell (Tewaycell 552530, label verified) is 3.8V nominal LiHV,
// i.e. 4.35V max charge. LilyGoLib's init for this board sets 4V35 for the
// same reason. Charging to 4V2 forfeits the top ~12-15% of capacity.
PMU->setChargeTargetVoltage(XPOWERS_AXP2101_CHG_VOL_4V35);
PMU->disableTSPinMeasure();
PMU->enableSystemVoltageMeasure();
PMU->enableVbusVoltageMeasure();
PMU->enableBattVoltageMeasure();
Serial.printf("[PWR] rails: ALDO2(bl)=%d ALDO3(disp/touch)=%d ALDO4(LoRa)=%d BLDO2(haptic)=%d DLDO1(spk)=%d VBACKUP(rtc)=%d\n",
PMU->isPowerChannelEnable(XPOWERS_ALDO2),
PMU->isPowerChannelEnable(XPOWERS_ALDO3),
PMU->isPowerChannelEnable(XPOWERS_ALDO4),
PMU->isPowerChannelEnable(XPOWERS_BLDO2),
PMU->isPowerChannelEnable(XPOWERS_DLDO1),
PMU->isPowerChannelEnable(XPOWERS_VBACKUP));
return true;
}
void TWatchS3Board::printPowerDebug() {
if (!PMU) return;
Serial.printf("[PWR] batt=%dmV %d%% vbus=%dmV charging=%d cpu=%dMHz bt=%d\n",
PMU->getBattVoltage(), PMU->getBatteryPercent(),
PMU->getVbusVoltage(), PMU->isCharging(),
getCpuFrequencyMhz(), (int)esp_bt_controller_get_status());
}
bool TWatchS3Board::tiltFired() {
if (_tilt_flag) { // set by the GPIO14 rising-edge ISR
_tilt_flag = false;
_accel->update(); // reading the status clears the sensor INT
return true;
}
// TEMP diagnostic: periodically report the raw INT pin level and ISR count,
// to distinguish "pin stuck high / never re-arms" from "never asserts".
static unsigned long _tilt_dbg_next = 0;
if (millis() >= _tilt_dbg_next) {
_tilt_dbg_next = millis() + 5000;
Serial.printf("[TILT] pin=%d isr_count=%u\n",
digitalRead(PIN_ACCEL_IRQ), (unsigned)_tilt_isr_count);
}
return false;
}
uint32_t TWatchS3Board::getStepCount() {
uint8_t d[4];
if (!bma423ReadRegs(BMA423_REG_STEP_CNT_OUT, d, 4)) return 0;
return (uint32_t)d[0] | ((uint32_t)d[1] << 8) |
((uint32_t)d[2] << 16) | ((uint32_t)d[3] << 24);
}
-84
View File
@@ -1,84 +0,0 @@
#pragma once
#include "variant.h" // Board-specific pin definitions (I2C, addresses, IRQs)
#include <Wire.h>
#include <Arduino.h>
#include "XPowersLib.h"
#include "helpers/ESP32Board.h"
#include <driver/rtc_io.h>
// LilyGo T-Watch S3 board (non-GPS, 470 mAh).
//
// Power is managed by an AXP2101 PMU on the main I2C bus. Two pointers to the
// same object are kept, because XPowersLib splits its API by access specifier:
// PMU (XPowersLibInterface*) -- the power-channel ops (setPowerChannelVoltage,
// enable/disablePowerOutput, isPowerChannelEnable) are PROTECTED on
// XPowersAXP2101 and only reachable through the interface.
// _axp (XPowersAXP2101*) -- setIrqLevelTime() and the PWRON press/release
// edges isPekeyNegativeIrq()/isPekeyPositiveIrq() are AXP2101-only and
// absent from the interface.
// Everything else is public on both.
class SensorBMA423; // full include kept in the .cpp to avoid a BLE-build header clash
class TWatchS3Board : public ESP32Board {
XPowersLibInterface* PMU = NULL;
XPowersAXP2101* _axp = NULL; // same object as PMU, concrete type
SensorBMA423* _accel = nullptr;
static volatile bool _tilt_flag;
static volatile uint32_t _tilt_isr_count; // TEMP diagnostic
static void IRAM_ATTR onTiltISR(); // defined in the .cpp (IRAM relocation)
bool power_init();
public:
void begin();
// Returns true once when the BMA423 tilt (wrist-raise) interrupt has fired.
bool tiltFired();
// The AXP2101 handle, for PMUButton. NULL if the PMU failed to init.
XPowersAXP2101* getPMU() { return _axp; }
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);
// NOTE: the PWR key is not a GPIO on this board, so it cannot be added to
// the ext1 mask. pin_wake_btn is accepted for signature compatibility with
// ESP32Board but callers on this variant pass -1.
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;
}
uint8_t getBatteryPercent() override {
return PMU ? PMU->getBatteryPercent() : 0;
}
// Wrapper-free BMA423 step count (raw I2C; defined in the .cpp).
uint32_t getStepCount();
// Power-debug probe: battery, VBUS, CPU clock, BT controller state and live
// rail states. Called periodically from UITask::loop.
void printPowerDebug();
const char* getManufacturerName() const override {
return "LilyGo T-Watch S3";
}
};
@@ -1,96 +0,0 @@
#pragma once
// LovyanGFX display + touch for the LilyGo T-Watch S3.
// ST7789V 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 <helpers/ui/LGFXDisplay.h>
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 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 TWatchS3Display : public LGFXDisplay {
LGFX_TWatchS3 disp;
public:
TWatchS3Display() : LGFXDisplay(240, 240, disp) {}
};
@@ -1,430 +0,0 @@
#pragma once
// =============================================================================
// WatchAlarmScreen -- vibrate-only alarm clock for the LilyGo T-Watch S3.
//
// The T-Deck Pro's AlarmScreen.h is bound to ESP32-audioI2S, an SD card and a
// 1-21 volume scale, none of which exist here. This is a separate, much smaller
// screen that keeps AlarmScreen's conventions (bit 0 = Sunday day-of-week mask,
// dowFromEpoch, the 3-buzz / 4s-pause vibrate cadence, effect 14) so the two
// stay recognisably related.
//
// Alarm is the DRV2605L on I2C 0x5A driving the 0827 coin motor. Its BLDO2 rail
// is already up from power_init(), so no motorEnable() call is needed.
//
// A ringing alarm is dismissed ONLY by the PWR key (which arrives as KEY_ENTER),
// or by the WATCH_ALARM_RINGING_MS timeout. Taps are swallowed, and UITask::loop
// holds this screen current and the display awake for the duration -- otherwise
// the buzzing trips the BMA423 tilt detector and raise-to-wake steals the screen.
//
// Config lives on the LittleFS partition mounted at /maps in main.cpp -- the
// same one the Plus uses for map tiles. The watch has no SD card.
//
// Geometry: the display is 240x240 physical, UI_ZOOM=2, so display.width() and
// display.height() both report 120 logical pixels.
// =============================================================================
// NOTE on include order: this header uses NodePrefs, which lives at
// examples/companion_radio/NodePrefs.h and is not on this variant's -I path.
// UITask.h pulls it in via "../NodePrefs.h", so WatchAlarmScreen.h must always
// be included *after* UITask.h. Both call sites (UITask.cpp, main.cpp) do.
// This mirrors how TWatchComposeScreens.h depends on ChannelScreen.h.
#include <Arduino.h>
#include <string.h>
#include <LittleFS.h>
#include <MeshCore.h> // mesh::RTCClock
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/LGFXDisplay.h>
#include "DRV2605Haptic.h"
class UITask;
#define WATCH_ALARM_SLOT_COUNT 4
#define WATCH_ALARM_CFG_PATH "/alarms.cfg"
#define WATCH_ALARM_CFG_MAGIC 0x334B414CUL // "LAK3"
#define WATCH_ALARM_CFG_VERSION 1
// Day-of-week bitmask, bit 0 = Sunday (matches AlarmScreen.h)
#define WDOW_SUN (1 << 0)
#define WDOW_MON (1 << 1)
#define WDOW_TUE (1 << 2)
#define WDOW_WED (1 << 3)
#define WDOW_THU (1 << 4)
#define WDOW_FRI (1 << 5)
#define WDOW_SAT (1 << 6)
#define WDOW_ALL 0x7F
#define WDOW_WEEKDAYS (WDOW_MON | WDOW_TUE | WDOW_WED | WDOW_THU | WDOW_FRI)
#define WDOW_WEEKEND (WDOW_SAT | WDOW_SUN)
// Vibrate cadence, identical to AlarmScreen's silent alarm on the T-Deck MAX.
#define WATCH_ALARM_EFFECT 14 // library 1: strong buzz, ~1s
#define WATCH_ALARM_BUZZ_MS 1200 // spacing between buzz starts
#define WATCH_ALARM_GROUP 3 // buzzes per group
#define WATCH_ALARM_PAUSE_MS 4000 // pause after each group
#define WATCH_ALARM_RINGING_MS 300000 // 5 minute auto-dismiss
#define WATCH_ALARM_FIRE_COOLDOWN 90 // seconds; do not re-fire the same slot
struct WatchAlarmSlot {
bool enabled;
uint8_t hour; // 0-23
uint8_t minute; // 0-59
uint8_t days; // day-of-week bitmask; WDOW_ALL = every day
};
struct WatchAlarmConfig {
uint32_t magic;
uint8_t version;
uint8_t _pad[3];
WatchAlarmSlot slots[WATCH_ALARM_SLOT_COUNT];
};
class WatchAlarmScreen : public UIScreen {
public:
enum Mode { LIST, EDIT, RINGING };
private:
UITask* _task;
mesh::RTCClock* _rtc;
NodePrefs* _node_prefs;
WatchAlarmConfig _cfg;
Mode _mode = LIST;
int _sel = 0; // selected slot in LIST / slot under EDIT
WatchAlarmSlot _edit; // working copy while editing
// Ringing state
int _ringSlot = -1;
unsigned long _ringStartMs = 0;
unsigned long _vibNextMs = 0;
int _vibCount = 0;
// Fire tracking
uint32_t _lastFireEpoch[WATCH_ALARM_SLOT_COUNT] = {0};
DRV2605Haptic _haptic;
bool _hapticReady = false;
static int dowFromEpoch(uint32_t epoch) {
// 1970-01-01 was a Thursday (index 4 with 0 = Sunday)
return (int)((epoch / 86400 + 4) % 7);
}
int32_t localNow() const {
uint32_t utc = _rtc->getCurrentTime();
if (utc < 1700000000) return -1; // clock not set yet
return (int32_t)utc + ((int32_t)_node_prefs->utc_offset_hours * 3600);
}
static const char* daysLabel(uint8_t d) {
if (d == WDOW_ALL) return "Daily";
if (d == WDOW_WEEKDAYS) return "Mon-Fri";
if (d == WDOW_WEEKEND) return "Sat/Sun";
if (d == 0) return "Once";
return "Custom";
}
static uint8_t nextDaysPreset(uint8_t d) {
if (d == WDOW_ALL) return WDOW_WEEKDAYS;
if (d == WDOW_WEEKDAYS) return WDOW_WEEKEND;
if (d == WDOW_WEEKEND) return 0; // Once
return WDOW_ALL;
}
void loadDefaults() {
memset(&_cfg, 0, sizeof(_cfg));
_cfg.magic = WATCH_ALARM_CFG_MAGIC;
_cfg.version = WATCH_ALARM_CFG_VERSION;
for (int i = 0; i < WATCH_ALARM_SLOT_COUNT; i++) {
_cfg.slots[i].enabled = false;
_cfg.slots[i].hour = 7;
_cfg.slots[i].minute = 0;
_cfg.slots[i].days = WDOW_ALL;
}
}
public:
WatchAlarmScreen(UITask* task, mesh::RTCClock* rtc, NodePrefs* node_prefs)
: _task(task), _rtc(rtc), _node_prefs(node_prefs) {
loadDefaults();
}
// Read the config from LittleFS. Called once from UITask::begin() after the
// /maps partition is mounted. A missing or malformed file leaves the defaults.
void load() {
File f = LittleFS.open(WATCH_ALARM_CFG_PATH, "r");
if (!f) return;
WatchAlarmConfig tmp;
size_t n = f.read((uint8_t*)&tmp, sizeof(tmp));
f.close();
if (n != sizeof(tmp)) return;
if (tmp.magic != WATCH_ALARM_CFG_MAGIC) return;
if (tmp.version != WATCH_ALARM_CFG_VERSION) return;
for (int i = 0; i < WATCH_ALARM_SLOT_COUNT; i++) {
if (tmp.slots[i].hour > 23 || tmp.slots[i].minute > 59) return; // reject junk
}
_cfg = tmp;
}
void save() {
File f = LittleFS.open(WATCH_ALARM_CFG_PATH, "w");
if (!f) {
Serial.println("WatchAlarmScreen: save FAILED (LittleFS)");
return;
}
f.write((const uint8_t*)&_cfg, sizeof(_cfg));
f.close();
}
Mode getMode() const { return _mode; }
bool isRinging() const { return _mode == RINGING; }
void enter() { _mode = LIST; }
// ---------------------------------------------------------------------------
// Firing. Driven from UITask::loop() so an alarm still fires with the display
// off or another screen showing. Returns true on the loop where it fires.
// ---------------------------------------------------------------------------
bool tick() {
if (_mode == RINGING) {
driveVibrate();
if (millis() - _ringStartMs > WATCH_ALARM_RINGING_MS) dismiss();
return false;
}
int32_t local = localNow();
if (local < 0) return false;
int hrs = (local / 3600) % 24; if (hrs < 0) hrs += 24;
int mins = (local / 60) % 60; if (mins < 0) mins += 60;
int dow = dowFromEpoch((uint32_t)local);
uint32_t nowEpoch = (uint32_t)local;
for (int i = 0; i < WATCH_ALARM_SLOT_COUNT; i++) {
const WatchAlarmSlot& s = _cfg.slots[i];
if (!s.enabled) continue;
if (s.hour != hrs || s.minute != mins) continue;
if (s.days != 0 && !(s.days & (1 << dow))) continue; // days==0 means Once
if (nowEpoch - _lastFireEpoch[i] < WATCH_ALARM_FIRE_COOLDOWN) continue;
_lastFireEpoch[i] = nowEpoch;
if (_cfg.slots[i].days == 0) { // one-shot: disarm after firing
_cfg.slots[i].enabled = false;
save();
}
startRinging(i);
return true;
}
return false;
}
void startRinging(int slot) {
_mode = RINGING;
_ringSlot = slot;
_ringStartMs = millis();
_vibNextMs = 0;
_vibCount = 0;
}
void dismiss() {
if (_hapticReady) {
_haptic.standby(); // drop DRV2605 back to ~1.8uA once the alarm ends
_hapticReady = false; // force begin() (exit standby) on the next alarm
}
_mode = LIST;
_ringSlot = -1;
}
private:
void driveVibrate() {
if (!_hapticReady) {
_hapticReady = _haptic.begin(); // BLDO2 is already up; no motorEnable()
if (!_hapticReady) return;
}
if (millis() < _vibNextMs) return;
_haptic.buzz(WATCH_ALARM_EFFECT);
if (++_vibCount >= WATCH_ALARM_GROUP) {
_vibCount = 0;
_vibNextMs = millis() + WATCH_ALARM_PAUSE_MS;
} else {
_vibNextMs = millis() + WATCH_ALARM_BUZZ_MS;
}
}
// ---------------------------------------------------------------------------
// Rendering. 120x120 logical.
// ---------------------------------------------------------------------------
static const int ROW_Y0 = 22; // first list row top
static const int ROW_H = 22; // list row height
static const int TOGGLE_X = 88; // left edge of the ON/OFF badge column
void renderList(DisplayDriver& display) {
LGFXDisplay* d = (LGFXDisplay*)&display;
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 6, "Alarms");
char buf[16];
for (int i = 0; i < WATCH_ALARM_SLOT_COUNT; i++) {
int y = ROW_Y0 + i * ROW_H;
const WatchAlarmSlot& s = _cfg.slots[i];
if (i == _sel) {
d->setRawColor(0x231D); // same blue as the home tile
d->drawRoundRect(1, y, display.width() - 2, ROW_H - 2, 3);
}
d->setRawColor(s.enabled ? 0xFFFF : 0x7BEF); // white when armed, grey when off
snprintf(buf, sizeof(buf), "%02d:%02d", s.hour, s.minute);
display.setCursor(5, y + 3);
display.print(buf);
d->setRawColor(0x7BEF);
d->printSmallFont(5, y + 14, daysLabel(s.days));
d->setRawColor(s.enabled ? 0xC618 : 0x4208);
d->printSmallFont(TOGGLE_X + 4, y + 8, s.enabled ? "ON" : "OFF");
}
display.setColor(DisplayDriver::LIGHT);
d->setRawColor(0x7BEF);
d->printSmallFont(4, display.height() - 7, "Tap time:edit ON/OFF:arm");
}
void renderEdit(DisplayDriver& display) {
LGFXDisplay* d = (LGFXDisplay*)&display;
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 4, "Edit alarm");
// Up arrows / value / down arrows. Hour occupies x 10..54, minute 66..110.
d->setRawColor(0xC618);
display.drawTextCentered(32, 22, "+");
display.drawTextCentered(88, 22, "+");
display.drawTextCentered(32, 74, "-");
display.drawTextCentered(88, 74, "-");
char buf[8];
d->setRawColor(0xFFFF);
display.setTextSize(2);
snprintf(buf, sizeof(buf), "%02d", _edit.hour);
display.drawTextCentered(32, 44, buf);
snprintf(buf, sizeof(buf), "%02d", _edit.minute);
display.drawTextCentered(88, 44, buf);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 48, ":");
// Footer: left half cycles the day preset, right half saves.
d->setRawColor(0x231D);
d->drawRoundRect(1, 92, 58, 18, 3);
d->drawRoundRect(61, 92, 58, 18, 3);
d->setRawColor(0xC618);
d->printSmallFont(6, 100, daysLabel(_edit.days));
d->setRawColor(0xFFFF);
d->printSmallFont(78, 100, "Save");
}
void renderRinging(DisplayDriver& display) {
LGFXDisplay* d = (LGFXDisplay*)&display;
char buf[8];
d->setRawColor(0xFFFF);
if (_ringSlot >= 0) {
snprintf(buf, sizeof(buf), "%02d:%02d", _cfg.slots[_ringSlot].hour,
_cfg.slots[_ringSlot].minute);
} else {
strcpy(buf, "--:--");
}
d->printClockFont(display.width() / 2, display.height() / 2 - 22, buf);
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, display.height() - 24, "ALARM");
((LGFXDisplay*)&display)->printSmallFont(14, display.height() - 10, "Press PWR to dismiss");
}
public:
int render(DisplayDriver& display) override {
switch (_mode) {
case LIST: renderList(display); return 500;
case EDIT: renderEdit(display); return 200;
case RINGING: renderRinging(display); return 250;
}
return 500;
}
// Physical tap in logical (display.width()) coordinates. Returns true if the
// tap was consumed. The status-bar strip is handled by main.cpp before this.
bool handleTap(int lx, int ly) {
// Ringing: swallow taps. Only the PWR key dismisses (see handleInput).
if (_mode == RINGING) return true;
if (_mode == LIST) {
for (int i = 0; i < WATCH_ALARM_SLOT_COUNT; i++) {
int y = ROW_Y0 + i * ROW_H;
if (ly < y || ly >= y + ROW_H - 2) continue;
if (lx >= TOGGLE_X) { // ON/OFF badge: arm / disarm
_cfg.slots[i].enabled = !_cfg.slots[i].enabled;
_sel = i;
save();
} else { // time area: open the editor
_sel = i;
_edit = _cfg.slots[i];
_mode = EDIT;
}
return true;
}
return false;
}
// EDIT
if (ly >= 92) { // footer
if (lx < 60) {
_edit.days = nextDaysPreset(_edit.days);
} else {
commitEdit();
}
return true;
}
bool hourCol = (lx < display_mid());
if (ly < 40) { // upper half: increment
if (hourCol) _edit.hour = (_edit.hour + 1) % 24;
else _edit.minute = (_edit.minute + 1) % 60;
return true;
}
if (ly >= 62) { // lower half: decrement
if (hourCol) _edit.hour = (_edit.hour + 23) % 24;
else _edit.minute = (_edit.minute + 59) % 60;
return true;
}
return false;
}
bool handleInput(char c) override {
// The PMU short press arrives as KEY_ENTER.
if (_mode == RINGING) {
if (c == KEY_ENTER) dismiss(); // PWR key only
return true; // swallow everything else
}
if (_mode == EDIT && c == KEY_ENTER) { commitEdit(); return true; }
if (_mode == LIST && c == KEY_ENTER) {
_edit = _cfg.slots[_sel];
_mode = EDIT;
return true;
}
return false;
}
private:
int display_mid() const { return 60; } // half of the 120px logical width
void commitEdit() {
_cfg.slots[_sel] = _edit;
_cfg.slots[_sel].enabled = true; // saving an edit arms the alarm
save();
_mode = LIST;
}
};
@@ -1,21 +0,0 @@
# Meck -- LilyGo T-Watch S3 16 MB custom partition layout
#
# Single factory app slot (no OTA): the watch has no SD card and does not
# define MECK_OTA_UPDATE, so no firmware-OTA path is compiled -- an OTA
# app1 slot would only waste flash. Maps get a dedicated LittleFS partition;
# the DataStore keeps its own SPIFFS partition.
#
# Both data partitions use the 'spiffs' subtype (the standard ESP32 data
# subtype). The DataStore is mounted as SPIFFS via SPIFFS.begin() (first
# spiffs-subtype partition by offset); maps is mounted as LittleFS by the
# explicit "maps" label, so the mount type is chosen at mount time, not by
# subtype.
#
# NOTE: flashing this shifts the layout and wipes the existing SPIFFS --
# re-provision identity/contacts after flashing.
#
# Name, Type, SubType, Offset, Size
nvs, data, nvs, 0x9000, 0x5000
app0, app, factory, 0x10000, 0x200000
spiffs, data, spiffs, 0x210000, 0x400000
maps, data, spiffs, 0x610000, 0x9F0000
Can't render this file because it contains an unexpected character in line 11 and column 12.
-140
View File
@@ -1,140 +0,0 @@
[LilyGo_TWatchS3]
extends = esp32_base
extra_scripts = post:merge_firmware.py
board = lilygo_twatch_s3
board_build.flash_mode = qio
board_build.f_flash = 80000000L
board_build.arduino.memory_type = qio_opi
board_upload.flash_size = 16MB
board_build.partitions = variants/lilygo_twatch_s3/meck_twatch_16mb.csv
build_flags =
${esp32_base.build_flags}
${sensor_base.build_flags}
-I variants/lilygo_twatch_s3
; MECK_TWATCH gates the watch form factor (240x240, UI_ZOOM=2, touch, lock
; screen, tile grid, grey palette) and is shared with the S3 Plus.
; LILYGO_TWATCH_S3 gates hardware unique to this board.
-D MECK_TWATCH
-D LILYGO_TWATCH_S3
; The only key is the AXP2101 PWRON. Replaces PIN_USER_BTN, which is undefined
; here because there is no GPIO button (see variant.h).
-D MECK_PMU_BUTTON
-D TWATCH_COMPOSE_ENABLED
-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 (ST7789V 240x240) + FT6336U touch via LovyanGFX ----
-D DISPLAY_CLASS=TWatchS3Display
-D UI_ZOOM=2
; ---- Misc ----
-D AUTO_SHUTDOWN_MILLIVOLTS=3100
-D ARDUINO_LOOP_STACK_SIZE=32768
; ---- No GPS ----
; HAS_GPS is deliberately left undefined, which drops the watch map screen.
; ENV_INCLUDE_GPS must be explicitly zeroed: sensor_base defaults it to 1, and
; it gates gpsStream, board.gpsPowerOn/Off and the GPS home page.
-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>
+<helpers/sensors/*.cpp>
lib_deps =
${esp32_base.lib_deps}
${sensor_base.lib_deps}
lovyan03/LovyanGFX @ ^1.2.0
lewisxhe/XPowersLib @ ^0.2.7
lewisxhe/SensorLib @ ^0.4.1
adafruit/Adafruit GFX Library @ ^1.11.0
bitbank2/PNGdec @ ^1.0.1
WebServer
DNSServer
Update
; ---------------------------------------------------------------------------
; Standalone (no BLE companion), touch + display + lock screen.
; MAX_CONTACTS=2000 -- contact + sort arrays allocated in PSRAM via
; BaseChatMesh::initContacts().
; ---------------------------------------------------------------------------
[env:meck_twatch_s3_standalone]
extends = LilyGo_TWatchS3
build_flags =
${LilyGo_TWatchS3.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=40
-D OFFLINE_QUEUE_SIZE=1
-D ESP32_CPU_FREQ=80
-D FIRMWARE_VERSION='"Meck TWatch S3 v0.3"'
build_src_filter = ${LilyGo_TWatchS3.build_src_filter}
+<helpers/esp32/*.cpp>
-<helpers/esp32/SerialBLEInterface.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/LGFXDisplay.cpp>
lib_deps =
${LilyGo_TWatchS3.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.
; Flash: pio run -e meck_twatch_s3_ble -t upload
; ---------------------------------------------------------------------------
[env:meck_twatch_s3_ble]
extends = LilyGo_TWatchS3
build_flags =
${LilyGo_TWatchS3.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 S3 BLE v0.3"'
build_src_filter = ${LilyGo_TWatchS3.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/LGFXDisplay.cpp>
lib_deps =
${LilyGo_TWatchS3.lib_deps}
densaugeo/base64 @ ~1.4.0
lib_ignore =
AsyncTCP
ESPAsyncWebServer
-67
View File
@@ -1,67 +0,0 @@
#include <Arduino.h>
#include "variant.h"
#include "target.h"
TWatchS3Board board;
// LoRa SX1262 (HPD16B3 module) 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);
// No GNSS and no environment sensors on this board, so the base SensorManager
// is used. node_lat/node_lon stay at 0 and advert location policy has nothing
// to publish.
SensorManager sensors;
#ifdef DISPLAY_CLASS
DISPLAY_CLASS display;
PMUButton user_btn(board);
#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 <= 9) ? 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() {
// Power-saving RX gain on the watch (deliberate): boosted gain costs
// ~0.6-0.7 mA continuously for ~+2 dB sensitivity. The SetRxGain write
// itself is what resets the AGC; false selects the power-saving value.
radio.setRxBoostedGainMode(false);
}
-35
View File
@@ -1,35 +0,0 @@
#pragma once
// Include variant.h first to ensure all board-specific defines are available
#include "variant.h"
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <TWatchS3Board.h>
#include <helpers/AutoDiscoverRTCClock.h>
#include <helpers/SensorManager.h>
#ifdef DISPLAY_CLASS
#include <TWatchS3Display.h>
#include <PMUButton.h>
#endif
extern TWatchS3Board board;
extern WRAPPER_CLASS radio_driver;
extern AutoDiscoverRTCClock rtc_clock;
extern SensorManager sensors;
#ifdef DISPLAY_CLASS
extern DISPLAY_CLASS display;
// Not a MomentaryButton: the only key on this board is the AXP2101 PWRON.
extern PMUButton 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();
-97
View File
@@ -1,97 +0,0 @@
#pragma once
// =============================================================================
// LilyGo T-Watch S3 (non-GPS, 470 mAh) - Board-level pin definitions
//
// Sources, in order of authority:
// 1. T_WATCH-S3 schematic (rev 25-03-24)
// 2. Xinyuan-LilyGO/TTGO_TWatch_Library, branch t-watch-s3, src/utilities.h
// 3. LilyGo hardware doc docs/hardware/lilygo-t-watch-s3.md
//
// Every pin below is identical to the T-Watch S3 Plus. The two boards differ in
// what is *populated*, not in how the ESP32-S3 is wired:
// - no onboard GNSS (the Plus has a MIA-M10Q on BLDO1; here BLDO1 is unused
// and GPIO 41/42 go to an optional external GPS shield)
// - no GPIO user button; the only control is the side PWR key, which is wired
// to the AXP2101 PWRON pin (schematic sheet 1: SW7 -> PWR_KEY -> pin 30)
// - MAX98357A speaker amp on DLDO1, PDM mic on +3V3, IR LED on GPIO2 -- none
// of which Meck compiles in
//
// NOTE: LoRa (P_LORA_*) pins and the SX126x radio parameters are supplied as
// -D build flags in this variant's platformio.ini, matching the T-Watch S3 Plus
// and T-Deck Pro convention. The display SPI/backlight and FT6336U touch pins
// live inline in TWatchS3Display.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 (open-drain, active LOW)
#endif
#define PIN_RTC_IRQ 17 // PCF8563 interrupt
#define PIN_ACCEL_IRQ 14 // BMA423 interrupt
// User button: deliberately NOT defined.
//
// The T-Watch S3 has no GPIO button. Schematic sheet 1 wires the side tact
// switch SW7 to net PWR_KEY, which lands on AXP2101 pin 30 (PWRON). Button
// events therefore arrive over I2C as PMU interrupts, not as a pin level, and
// are read by PMUButton (see PMUButton.h). MECK_PMU_BUTTON is defined in this
// variant's platformio.ini in place of PIN_USER_BTN.
//
// #define PIN_USER_BTN <none>
// -----------------------------------------------------------------------------
// Display dimensions (ST7789V 1.54" IPS, 240x240)
// -----------------------------------------------------------------------------
#define LCD_HOR_SIZE 240
#define LCD_VER_SIZE 240
// -----------------------------------------------------------------------------
// Storage
// -----------------------------------------------------------------------------
// The T-Watch S3 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.
#define SDCARD_CS -1
// -----------------------------------------------------------------------------
// GPS: none. HAS_GPS and ENV_INCLUDE_GPS are left undefined / zeroed in
// platformio.ini, which drops the map screen, the GPS home page and the BLDO1
// rail control. The optional external GPS shield lands on GPIO41 (RX) /
// GPIO42 (TX) if it is ever wired up.
// -----------------------------------------------------------------------------
// #define HAS_GPS 1
// #define GPS_BAUDRATE 38400
// #define GPS_RX_PIN 41
// #define GPS_TX_PIN 42
// Fallback for code that references GPS_BAUDRATE without a HAS_GPS guard
// (e.g. MyMesh.cpp "gps.baud" CLI command)
#ifndef GPS_BAUDRATE
#define GPS_BAUDRATE 9600
#endif
@@ -1,113 +0,0 @@
#pragma once
#include <Arduino.h>
// 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
@@ -1,72 +0,0 @@
#pragma once
#include <Arduino.h>
// 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;
};
@@ -1,795 +0,0 @@
#pragma once
// =============================================================================
// TWatchComposeScreens — touch keyboard compose system for the LilyGo
// T-Watch S3 Plus (ST7789 240x240, capacitive touch, companion_radio ui-new).
//
// Three UIScreen subclasses, compiled only when TWATCH_COMPOSE_ENABLED is
// defined (companion build only). All drawing and hit-testing is in the
// LGFXDisplay virtual coordinate space, which is 120x120 on this build
// (240px panel / UI_ZOOM=2). getTouch() returns coords already divided by
// UI_ZOOM, so a tap coordinate is directly comparable to draw coordinates.
//
// TWatchChannelPicker -- long-press on the home screen opens this. A single
// tap selects the channel row under the finger; a
// long-press on a row opens it; the back arrow
// (top-left) returns home.
// TWatchChannelScreen -- shows the most recent messages (sent + received)
// for the selected channel, read live from the shared
// ChannelScreen history store. Tap a line to ticker-
// scroll it; tap the compose bar to open the keyboard;
// back arrow returns home.
// TWatchKeyboardScreen — on-screen QWERTY. Bottom-left mode key cycles
// lower -> UPPER -> SYM. Bottom row is mode | space |
// enter | backspace. Enter sends on the channel; back
// arrow returns home.
//
// Each screen reads getTouch() itself in poll() and does its own release-edge
// detection, so actions fire on touch release (finger up) — this keeps the
// held finger from carrying into the next screen after a transition. Send/exit
// are delegated to UITask via the consume/flag pattern; message history and
// unread counts are read from the shared ChannelScreen store.
// =============================================================================
#ifdef TWATCH_COMPOSE_ENABLED
#include <Arduino.h>
#include <string.h>
#include <helpers/ui/LGFXDisplay.h>
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include "ChannelScreen.h" // shared message history store (-I examples/companion_radio/ui-new)
// ---- Tunables ---------------------------------------------------------------
#define TW_LONG_PRESS_MS 600 // hold to select a channel in the picker
#define TW_OUT_BUF_LEN 134 // MeshCore per-channel msg cap (~133) + NUL
#define TW_TICKER_MS_PER_PX 20 // channel-screen ticker scroll speed (ms per pixel)
#define TW_CH_NAME_LEN 32
#define TW_PICKER_MAX 20 // matches MAX_GROUP_CHANNELS
// =============================================================================
// TWatchChannelPicker
// =============================================================================
class TWatchChannelPicker : public UIScreen {
DisplayDriver* _display;
struct Entry { uint8_t idx; char name[TW_CH_NAME_LEN]; };
Entry _channels[TW_PICKER_MAX];
uint8_t _numChannels;
uint8_t _highlighted;
uint8_t _scrollTop;
ChannelScreen* _store; // unread badge source
// touch edge state
bool _touchDown;
int _downX, _downY;
unsigned long _downAt;
// cross-screen flags (UITask polls these)
bool _confirmed;
bool _wantsExit;
static const int HEADER_H = 14;
static const int ROW_H = 14;
int visibleRows() const {
int h = _display ? _display->height() : 120;
int rows = (h - HEADER_H) / ROW_H;
return rows < 1 ? 1 : rows;
}
void ensureVisible() {
int vis = visibleRows();
if (_highlighted < _scrollTop) _scrollTop = _highlighted;
else if (_highlighted >= _scrollTop + vis) _scrollTop = (uint8_t)(_highlighted - vis + 1);
}
void moveUp() {
if (_numChannels == 0) return;
_highlighted = (_highlighted == 0) ? (uint8_t)(_numChannels - 1) : (uint8_t)(_highlighted - 1);
ensureVisible();
}
void moveDown() {
if (_numChannels == 0) return;
_highlighted = (uint8_t)((_highlighted + 1) % _numChannels);
ensureVisible();
}
// Map a draw-space y coordinate to a channel index, or -1 if the tap is
// above the list or on an empty row. getTouch() returns coords already
// divided by UI_ZOOM, so _downY is directly comparable to the row layout.
int rowAtY(int y) const {
if (y < HEADER_H) return -1;
int i = (y - HEADER_H) / ROW_H;
if (i < 0 || i >= visibleRows()) return -1;
int ci = _scrollTop + i;
if (ci >= _numChannels) return -1;
return ci;
}
public:
TWatchChannelPicker(DisplayDriver* display)
: _display(display), _numChannels(0), _highlighted(0), _scrollTop(0),
_store(nullptr),
_touchDown(false), _downX(0), _downY(0), _downAt(0),
_confirmed(false), _wantsExit(false) {
memset(_channels, 0, sizeof(_channels));
}
// Shared history store, used for the per-channel unread badges.
void setStore(ChannelScreen* store) { _store = store; }
// Called by UITask before showing the screen.
void beginChannelSelect() {
_numChannels = 0; _highlighted = 0; _scrollTop = 0;
_touchDown = false; _confirmed = false; _wantsExit = false;
}
void addChannel(uint8_t idx, const char* name) {
if (_numChannels >= TW_PICKER_MAX) return;
_channels[_numChannels].idx = idx;
strncpy(_channels[_numChannels].name, name ? name : "", TW_CH_NAME_LEN - 1);
_channels[_numChannels].name[TW_CH_NAME_LEN - 1] = 0;
_numChannels++;
}
// UITask bridges
bool isConfirmed() const { return _confirmed; }
void acknowledgeConfirm() { _confirmed = false; }
uint8_t getSelectedChannelIdx() const { return _channels[_highlighted].idx; }
const char* getSelectedChannelName() const { return _channels[_highlighted].name; }
bool wantsExit() const { return _wantsExit; }
void acknowledgeExit() { _wantsExit = false; }
bool handleInput(char c) override { return false; } // touch handled in poll()
void poll() override {
if (!_display) return;
int x, y;
bool now = ((LGFXDisplay*)_display)->getTouch(&x, &y);
if (now && !_touchDown) {
_touchDown = true; _downX = x; _downY = y; _downAt = millis();
} else if (!now && _touchDown) {
_touchDown = false;
unsigned long held = millis() - _downAt;
if (_downX < 20 && _downY < HEADER_H) { _wantsExit = true; return; } // back arrow
int row = rowAtY(_downY);
if (row < 0) return; // tap outside the channel list
_highlighted = (uint8_t)row; // single tap selects the tapped row
ensureVisible();
if (held >= TW_LONG_PRESS_MS) _confirmed = true; // long press opens it
}
}
int render(DisplayDriver& display) override {
const int W = display.width();
display.setTextSize(1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, 0);
display.print("<");
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(W / 2, 0, "CHANNELS");
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, HEADER_H - 2, W, 1);
if (_numChannels == 0) {
display.setCursor(2, HEADER_H + 2);
display.print("(no channels)");
return 500;
}
int vis = visibleRows();
int y = HEADER_H;
for (int i = 0; i < vis; i++) {
int ci = _scrollTop + i;
if (ci >= _numChannels) break;
if (ci == _highlighted) {
display.setColor(DisplayDriver::GREEN);
display.fillRect(0, y, W, ROW_H);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
int nameMaxW = W - 6;
int unread = _store ? _store->getUnreadForChannel(_channels[ci].idx) : 0;
if (unread > 0) {
char cnt[8];
snprintf(cnt, sizeof(cnt), "%d", unread > 99 ? 99 : unread);
int cw = display.getTextWidth(cnt);
display.setColor((ci == _highlighted) ? DisplayDriver::DARK : DisplayDriver::BLUE);
display.drawTextRightAlign(W - 3, y + 2, cnt);
display.setColor((ci == _highlighted) ? DisplayDriver::DARK : DisplayDriver::LIGHT);
nameMaxW = W - 6 - cw - 4;
}
display.drawTextEllipsized(3, y + 2, nameMaxW, _channels[ci].name);
y += ROW_H;
}
return 500;
}
};
// =============================================================================
// TWatchChannelScreen
// =============================================================================
class TWatchChannelScreen : public UIScreen {
DisplayDriver* _display;
ChannelScreen* _store; // shared message history store
uint8_t _channelIdx;
char _channelName[TW_CH_NAME_LEN];
char _selfPrefix[TW_CH_NAME_LEN + 2]; // "NodeName: " -- marks sent lines
bool _touchDown;
int _downX, _downY;
bool _wantsCompose;
bool _wantsExit;
int _selectedN; // recency index shown as ticker, -1 = none
unsigned long _tickerStartMs;
const void* _lastNewest; // newest store entry seen last render
uint32_t _lastNewestTs; // (a change dismisses the ticker)
bool _showPath; // hop-path overlay visible
int _pathMsgN; // recency index the overlay describes
static const int HEADER_H = 14;
static const int COMPOSE_BAR_H = 18;
static const int MSG_LINE_H = 11;
static const int MSG_TOP = HEADER_H + 2;
int rowsThatFit() const {
int h = _display ? _display->height() : 120;
int rows = (h - COMPOSE_BAR_H - 1 - MSG_TOP) / MSG_LINE_H;
return rows < 1 ? 1 : rows;
}
// Store messages available for this channel, capped at what fits on screen.
int visibleCount() const {
if (!_store) return 0;
int maxRows = rowsThatFit();
int n = 0;
while (n < maxRows && _store->getChannelMsgByRecency(_channelIdx, n)) n++;
return n;
}
// Sent messages are stored with path_len 0 and a "NodeName: " prefix.
bool isSent(const ChannelScreen::ChannelMessage* m) const {
return m->path_len == 0 && _selfPrefix[0] != 0 &&
strncmp(m->text, _selfPrefix, strlen(_selfPrefix)) == 0;
}
void drawMsgLine(DisplayDriver& display, int y, const char* text, bool sent) {
const int W = display.width();
const int maxW = W - 4;
display.setColor(DisplayDriver::LIGHT);
if (!sent) {
display.drawTextEllipsized(2, y, maxW, text); // incoming: left-aligned
return;
}
if (display.getTextWidth(text) <= maxW) { // sent: right-aligned
display.drawTextRightAlign(W - 2, y, text);
return;
}
char buf[CHANNEL_MSG_TEXT_LEN + 4];
strncpy(buf, text, sizeof(buf) - 4);
buf[sizeof(buf) - 4] = 0;
int ellW = display.getTextWidth("...");
int len = (int)strlen(buf);
while (len > 0 && display.getTextWidth(buf) > maxW - ellW) { buf[--len] = 0; }
strcat(buf, "...");
display.drawTextRightAlign(W - 2, y, buf);
}
void drawTicker(DisplayDriver& display, int y, const char* text, bool sent) {
const int W = display.width();
int textW = display.getTextWidth(text);
display.setColor(DisplayDriver::LIGHT);
if (textW <= W - 4) { // fits -> nothing to scroll, keep alignment
if (sent) display.drawTextRightAlign(W - 2, y, text);
else { display.setCursor(2, y); display.print(text); }
return;
}
int period = textW + 24; // full text width + trailing gap
unsigned long elapsed = millis() - _tickerStartMs;
int off = (int)((elapsed / TW_TICKER_MS_PER_PX) % (unsigned long)period);
// Wrap off while the marquee draws: a long line must clip at the screen
// edge, not wrap onto the rows below. Restored straight after.
((LGFXDisplay*)_display)->setTextWrap(false);
display.setCursor(2 - off, y);
display.print(text);
display.setCursor(2 - off + period, y);
display.print(text);
((LGFXDisplay*)_display)->setTextWrap(true);
}
// Renders the "Heard by" list (repeaters that echoed our sent message + the
// SNR we heard each at) into the already-drawn overlay box. Data comes from
// MyMesh::getHeardBy, keyed by the fingerprint stashed in the sent bubble's
// path[]. Same blue box as the hop-path overlay.
void drawHeardBy(LGFXDisplay* d, const ChannelScreen::ChannelMessage* m,
int bx, int by, int bh) {
uint8_t bph = 0, count = 0;
uint8_t hashes[16];
int8_t snrs[8];
(void)bh;
the_mesh.getHeardBy(m->path, 12, bph, count, hashes, snrs, 8);
char head[28];
snprintf(head, sizeof(head), "Heard by: %d", count); // distinct repeaters
d->setRawColor(0x7BEF);
d->printSmallFont(bx + 5, by + 8, head);
if (count == 0 || bph == 0) {
d->setRawColor(0xFFFF);
d->printSmallFont(bx + 5, by + 19, "No repeater echoes yet");
return;
}
const int maxLines = 5; // 5 repeaters, then a "+N more" 6th line
int shown = (count > maxLines) ? maxLines : count;
int y = by + 19;
for (int i = 0; i < shown; i++) {
const int off = i * bph;
char hex[6];
if (bph == 1) snprintf(hex, sizeof(hex), "%02x", hashes[off]);
else snprintf(hex, sizeof(hex), "%02x%02x", hashes[off], hashes[off + 1]);
char nm[16]; nm[0] = 0;
uint32_t numC = the_mesh.getNumContacts();
ContactInfo c;
for (int pass = 0; pass < 2 && nm[0] == 0; pass++) {
for (uint32_t ci = 0; ci < numC; ci++) {
if (!the_mesh.getContactByIdx(ci, c)) continue;
if (memcmp(c.id.pub_key, &hashes[off], bph) != 0) continue;
if (pass == 0 && c.type != ADV_TYPE_REPEATER) continue;
strncpy(nm, c.name, sizeof(nm) - 1); nm[sizeof(nm) - 1] = 0;
break;
}
}
int8_t s = snrs[i];
char line[40];
snprintf(line, sizeof(line), "(%s) %s %d.%ddB",
hex, nm[0] ? nm : "?", s / 4, ((abs((int)s) % 4) * 10) / 4);
d->setRawColor(0xFFFF);
d->printSmallFont(bx + 5, y, line);
y += 9;
}
if (count > shown) {
d->setRawColor(0xFFFF);
char more[16];
snprintf(more, sizeof(more), "+%d more", count - shown);
d->printSmallFont(bx + 5, y, more);
}
}
// Hop-path overlay for the message at recency _pathMsgN. Drawn last so it
// sits above the message list; toggled by the button (see handleInput),
// dismissed by any tap (see poll).
void drawPathOverlay(DisplayDriver& display) {
const ChannelScreen::ChannelMessage* m =
_store ? _store->getChannelMsgByRecency(_channelIdx, _pathMsgN) : nullptr;
if (!m) { _showPath = false; return; }
LGFXDisplay* d = (LGFXDisplay*)&display;
const int bx = 3, bw = display.width() - 6, by = 18, bh = 76;
d->setRawColor(0x0000);
d->fillRoundRect(bx, by, bw, bh, 4);
d->setRawColor(0x231D);
d->drawRoundRect(bx, by, bw, bh, 4);
// Sent message with a captured fingerprint: show which repeaters echoed it
// ("Heard by") instead of a hop path, which a sent bubble doesn't have.
if (m->path_len == 0 && m->has_fp) {
drawHeardBy(d, m, bx, by, bh);
return;
}
char head[28];
if (m->path_len == 0) {
snprintf(head, sizeof(head), "%s", isSent(m) ? "Sent (no path)" : "Direct (0 hops)");
} else if (m->path_len == 0xFF) {
snprintf(head, sizeof(head), "%s", "Flood (no path)");
} else {
snprintf(head, sizeof(head), "%d hops, %d-byte",
m->path_len & 63, (m->path_len >> 6) + 1);
}
d->setRawColor(0x7BEF);
d->printSmallFont(bx + 5, by + 8, head);
if (m->path_len == 0 || m->path_len == 0xFF) return;
int byteLen = (int)mesh::Packet::getPathByteLenFor(m->path_len);
if (byteLen > MSG_PATH_MAX) byteLen = MSG_PATH_MAX;
int bph = (m->path_len >> 6) + 1;
// Hex hops in the path-editor format ("3601,2198,..."), wrapped
// token-wise across small-font lines.
char line[36];
int ll = 0, y = by + 19;
d->setRawColor(0xFFFF);
for (int b = 0; b < byteLen; b += bph) {
char tok[9];
int tl = 0;
for (int k = 0; k < bph && b + k < byteLen; k++)
tl += snprintf(tok + tl, sizeof(tok) - tl, "%02x", m->path[b + k]);
if (b + bph < byteLen) { tok[tl++] = ','; tok[tl] = 0; }
if (ll + tl > 26) {
line[ll] = 0;
d->printSmallFont(bx + 5, y, line);
y += 9; ll = 0;
if (y > by + bh - 8) { d->printSmallFont(bx + 5, y, "..."); return; }
}
memcpy(line + ll, tok, tl); ll += tl;
}
if (ll > 0) { line[ll] = 0; d->printSmallFont(bx + 5, y, line); }
}
public:
TWatchChannelScreen(DisplayDriver* display)
: _display(display), _store(nullptr), _channelIdx(0),
_touchDown(false), _downX(0), _downY(0),
_wantsCompose(false), _wantsExit(false),
_selectedN(-1), _tickerStartMs(0),
_lastNewest(nullptr), _lastNewestTs(0),
_showPath(false), _pathMsgN(0) {
_channelName[0] = 0;
_selfPrefix[0] = 0;
}
// Shared history store this screen renders from.
void setStore(ChannelScreen* store) { _store = store; }
// Own node name, used to right-align sent lines ("NodeName: text").
void setSelfName(const char* name) {
if (!name || !name[0]) { _selfPrefix[0] = 0; return; }
snprintf(_selfPrefix, sizeof(_selfPrefix), "%s: ", name);
}
// Switch to a channel (called when a channel is selected in the picker).
// Opening a channel marks its messages as read.
void activate(uint8_t idx, const char* name) {
_channelIdx = idx;
strncpy(_channelName, name ? name : "", TW_CH_NAME_LEN - 1);
_channelName[TW_CH_NAME_LEN - 1] = 0;
_touchDown = false; _wantsCompose = false; _wantsExit = false;
_selectedN = -1;
_showPath = false;
_lastNewest = nullptr; _lastNewestTs = 0;
if (_store) _store->markChannelRead(_channelIdx);
}
uint8_t getChannelIdx() const { return _channelIdx; }
const char* getChannelName() const { return _channelName; }
bool wantsCompose() const { return _wantsCompose; }
void acknowledgeCompose() { _wantsCompose = false; }
bool wantsExit() const { return _wantsExit; }
void acknowledgeExit() { _wantsExit = false; }
// Short press of the PWR key (S3, KEY_ENTER) or the boot button (S3 Plus,
// KEY_NEXT): toggle the hop-path overlay for the ticker-selected message
// (the newest one if none is selected). Touch is handled in poll().
bool handleInput(char c) override {
if (c == KEY_ENTER || c == KEY_NEXT) {
if (_showPath) { _showPath = false; return true; }
int n = (_selectedN >= 0) ? _selectedN : 0;
if (_store && _store->getChannelMsgByRecency(_channelIdx, n)) {
_pathMsgN = n;
_showPath = true;
}
return true;
}
return false;
}
void poll() override {
if (!_display) return;
int x, y;
bool now = ((LGFXDisplay*)_display)->getTouch(&x, &y);
if (now && !_touchDown) {
_touchDown = true; _downX = x; _downY = y;
} else if (!now && _touchDown) {
_touchDown = false;
int H = _display->height();
if (_showPath) { _showPath = false; return; } // any tap dismisses the path overlay
if (_downX < 20 && _downY < HEADER_H) { _selectedN = -1; _wantsExit = true; return; } // back arrow
if (_downY >= H - COMPOSE_BAR_H) { _selectedN = -1; _wantsCompose = true; return; } // compose bar
// message area -> tap to open/close ticker
if (_downY >= MSG_TOP && _downY < H - COMPOSE_BAR_H) {
int visualRow = (_downY - MSG_TOP) / MSG_LINE_H;
int count = visibleCount();
if (visualRow >= 0 && visualRow < count) {
int n = (count - 1) - visualRow; // top row = oldest visible
if (_selectedN == n) _selectedN = -1;
else { _selectedN = n; _tickerStartMs = millis(); }
}
}
}
}
int render(DisplayDriver& display) override {
const int W = display.width();
const int H = display.height();
display.setTextSize(1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, 0);
display.print("<");
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(W / 2, 0, _channelName);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, HEADER_H - 2, W, 1);
// A new message (sent or received) shifts the rows, so dismiss any open
// ticker when the newest store entry for this channel changes.
const ChannelScreen::ChannelMessage* newest =
_store ? _store->getChannelMsgByRecency(_channelIdx, 0) : nullptr;
if (newest != _lastNewest || (newest && newest->timestamp != _lastNewestTs)) {
_lastNewest = newest;
_lastNewestTs = newest ? newest->timestamp : 0;
_selectedN = -1;
}
int count = visibleCount();
if (count == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(2, MSG_TOP);
display.print("(no messages)");
} else {
int y = MSG_TOP;
for (int n = count - 1; n >= 0; n--) { // oldest visible (top) -> newest (bottom)
const ChannelScreen::ChannelMessage* m = _store->getChannelMsgByRecency(_channelIdx, n);
if (!m) continue;
bool sent = isSent(m);
if (n == _selectedN) drawTicker(display, y, m->text, sent);
else drawMsgLine(display, y, m->text, sent);
y += MSG_LINE_H;
}
}
// compose bar
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, H - COMPOSE_BAR_H, W, COMPOSE_BAR_H - 1);
display.drawTextEllipsized(3, H - COMPOSE_BAR_H + 4, W - 6, "Tap to compose");
if (_showPath) drawPathOverlay(display);
return (_selectedN >= 0 || _showPath) ? 60 : 500;
}
};
// =============================================================================
// TWatchKeyboardScreen
// =============================================================================
class TWatchKeyboardScreen : public UIScreen {
DisplayDriver* _display;
uint8_t _channelIdx;
public:
enum Purpose { TWKB_CHANNEL, TWKB_DM, TWKB_ADMIN_PASSWORD, TWKB_ADMIN_CLI, TWKB_PATH, TWKB_NOTE, TWKB_SCOPE, TWKB_TRACE_PATH };
private:
Purpose _purpose;
int _contextIdx; // channel idx (channel) or contact idx (DM/admin)
bool _mask; // render composed text as '*' (admin password)
unsigned long _lastCharAt; // when the last char was typed (for reveal window)
static const unsigned long PW_REVEAL_MS = 2000; // show last char this long before masking
char _outBuf[TW_OUT_BUF_LEN];
uint16_t _outLen;
enum Mode { LOWER, UPPER, SYM };
Mode _mode;
bool _touchDown;
int _downX, _downY;
bool _wantsSend;
bool _wantsExit;
static const int MAXLEN = TW_OUT_BUF_LEN - 1; // 133
// layout geometry (120x120 virtual space)
static const int TOPBAR_H = 15;
static const int KEY_W = 12; // 10 * 12 = 120 wide
static const int KEY_H = 26;
static const int GRID_Y = 15; // row0 top; rows at GRID_Y + row*KEY_H
const char* const* layout() const {
static const char* const lower[3] = { "qwertyuiop", "asdfghjkl'", "zxcvbnm,.?" };
static const char* const upper[3] = { "QWERTYUIOP", "ASDFGHJKL\"", "ZXCVBNM<>/" };
static const char* const syms[3] = { "123!@#$%^&", "456()[]{}-", "7890+=_:;|" };
return _mode == SYM ? syms : (_mode == UPPER ? upper : lower);
}
const char* modeLabel() const {
return _mode == SYM ? "SYM" : (_mode == UPPER ? "A-Z" : "a-z");
}
void appendChar(char ch) {
if (_outLen < MAXLEN) { _outBuf[_outLen++] = ch; _outBuf[_outLen] = 0; _lastCharAt = millis(); }
}
void backspace() {
if (_outLen > 0) { _outLen--; _outBuf[_outLen] = 0; _lastCharAt = 0; }
}
void cycleMode() {
_mode = (_mode == LOWER) ? UPPER : (_mode == UPPER ? SYM : LOWER);
}
// bottom row zones: mode 0..24 | space 24..72 | enter 72..96 | backspace 96..120
void handleBottomRow(int x) {
if (x < 24) cycleMode();
else if (x < 72) appendChar(' ');
else if (x < 96) { if (_outLen > 0) _wantsSend = true; }
else backspace();
}
public:
TWatchKeyboardScreen(DisplayDriver* display)
: _display(display), _channelIdx(0),
_purpose(TWKB_CHANNEL), _contextIdx(0), _mask(false), _lastCharAt(0),
_outLen(0), _mode(LOWER),
_touchDown(false), _downX(0), _downY(0),
_wantsSend(false), _wantsExit(false) {
_outBuf[0] = 0;
}
// Enter compose on the given channel (called when the compose bar is tapped).
void activate(uint8_t idx, const char* name) {
(void)name;
_channelIdx = idx;
_purpose = TWKB_CHANNEL;
_contextIdx = idx;
_mask = false;
_outLen = 0; _outBuf[0] = 0;
_mode = LOWER;
_touchDown = false; _wantsSend = false; _wantsExit = false;
}
// Enter text for a non-channel purpose (DM / admin password / admin CLI).
void activateFor(Purpose purpose, int contextIdx) {
_purpose = purpose;
_contextIdx = contextIdx;
_channelIdx = 0;
_mask = (purpose == TWKB_ADMIN_PASSWORD);
_outLen = 0; _outBuf[0] = 0;
_mode = LOWER;
_touchDown = false; _wantsSend = false; _wantsExit = false;
}
Purpose getPurpose() const { return _purpose; }
int getContextIdx() const { return _contextIdx; }
uint8_t getChannelIdx() const { return _channelIdx; }
bool consumeSendRequest(const char** textOut) {
if (!_wantsSend) return false;
_wantsSend = false;
if (textOut) *textOut = _outBuf;
return true;
}
void clearOutBuf() { _outLen = 0; _outBuf[0] = 0; }
// Pre-fill the compose buffer (e.g. editing an existing note). Call after
// activateFor(), which clears the buffer; input is clamped at MAXLEN.
void setInitialText(const char* s) {
_outLen = 0;
if (s) {
while (s[_outLen] && _outLen < MAXLEN) { _outBuf[_outLen] = s[_outLen]; _outLen++; }
}
_outBuf[_outLen] = 0;
}
bool wantsExit() const { return _wantsExit; }
void acknowledgeExit() { _wantsExit = false; }
bool handleInput(char c) override { return false; }
void poll() override {
if (!_display) return;
int x, y;
bool now = ((LGFXDisplay*)_display)->getTouch(&x, &y);
if (now && !_touchDown) {
_touchDown = true; _downX = x; _downY = y;
} else if (!now && _touchDown) {
_touchDown = false;
int x0 = _downX, y0 = _downY;
if (x0 < 20 && y0 < TOPBAR_H) { _wantsExit = true; return; } // back arrow
if (y0 < GRID_Y) return; // top bar (text) area
int row = (y0 - GRID_Y) / KEY_H;
if (row <= 2) {
int col = x0 / KEY_W;
char ch = layout()[row][col];
if (ch) appendChar(ch);
} else {
handleBottomRow(x0); // bottom row
}
}
}
int render(DisplayDriver& display) override {
const int W = display.width();
display.setTextSize(1);
// ---- top bar: back arrow + composed text tail + cursor ----
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, 0);
display.print("<");
display.setColor(DisplayDriver::LIGHT);
{
// Mask the composed text with '*' for the admin password purpose.
char masked[TW_OUT_BUF_LEN];
const char* src = _outBuf;
if (_mask) {
int m = (_outLen < TW_OUT_BUF_LEN - 1) ? _outLen : TW_OUT_BUF_LEN - 1;
for (int i = 0; i < m; i++) masked[i] = '*';
// Reveal the most recently typed char briefly so it can be read.
if (m > 0 && (millis() - _lastCharAt < PW_REVEAL_MS)) masked[m - 1] = _outBuf[m - 1];
masked[m] = 0;
src = masked;
}
const int avail = W - 16 - 4; // room after the back arrow, minus cursor
int len = _outLen;
int fit = 0;
for (int n = 1; n <= len; n++) {
if (display.getTextWidth(src + (len - n)) > avail) break;
fit = n;
}
char tail[TW_OUT_BUF_LEN + 2];
strncpy(tail, src + (len - fit), sizeof(tail) - 2);
tail[sizeof(tail) - 2] = 0;
size_t tl = strlen(tail);
tail[tl] = '_'; tail[tl + 1] = 0;
display.setCursor(16, 2);
display.print(tail);
}
// ---- key grid rows 0..2 ----
const char* const* lay = layout();
for (int row = 0; row < 3; row++) {
int ry = GRID_Y + row * KEY_H;
const char* r = lay[row];
for (int col = 0; col < 10; col++) {
int kx = col * KEY_W;
display.setColor(DisplayDriver::GREEN);
display.drawRect(kx, ry, KEY_W, KEY_H);
char ch[2] = { r[col], 0 };
display.setColor(DisplayDriver::LIGHT);
int tw = display.getTextWidth(ch);
display.setCursor(kx + (KEY_W - tw) / 2, ry + (KEY_H - 8) / 2);
display.print(ch);
}
}
// ---- bottom row: mode | space | enter | backspace ----
int by = GRID_Y + 3 * KEY_H;
display.setColor(DisplayDriver::GREEN);
display.fillRect(0, by, 24, KEY_H);
display.setColor(DisplayDriver::DARK);
{
const char* ml = modeLabel();
int tw = display.getTextWidth(ml);
display.setCursor((24 - tw) / 2, by + (KEY_H - 8) / 2);
display.print(ml);
}
display.setColor(DisplayDriver::LIGHT);
display.drawRect(24, by, 48, KEY_H); // space
display.setColor(DisplayDriver::GREEN);
display.fillRect(72, by, 24, KEY_H);
display.setColor(DisplayDriver::DARK);
{
const char* el = "OK";
int tw = display.getTextWidth(el);
display.setCursor(72 + (24 - tw) / 2, by + (KEY_H - 8) / 2);
display.print(el);
}
display.setColor(DisplayDriver::ORANGE);
display.fillRect(96, by, 24, KEY_H);
display.setColor(DisplayDriver::DARK);
{
const char* bl = "DEL";
int tw = display.getTextWidth(bl);
display.setCursor(96 + (24 - tw) / 2, by + (KEY_H - 8) / 2);
display.print(bl);
}
return 500;
}
};
#endif // TWATCH_COMPOSE_ENABLED
@@ -1,220 +0,0 @@
#include <Arduino.h>
#include "TWatchS3PlusBoard.h"
#include <SensorBMA423.hpp>
#include <esp_bt.h> // TEMP power-debug: esp_bt_controller_get_status()
volatile bool TWatchS3PlusBoard::_tilt_flag = false;
// memw forces the _tilt_flag store to retire to SRAM before this ISR
// returns. Without it, tiltFired() in the main loop can read a stale
// value and raise-to-wake is missed. Do not remove.
void IRAM_ATTR TWatchS3PlusBoard::onTiltISR() { _tilt_flag = true; asm volatile("memw" ::: "memory"); }
// ---- Wrapper-free BMA423 step counter (raw I2C) ----------------------------
// SensorLib's SensorBMA423 step-counter methods do not compile in this build,
// so the step counter is driven directly over I2C. Register/offset/mask values
// are from the Bosch BMA423 driver.
#define BMA423_REG_STEP_CNT_OUT 0x1E // 4-byte little-endian step count output
#define BMA423_REG_FEATURE_CONFIG 0x5E // 64-byte feature config stream
#define BMA423_FEATURE_LEN 64
#define BMA423_STEP_EN_BYTE 0x37 // BMA423_STEP_CNTR_OFFSET(0x36) + 1
#define BMA423_STEP_EN_BIT 0x10 // BMA423_STEP_CNTR_EN_MSK
// Step counter watermark: a 10-bit field (BMA423_STEP_CNTR_WM_MSK = 0x03FF)
// spanning feature_config[0x36] as LSB and the low two bits of [0x37] as MSB.
// It does not collide with the enable bit, which is bit 4 of [0x37] (bit 12 of
// the 16-bit word). Bosch's bma423_step_counter_set_watermark() writes exactly
// these bits; SensorLib's enableStepCounter() calls it with 1, and LilyGo's own
// firmware calls setStepCounterWatermark(1). Meck never set it, which is the
// prime suspect for the undercounting.
#define BMA423_STEP_WM_LSB_BYTE 0x36 // BMA423_STEP_CNTR_OFFSET
#define BMA423_STEP_WM_MSK 0x03FF // BMA423_STEP_CNTR_WM_MSK
#define BMA423_STEP_WM_LEVEL 1 // matches LilyGoLib and the SensorLib example
#define BMA423_REG_POWER_CONF 0x7C // BMA4_POWER_CONF_ADDR
#define BMA423_ADV_PWR_SAVE_BIT 0x01 // BMA4_ADVANCE_POWER_SAVE_MSK
static bool bma423ReadRegs(uint8_t reg, uint8_t* buf, uint8_t len) {
Wire.beginTransmission(I2C_ADDR_ACCEL);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom((int)I2C_ADDR_ACCEL, (int)len) != len) return false;
for (uint8_t i = 0; i < len; i++) buf[i] = Wire.read();
return true;
}
static bool bma423WriteRegs(uint8_t reg, const uint8_t* buf, uint8_t len) {
Wire.beginTransmission(I2C_ADDR_ACCEL);
Wire.write(reg);
for (uint8_t i = 0; i < len; i++) Wire.write(buf[i]);
return Wire.endTransmission() == 0;
}
// Enable the step counter by setting its enable bit in the feature config,
// preserving every other byte (tilt lives at a different offset, 0x3A, so it is
// untouched). The feature config can only be written with advanced-power-save
// disabled, so we bracket the write and restore the prior power state after.
static void bma423EnableStepCounter() {
uint8_t pc;
if (!bma423ReadRegs(BMA423_REG_POWER_CONF, &pc, 1)) return; // save power state
uint8_t off = pc & ~BMA423_ADV_PWR_SAVE_BIT; // disable adv power save
bma423WriteRegs(BMA423_REG_POWER_CONF, &off, 1);
delay(2); // wake from low-power (>=450us)
uint8_t cfg[BMA423_FEATURE_LEN];
if (bma423ReadRegs(BMA423_REG_FEATURE_CONFIG, cfg, BMA423_FEATURE_LEN)) {
// Watermark first, then the enable bit, in a single read-modify-write.
uint16_t wm = ((uint16_t)cfg[BMA423_STEP_EN_BYTE] << 8) | cfg[BMA423_STEP_WM_LSB_BYTE];
wm = (wm & ~BMA423_STEP_WM_MSK) | (BMA423_STEP_WM_LEVEL & BMA423_STEP_WM_MSK);
cfg[BMA423_STEP_WM_LSB_BYTE] = (uint8_t)(wm & 0xFF);
cfg[BMA423_STEP_EN_BYTE] = (uint8_t)((wm >> 8) & 0xFF);
cfg[BMA423_STEP_EN_BYTE] |= BMA423_STEP_EN_BIT;
bma423WriteRegs(BMA423_REG_FEATURE_CONFIG, cfg, BMA423_FEATURE_LEN);
delay(1); // write settle
}
bma423WriteRegs(BMA423_REG_POWER_CONF, &pc, 1); // restore power state
}
void TWatchS3PlusBoard::begin() {
ESP32Board::begin();
power_init();
// BMA423 accelerometer (always-on I2C, 0x19): enable the tilt / wrist-raise
// feature and its interrupt (routed to PIN1 -> GPIO14) for raise-to-wake.
_accel = new SensorBMA423();
if (_accel->begin(Wire, I2C_ADDR_ACCEL, PIN_BOARD_SDA, PIN_BOARD_SCL)) {
_accel->setRemapAxes(SensorRemap::BOTTOM_LAYER_TOP_RIGHT_CORNER);
// 100 Hz ODR: SensorLib's BMA423_StepDetector example runs the pedometer at
// 100 Hz, not the 50 Hz used here previously. Everything else already matched
// (NORMAL, FS_2G, OSR2_AVG2, CIC_AVG_MODE). Revert this one literal to 50.0f
// to A/B it against the watermark change above.
_accel->configAccelerometer(OperationMode::NORMAL, AccelFullScaleRange::FS_2G,
100.0f, AccelBandwidth::OSR2_AVG2, AccelPerfMode::CIC_AVG_MODE);
// INT1 pin electrical config: level trigger, active high, push-pull,
// output enabled. INT1_IO_CTRL resets to output-disabled, so without
// this the pin never drives and INPUT_PULLDOWN reads low forever.
_accel->setInterruptPinConfig(InterruptPinMap::PIN1, false, false, true, false);
pinMode(PIN_ACCEL_IRQ, INPUT_PULLDOWN);
// Attach the edge ISR BEFORE enabling the tilt source, so the first
// assertion cannot occur before the handler is armed (a missed first edge
// on a self-clearing line otherwise locks tilt-wake out permanently).
attachInterrupt(digitalPinToInterrupt(PIN_ACCEL_IRQ), onTiltISR, RISING);
_accel->enableTiltDetector(true, true);
// Enable the hardware step counter via raw I2C (SensorLib's wrapper method
// does not compile in this build). It then counts in the BMA423 feature
// engine with no CPU cost, even while the display is off.
bma423EnableStepCounter();
}
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_200MA);
// The fitted cell (Tewaycell 112530-2P, label verified) is 3.8V nominal
// LiHV -- i.e. 4.35V max charge, same chemistry as the S3's cell. Charging
// to 4V2 forfeits the top ~12-15% of capacity.
PMU->setChargeTargetVoltage(XPOWERS_AXP2101_CHG_VOL_4V35);
PMU->disableTSPinMeasure();
PMU->enableSystemVoltageMeasure();
PMU->enableVbusVoltageMeasure();
PMU->enableBattVoltageMeasure();
PMU->setPowerKeyPressOffTime(XPOWERS_POWEROFF_4S);
// TEMP power-debug: one-shot rail dump at boot. Rail OFF means the attached
// chip has no power at all (not merely sleeping).
Serial.printf("[PWR] rails: ALDO2(bl)=%d ALDO3(disp/touch)=%d ALDO4(LoRa)=%d BLDO1(GPS)=%d BLDO2(haptic)=%d\n",
PMU->isPowerChannelEnable(XPOWERS_ALDO2),
PMU->isPowerChannelEnable(XPOWERS_ALDO3),
PMU->isPowerChannelEnable(XPOWERS_ALDO4),
PMU->isPowerChannelEnable(XPOWERS_BLDO1),
PMU->isPowerChannelEnable(XPOWERS_BLDO2));
Serial.printf("[PWR] GPS rail (BLDO1) at boot: %s\n",
PMU->isPowerChannelEnable(XPOWERS_BLDO1) ? "ON" : "OFF (fully powered down)");
return true;
}
// TEMP power-debug probe.
void TWatchS3PlusBoard::printPowerDebug() {
if (!PMU) return;
Serial.printf("[PWR] batt=%dmV %d%% vbus=%dmV charging=%d cpu=%dMHz bt=%d gps_rail=%s\n",
PMU->getBattVoltage(), PMU->getBatteryPercent(),
PMU->getVbusVoltage(), PMU->isCharging(),
getCpuFrequencyMhz(), (int)esp_bt_controller_get_status(),
PMU->isPowerChannelEnable(XPOWERS_BLDO1) ? "ON" : "OFF");
}
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);
}
bool TWatchS3PlusBoard::tiltFired() {
if (_tilt_flag) { // set by the GPIO14 rising-edge ISR
_tilt_flag = false;
_accel->update(); // reading the status clears the sensor INT
return true;
}
return false;
}
uint32_t TWatchS3PlusBoard::getStepCount() {
uint8_t d[4];
if (!bma423ReadRegs(BMA423_REG_STEP_CNT_OUT, d, 4)) return 0;
return (uint32_t)d[0] | ((uint32_t)d[1] << 8) |
((uint32_t)d[2] << 16) | ((uint32_t)d[3] << 24);
}
@@ -1,74 +0,0 @@
#pragma once
#include "variant.h" // Board-specific pin definitions (I2C, user btn, addresses)
#include <Wire.h>
#include <Arduino.h>
#include "XPowersLib.h"
#include "helpers/ESP32Board.h"
#include <driver/rtc_io.h>
// 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 SensorBMA423; // full include kept in the .cpp to avoid a BLE-build header clash
class TWatchS3PlusBoard : public ESP32Board {
XPowersLibInterface* PMU = NULL;
SensorBMA423* _accel = nullptr;
static volatile bool _tilt_flag;
static void IRAM_ATTR onTiltISR(); // defined in the .cpp (IRAM relocation)
bool power_init();
public:
void begin();
// Returns true once when the BMA423 tilt (wrist-raise) interrupt has fired.
bool tiltFired();
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;
}
uint8_t getBatteryPercent() override {
return PMU ? PMU->getBatteryPercent() : 0;
}
// Wrapper-free BMA423 step count (raw I2C; defined in the .cpp).
uint32_t getStepCount();
// 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();
// TEMP power-debug probe: battery, VBUS, CPU clock, BT controller state and
// live rail states (incl. GPS/BLDO1). Called periodically from UITask::loop.
void printPowerDebug();
const char* getManufacturerName() const override {
return "LilyGo T-Watch S3 Plus";
}
};
@@ -1,96 +0,0 @@
#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 <helpers/ui/LGFXDisplay.h>
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) {}
};
@@ -1,382 +0,0 @@
#pragma once
// =============================================================================
// WatchChannelConfigScreen -- per-channel settings for the T-Watch S3 / S3 Plus.
//
// The SettingsScreen SUB_CHANNELS sub-screen drives every per-channel action
// from physical-keyboard hotkeys on the selected row (Ent: edit region scope,
// N: cycle notifications, T: tone, X/Hold: delete), none of which exist on the
// touch-only watch. This standalone screen replaces it on MECK_TWATCH builds,
// following the WatchAlarmScreen / WatchNotesScreen conventions.
//
// LIST mode shows each channel with its region scope tag and notification
// state; tap a row to select it, tap again (or PWR/Enter) to open it. DETAIL
// mode offers: Region (opens the watch keyboard via TWKB_SCOPE, pre-filled
// with the current scope), Clear region (reverts to the device default),
// Notify (tap-cycles All -> Mentions -> Off, mirroring the desktop order),
// and Delete via the button (channel 1+ only): a short press of the PWR key
// (S3) or the boot button (S3 Plus) arms it, a second press within 3s
// confirms. The tone picker is deliberately absent -- no audio path.
//
// Exit: the footer Back returns to the Settings screen (via a flag polled by
// UITask::loop); the universal top-strip tap still goes home.
//
// Geometry: 240x240 physical, UI_ZOOM=2, so display.width()/height() = 120.
// =============================================================================
// NOTE on include order: mirrors WatchNotesScreen.h -- this header uses
// NodePrefs (NOTIF_* constants, channel_notif[]) and the_mesh, which the two
// call sites (UITask.cpp, main.cpp) already have in scope before including it.
#include <Arduino.h>
#include <string.h>
#include <MeshCore.h>
#include <helpers/ChannelDetails.h>
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/LGFXDisplay.h>
class UITask;
#ifndef MAX_GROUP_CHANNELS
#define MAX_GROUP_CHANNELS 20
#endif
#define WCC_ROWS 4 // list rows per page
class WatchChannelConfigScreen : public UIScreen {
public:
enum Mode { LIST, DETAIL };
private:
UITask* _task;
NodePrefs* _node_prefs;
uint8_t _chIdx[MAX_GROUP_CHANNELS]; // populated channel slots, in order
int _numCh = 0;
Mode _mode = LIST;
int _sel = 0; // selected list row (index into _chIdx)
int _scrollTop = 0;
int _detail = -1; // channel slot open in DETAIL (value from _chIdx)
bool _confirmDelete = false;
unsigned long _confirmAt = 0;
// Keyboard / exit handshake -- UITask::loop() polls these (UITask is only
// forward declared here, so this screen cannot call it directly).
bool _wantKeyboard = false;
bool _wantExit = false;
char _editScope[31]; // pre-fill text handed to the keyboard
// Layout (logical pixels)
static const int HEADER_H = 14;
static const int ROW_H = 20;
static const int FOOTER_Y = 100;
void rebuildList() {
// Scan ALL slots -- the companion app may write non-contiguously, and
// gaps can appear after deletion (same scan as SettingsScreen).
_numCh = 0;
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
_chIdx[_numCh++] = i;
}
}
if (_sel >= _numCh) _sel = _numCh > 0 ? _numCh - 1 : 0;
if (_scrollTop > _sel) _scrollTop = _sel;
}
const char* notifLabel(uint8_t idx) const {
uint8_t n = _node_prefs->channel_notif[idx];
return (n == NOTIF_NONE) ? "Off" : (n == NOTIF_MENTIONS) ? "@" : "All";
}
// Replicates SettingsScreen::deleteChannel (kept in sync manually). Note:
// channel_notif[] is not shifted during compaction -- this matches the
// desktop behaviour exactly.
void deleteChannel(uint8_t idx) {
ChannelDetails empty;
memset(&empty, 0, sizeof(empty));
int total = 0;
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
total = i + 1;
}
}
for (int i = idx; i < total - 1; i++) {
ChannelDetails next;
if (the_mesh.getChannel(i + 1, next)) {
the_mesh.setChannel(i, next);
}
}
the_mesh.setChannel(total - 1, empty);
the_mesh.saveChannels();
Serial.printf("WatchChannelCfg: Deleted channel at idx %d, compacted %d channels\n", idx, total);
}
void ensureVisible() {
if (_sel < _scrollTop) _scrollTop = _sel;
if (_sel >= _scrollTop + WCC_ROWS) _scrollTop = _sel - WCC_ROWS + 1;
}
public:
WatchChannelConfigScreen(UITask* task, NodePrefs* node_prefs)
: _task(task), _node_prefs(node_prefs) {
_editScope[0] = 0;
}
void enter() {
rebuildList();
_mode = LIST;
_detail = -1;
_confirmDelete = false;
_wantKeyboard = false;
_wantExit = false;
}
// --- handshake (polled from UITask::loop) ---
bool wantsKeyboard() const { return _wantKeyboard; }
void clearWantKeyboard() { _wantKeyboard = false; }
bool wantsExit() const { return _wantExit; }
void clearWantExit() { _wantExit = false; }
const char* getEditText() const { return _editScope; }
// Save the keyboard result as the open channel's region scope. An empty
// string clears it back to the device default (same as Clear). Returns
// NULL on success, else an error message.
const char* applyComposedScope(const char* txt) {
if (_detail < 0) return "No channel open";
ChannelDetails ch;
if (!the_mesh.getChannel((uint8_t)_detail, ch)) return "Channel gone";
if (txt == nullptr) txt = "";
strncpy(ch.scope_name, txt, sizeof(ch.scope_name));
ch.scope_name[30] = '\0';
the_mesh.setChannel((uint8_t)_detail, ch);
the_mesh.saveChannels();
Serial.printf("WatchChannelCfg: Channel %d scope set to '%s'\n",
_detail, ch.scope_name[0] ? ch.scope_name : "(device default)");
return nullptr;
}
// ---------------------------------------------------------------------------
// Rendering. 120x120 logical.
// ---------------------------------------------------------------------------
private:
void renderList(DisplayDriver& display) {
LGFXDisplay* d = (LGFXDisplay*)&display;
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 4, "Channels");
if (_numCh == 0) {
d->setRawColor(0x7BEF);
display.drawTextCentered(display.width() / 2, 48, "No channels");
}
char buf[40];
int visible = _numCh - _scrollTop;
if (visible > WCC_ROWS) visible = WCC_ROWS;
for (int i = 0; i < visible; i++) {
uint8_t idx = _chIdx[_scrollTop + i];
int y = HEADER_H + i * ROW_H;
ChannelDetails ch;
if (!the_mesh.getChannel(idx, ch)) continue;
if (_scrollTop + i == _sel) {
d->setRawColor(0x231D); // same blue as the home tile
d->drawRoundRect(1, y, display.width() - 2, ROW_H - 2, 3);
}
d->setRawColor(0xFFFF);
char nameBuf[15];
strncpy(nameBuf, ch.name, sizeof(nameBuf) - 1);
nameBuf[sizeof(nameBuf) - 1] = 0;
display.setCursor(5, y + 2);
display.print(nameBuf);
snprintf(buf, sizeof(buf), "[%s] %s",
ch.scope_name[0] ? ch.scope_name : "*", notifLabel(idx));
d->setRawColor(0x7BEF);
d->printSmallFont(5, y + 13, buf);
}
// Footer: Back (left) and, when the list overflows a page, More (right).
d->setRawColor(0x231D);
d->drawRoundRect(1, FOOTER_Y, 58, 18, 3);
d->setRawColor(0xFFFF);
d->printSmallFont(20, FOOTER_Y + 8, "Back");
if (_numCh > WCC_ROWS) {
d->setRawColor(0x231D);
d->drawRoundRect(61, FOOTER_Y, 58, 18, 3);
d->setRawColor(0xC618);
d->printSmallFont(78, FOOTER_Y + 8, "More");
}
}
void renderDetail(DisplayDriver& display) {
LGFXDisplay* d = (LGFXDisplay*)&display;
char buf[44];
ChannelDetails ch;
if (_detail < 0 || !the_mesh.getChannel((uint8_t)_detail, ch)) {
_mode = LIST;
return;
}
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
char nameBuf[18];
strncpy(nameBuf, ch.name, sizeof(nameBuf) - 1);
nameBuf[sizeof(nameBuf) - 1] = 0;
display.drawTextCentered(display.width() / 2, 3, nameBuf);
// Region row -- opens the keyboard.
d->setRawColor(0x231D);
d->drawRoundRect(1, 16, display.width() - 2, 18, 3);
snprintf(buf, sizeof(buf), "Region: %s",
ch.scope_name[0] ? ch.scope_name : "(default)");
d->setRawColor(0xFFFF);
d->printSmallFont(6, 24, buf);
// Clear-region row -- dim when there is nothing to clear.
d->setRawColor(ch.scope_name[0] ? 0x231D : 0x18C3);
d->drawRoundRect(1, 36, display.width() - 2, 18, 3);
d->setRawColor(ch.scope_name[0] ? 0xC618 : 0x4208);
d->printSmallFont(6, 44, "Clear region (use default)");
// Notify row -- tap cycles All -> Mentions -> Off.
d->setRawColor(0x231D);
d->drawRoundRect(1, 56, display.width() - 2, 18, 3);
uint8_t n = _node_prefs->channel_notif[(uint8_t)_detail];
snprintf(buf, sizeof(buf), "Notify: %s",
(n == NOTIF_NONE) ? "Off" : (n == NOTIF_MENTIONS) ? "Mentions" : "All");
d->setRawColor(0xFFFF);
d->printSmallFont(6, 64, buf);
// Delete row -- status only; deletion is driven by the button (see
// handleInput). Channel 0 (public) cannot be deleted, same as desktop.
bool canDel = (_detail > 0);
d->setRawColor(canDel ? 0x231D : 0x18C3);
d->drawRoundRect(1, 76, display.width() - 2, 18, 3);
d->setRawColor(canDel ? (_confirmDelete ? 0xF800 : 0xC618) : 0x4208);
d->printSmallFont(6, 84, !canDel ? "Delete (public ch)"
: _confirmDelete ? "Delete? press again" : "Button: delete");
// Footer: Back to the list.
d->setRawColor(0x231D);
d->drawRoundRect(1, FOOTER_Y, 58, 18, 3);
d->setRawColor(0xFFFF);
d->printSmallFont(20, FOOTER_Y + 8, "Back");
}
public:
int render(DisplayDriver& display) override {
if (_confirmDelete && millis() - _confirmAt > 3000) _confirmDelete = false;
switch (_mode) {
case LIST: renderList(display); return 500;
case DETAIL: renderDetail(display); return 500;
}
return 500;
}
// Physical tap in logical coordinates. The top-strip home tap is handled by
// main.cpp before this is called. Returns true if the tap was consumed.
bool handleTap(int lx, int ly) {
if (_mode == LIST) {
if (ly >= FOOTER_Y) {
if (lx < 60) { // Back -> Settings
_wantExit = true;
} else if (_numCh > WCC_ROWS) { // More: next page (wraps)
_scrollTop += WCC_ROWS;
if (_scrollTop >= _numCh) _scrollTop = 0;
_sel = _scrollTop;
}
return true;
}
if (ly >= HEADER_H) {
int i = (ly - HEADER_H) / ROW_H;
int idx = _scrollTop + i;
if (i < WCC_ROWS && idx < _numCh) {
if (idx == _sel) { // second tap on the row: open it
_detail = _chIdx[idx];
_mode = DETAIL;
_confirmDelete = false;
} else {
_sel = idx; // first tap: select
ensureVisible();
}
return true;
}
}
return false;
}
// DETAIL
if (ly >= FOOTER_Y) { // Back -> list
_confirmDelete = false;
_mode = LIST;
rebuildList();
return true;
}
ChannelDetails ch;
if (_detail < 0 || !the_mesh.getChannel((uint8_t)_detail, ch)) {
_mode = LIST;
return true;
}
if (ly >= 16 && ly < 34) { // Region -> keyboard, pre-filled
strncpy(_editScope, ch.scope_name, sizeof(_editScope));
_editScope[30] = '\0';
_wantKeyboard = true;
return true;
}
if (ly >= 36 && ly < 54) { // Clear region
if (ch.scope_name[0]) {
ch.scope_name[0] = '\0';
the_mesh.setChannel((uint8_t)_detail, ch);
the_mesh.saveChannels();
Serial.printf("WatchChannelCfg: Channel %d scope cleared\n", _detail);
}
return true;
}
if (ly >= 56 && ly < 74) { // Notify: cycle All -> @ -> Off
uint8_t cur = _node_prefs->channel_notif[(uint8_t)_detail];
_node_prefs->channel_notif[(uint8_t)_detail] = (cur + 1) % 3;
the_mesh.savePrefs();
return true;
}
return true; // swallow other taps (incl. delete row)
}
bool handleInput(char c) override {
// The PMU short press (S3) arrives as KEY_ENTER; the S3 Plus boot button
// arrives as KEY_NEXT.
if (_mode == LIST && c == KEY_ENTER) {
if (_sel < _numCh) {
_detail = _chIdx[_sel];
_mode = DETAIL;
_confirmDelete = false;
}
return true;
}
if (_mode == DETAIL && (c == KEY_ENTER || c == KEY_NEXT)) {
// Button click deletes the open channel (channel 0 is protected):
// first press arms, a second press within 3s confirms. Back is the
// touch footer.
if (_detail > 0) {
if (_confirmDelete) {
deleteChannel((uint8_t)_detail);
_confirmDelete = false;
_detail = -1;
rebuildList();
_mode = LIST;
} else {
_confirmDelete = true;
_confirmAt = millis();
}
}
return true;
}
return false;
}
};
@@ -1,21 +0,0 @@
# Meck -- LilyGo T-Watch S3 Plus 16 MB custom partition layout
#
# Single factory app slot (no OTA): the watch has no SD card and does not
# define MECK_OTA_UPDATE, so no firmware-OTA path is compiled -- an OTA
# app1 slot would only waste flash. Maps get a dedicated LittleFS partition;
# the DataStore keeps its own SPIFFS partition.
#
# Both data partitions use the 'spiffs' subtype (the standard ESP32 data
# subtype). The DataStore is mounted as SPIFFS via SPIFFS.begin() (first
# spiffs-subtype partition by offset); maps is mounted as LittleFS by the
# explicit "maps" label, so the mount type is chosen at mount time, not by
# subtype.
#
# NOTE: flashing this shifts the layout and wipes the existing SPIFFS --
# re-provision identity/contacts after flashing.
#
# Name, Type, SubType, Offset, Size
nvs, data, nvs, 0x9000, 0x5000
app0, app, factory, 0x10000, 0x200000
spiffs, data, spiffs, 0x210000, 0x400000
maps, data, spiffs, 0x610000, 0x9F0000
Can't render this file because it contains an unexpected character in line 11 and column 12.
@@ -1,156 +0,0 @@
[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
board_build.partitions = variants/lilygo_twatch_s3_plus/meck_twatch_16mb.csv
build_flags =
${esp32_base.build_flags}
${sensor_base.build_flags}
-I variants/lilygo_twatch_s3_plus
; MECK_TWATCH gates the watch form factor (240x240, UI_ZOOM=2, touch, lock
; screen, tile grid, grey palette). LILYGO_TWATCH_S3_PLUS gates hardware that
; is specific to the Plus (onboard GNSS on BLDO1, GPIO0 user button).
-D MECK_TWATCH
-D LILYGO_TWATCH_S3_PLUS
-D TWATCH_COMPOSE_ENABLED
-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=3100
-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>
+<helpers/sensors/*.cpp>
lib_deps =
${esp32_base.lib_deps}
${sensor_base.lib_deps}
lovyan03/LovyanGFX @ ^1.2.0
lewisxhe/XPowersLib @ ^0.2.7
lewisxhe/SensorLib @ ^0.4.1
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_s3_plus_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 ESP32_CPU_FREQ=80
-D FIRMWARE_VERSION='"Meck TWatch v0.3"'
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
build_src_filter = ${LilyGo_TWatchS3Plus.build_src_filter}
+<helpers/esp32/*.cpp>
-<helpers/esp32/SerialBLEInterface.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/LGFXDisplay.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_s3_plus_ble -t upload
; ---------------------------------------------------------------------------
[env:meck_twatch_s3_plus_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.3"'
;-D MESH_PACKET_LOGGING=1
;-D MESH_DEBUG=1
build_src_filter = ${LilyGo_TWatchS3Plus.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/LGFXDisplay.cpp>
lib_deps =
${LilyGo_TWatchS3Plus.lib_deps}
densaugeo/base64 @ ~1.4.0
lib_ignore =
AsyncTCP
ESPAsyncWebServer
-72
View File
@@ -1,72 +0,0 @@
#include <Arduino.h>
#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 <= 9) ? 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() {
// Power-saving RX gain on the watch (deliberate): boosted gain costs
// ~0.6-0.7 mA continuously for ~+2 dB sensitivity. The SetRxGain write
// itself is what resets the AGC; false selects the power-saving value.
radio.setRxBoostedGainMode(false);
}
-47
View File
@@ -1,47 +0,0 @@
#pragma once
// Include variant.h first to ensure all board-specific defines are available
#include "variant.h"
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <TWatchS3PlusBoard.h>
#include <helpers/AutoDiscoverRTCClock.h>
#ifdef DISPLAY_CLASS
#include <TWatchS3PlusDisplay.h>
#include <helpers/ui/MomentaryButton.h>
#endif
#if HAS_GPS
#include "helpers/sensors/EnvironmentSensorManager.h"
#include "helpers/sensors/MicroNMEALocationProvider.h"
#include "GPSStreamCounter.h"
#else
#include <helpers/SensorManager.h>
#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();
-79
View File
@@ -1,79 +0,0 @@
#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