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
+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;
}
};