mirror of
https://github.com/pelgraine/Meck.git
synced 2026-08-09 10:12:42 +02:00
Remove T-Watch code from repo
This commit is contained in:
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"partitions": "default_16MB.csv",
|
||||
"memory_type": "qio_opi"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"psram_type": "opi",
|
||||
"hwids": [
|
||||
[
|
||||
"0x303A",
|
||||
"0x1001"
|
||||
]
|
||||
],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "esp32s3"
|
||||
},
|
||||
"connectivity": [
|
||||
"wifi",
|
||||
"bluetooth"
|
||||
],
|
||||
"debug": {
|
||||
"default_tool": "esp-builtin",
|
||||
"onboard_tools": [
|
||||
"esp-builtin"
|
||||
],
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": [
|
||||
"arduino",
|
||||
"espidf"
|
||||
],
|
||||
"name": "LilyGo T-Watch S3 (16M Flash 8M PSRAM)",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 16777216,
|
||||
"require_upload_port": true,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://www.lilygo.cc/products/t-watch-s3",
|
||||
"vendor": "LilyGo"
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"partitions": "default_16MB.csv",
|
||||
"memory_type": "qio_opi"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"psram_type": "opi",
|
||||
"hwids": [["0x303A", "0x1001"]],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "esp32s3"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth"],
|
||||
"debug": {
|
||||
"default_tool": "esp-builtin",
|
||||
"onboard_tools": ["esp-builtin"],
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "LilyGo T-Watch S3 Plus (16M Flash 8M PSRAM)",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 16777216,
|
||||
"require_upload_port": true,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://www.lilygo.cc/products/t-watch-s3-plus",
|
||||
"vendor": "LilyGo"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -56,28 +56,16 @@ void LGFXDisplay::setColor(Color c) {
|
||||
_color = TFT_WHITE;
|
||||
break;
|
||||
case RED:
|
||||
#if defined(MECK_TWATCH)
|
||||
_color = 0x7BEF; // dark grey -- the watch UI uses a grey/white/black palette
|
||||
#else
|
||||
_color = TFT_RED;
|
||||
#endif
|
||||
break;
|
||||
case GREEN:
|
||||
#if defined(MECK_TWATCH)
|
||||
_color = 0xC618; // light grey
|
||||
#else
|
||||
_color = TFT_GREEN;
|
||||
#endif
|
||||
break;
|
||||
case BLUE:
|
||||
_color = TFT_BLUE;
|
||||
break;
|
||||
case YELLOW:
|
||||
#if defined(MECK_TWATCH)
|
||||
_color = TFT_WHITE;
|
||||
#else
|
||||
_color = TFT_YELLOW;
|
||||
#endif
|
||||
break;
|
||||
case ORANGE:
|
||||
_color = TFT_ORANGE;
|
||||
|
||||
@@ -28,58 +28,6 @@ public:
|
||||
void startFrame(Color bkg = DARK) override;
|
||||
void setTextSize(int sz) override;
|
||||
void setColor(Color c) override;
|
||||
#if defined(MECK_TWATCH)
|
||||
// Set an exact RGB565 colour, bypassing the Color enum + watch grey remap.
|
||||
// Used by the watch P4-style tile grid for per-tile border/fill colours.
|
||||
void setRawColor(uint16_t c) { _color = c; buffer.setTextColor(c); }
|
||||
// Rounded-rect helpers for the P4-style tile grid (LovyanGFX buffer).
|
||||
void fillRoundRect(int x, int y, int w, int h, int r) { buffer.fillRoundRect(x, y, w, h, r, _color); }
|
||||
void drawRoundRect(int x, int y, int w, int h, int r) { buffer.drawRoundRect(x, y, w, h, r, _color); }
|
||||
// Plot a single pixel at an exact RGB565 colour directly into the sprite
|
||||
// buffer. Used by WatchMapScreen to draw decoded map tiles, markers and the
|
||||
// crosshair (parallels GxEPDDisplay::drawPixelRaw on the e-ink boards).
|
||||
void drawPixelRaw(int x, int y, uint16_t c) { buffer.drawPixel(x, y, c); }
|
||||
// Render a string in a very small font at (x,y) so long node names fit
|
||||
// beside the centred clock, then restore the default GLCD font + size so
|
||||
// later text is unaffected. Uses the colour set via setColor()/setRawColor().
|
||||
void printSmallFont(int x, int y, const char* str) {
|
||||
buffer.setFont(&fonts::TomThumb);
|
||||
buffer.setTextColor(_color);
|
||||
buffer.setTextSize(1);
|
||||
buffer.setCursor(x, y);
|
||||
buffer.print(str);
|
||||
buffer.setFont(&fonts::Font0); // restore default 6x8 GLCD font
|
||||
buffer.setTextSize(1);
|
||||
}
|
||||
// Width of a string in the tiny font (the font printSmallFont uses), for
|
||||
// right-aligning tiny-font header text. Restores the default font after.
|
||||
uint16_t smallTextWidth(const char* str) {
|
||||
buffer.setFont(&fonts::TomThumb);
|
||||
uint16_t w = buffer.textWidth(str);
|
||||
buffer.setFont(&fonts::Font0);
|
||||
return w;
|
||||
}
|
||||
// Enable/disable horizontal text wrap on the sprite buffer. The compose
|
||||
// ticker turns wrap off so a long scrolling line is clipped at the screen
|
||||
// edge instead of wrapping onto the rows below.
|
||||
void setTextWrap(bool en) { buffer.setTextWrap(en, false); }
|
||||
// Current print-cursor X on the sprite buffer -- lets a caller measure how
|
||||
// wide a just-printed row was (used by the settings row ticker).
|
||||
int getCursorX() { return buffer.getCursorX(); }
|
||||
// Draw a string centred horizontally at mid_x with its top at y, in
|
||||
// FreeSansBold 24pt (used for the clock HH:MM), then restore the default
|
||||
// GLCD font + size so later text is unaffected.
|
||||
void printClockFont(int mid_x, int y, const char* str) {
|
||||
buffer.setFont(&fonts::FreeSansBold24pt7b);
|
||||
buffer.setTextColor(_color);
|
||||
buffer.setTextSize(1);
|
||||
int w = buffer.textWidth(str);
|
||||
buffer.setCursor(mid_x - w / 2, y);
|
||||
buffer.print(str);
|
||||
buffer.setFont(&fonts::Font0); // restore default 6x8 GLCD font
|
||||
buffer.setTextSize(1);
|
||||
}
|
||||
#endif
|
||||
void setCursor(int x, int y) override;
|
||||
void print(const char* str) override;
|
||||
void fillRect(int x, int y, int w, int h) override;
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
// CPU Frequency Scaling for ESP32-S3
|
||||
//
|
||||
// Typical current draw (CPU only, rough):
|
||||
// 240 MHz ~70-80 mA
|
||||
// 160 MHz ~50-60 mA
|
||||
// 80 MHz ~30-40 mA
|
||||
// 40 MHz ~15-20 mA (low-power / lock screen mode)
|
||||
//
|
||||
// SPI peripherals and UART use their own clock dividers from the APB clock,
|
||||
// so LoRa, e-ink, and GPS serial all work fine at 80MHz and 40MHz.
|
||||
|
||||
#ifdef ESP32
|
||||
|
||||
#ifndef CPU_FREQ_IDLE
|
||||
#define CPU_FREQ_IDLE 80 // MHz — normal mesh listening
|
||||
#endif
|
||||
|
||||
#ifndef CPU_FREQ_BOOST
|
||||
#define CPU_FREQ_BOOST 80 // MHz -- S3: no map screen, so cap boost to idle to save power
|
||||
#endif
|
||||
|
||||
#ifndef CPU_FREQ_LOW_POWER
|
||||
#define CPU_FREQ_LOW_POWER 80 // MHz — lock screen / idle standby (40 MHz breaks I2C)
|
||||
#endif
|
||||
|
||||
#ifndef CPU_BOOST_TIMEOUT_MS
|
||||
#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds
|
||||
#endif
|
||||
|
||||
class CPUPowerManager {
|
||||
public:
|
||||
CPUPowerManager() : _boosted(false), _lowPower(false), _boost_started(0) {}
|
||||
|
||||
void begin() {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
_boosted = false;
|
||||
_lowPower = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (_boosted && (millis() - _boost_started >= CPU_BOOST_TIMEOUT_MS)) {
|
||||
// Return to low-power if locked, otherwise normal idle
|
||||
if (_lowPower) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_LOW_POWER);
|
||||
MESH_DEBUG_PRINTLN("CPU power: boost expired, returning to low-power %d MHz", CPU_FREQ_LOW_POWER);
|
||||
} else {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
_boosted = false;
|
||||
}
|
||||
}
|
||||
|
||||
void setBoost() {
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_BOOST);
|
||||
_boosted = true;
|
||||
MESH_DEBUG_PRINTLN("CPU power: boosted to %d MHz", CPU_FREQ_BOOST);
|
||||
}
|
||||
_boost_started = millis();
|
||||
}
|
||||
|
||||
void setIdle() {
|
||||
if (_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
_boosted = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
if (_lowPower) {
|
||||
_lowPower = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Low-power mode — drops CPU to 40 MHz for lock screen standby.
|
||||
// If currently boosted, the boost timeout will return to 40 MHz
|
||||
// instead of 80 MHz.
|
||||
void setLowPower() {
|
||||
_lowPower = true;
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_LOW_POWER);
|
||||
MESH_DEBUG_PRINTLN("CPU power: low-power at %d MHz", CPU_FREQ_LOW_POWER);
|
||||
}
|
||||
// If boosted, the loop() timeout will drop to low-power instead of idle
|
||||
}
|
||||
|
||||
// Exit low-power mode — returns to normal idle (80 MHz).
|
||||
// If currently boosted, the boost timeout will return to idle
|
||||
// instead of low-power.
|
||||
void clearLowPower() {
|
||||
_lowPower = false;
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz (low-power cleared)", CPU_FREQ_IDLE);
|
||||
}
|
||||
// If boosted, the loop() timeout will drop to idle as normal
|
||||
}
|
||||
|
||||
bool isBoosted() const { return _boosted; }
|
||||
bool isLowPower() const { return _lowPower; }
|
||||
uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); }
|
||||
|
||||
private:
|
||||
bool _boosted;
|
||||
bool _lowPower;
|
||||
unsigned long _boost_started;
|
||||
};
|
||||
|
||||
#endif // ESP32
|
||||
@@ -1,92 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
|
||||
// Minimal inline DRV2605 haptic driver for the LilyGo T-Watch S3.
|
||||
//
|
||||
// Copied from variants/lilygo_tdeck_max/DRV2605Haptic.h so that the MAX build is
|
||||
// untouched. Register sequence is identical; only the power note differs.
|
||||
//
|
||||
// The DRV2605L (schematic sheet 3, U47) sits on the shared Wire bus at I2C 0x5A
|
||||
// and drives the 0827 coin motor on J11. Unlike the T-Deck Pro MAX, whose motor
|
||||
// supply is gated by an XL9555 expander pin (board.motorEnable()), the watch
|
||||
// powers the DRV2605 from the AXP2101 BLDO2 rail, which TWatchS3Board::power_init()
|
||||
// already brings up at 3300 mV. There is therefore nothing to enable before
|
||||
// begin(); if it does not ACK, BLDO2 is off or the I2C bus is not up yet.
|
||||
//
|
||||
// The register sequence mirrors Adafruit_DRV2605::begin() followed by
|
||||
// selectLibrary(1) + setMode(INTTRIG) - i.e. exactly what the LilyGo basic.ino
|
||||
// example does: an ERM motor, open-loop, internal-trigger, effect library 1.
|
||||
class DRV2605Haptic {
|
||||
public:
|
||||
DRV2605Haptic(uint8_t addr = 0x5A, TwoWire* wire = &Wire)
|
||||
: _addr(addr), _wire(wire) {}
|
||||
|
||||
// Returns false if the DRV2605 does not ACK (e.g. BLDO2 still off).
|
||||
bool begin() {
|
||||
_wire->beginTransmission(_addr);
|
||||
if (_wire->endTransmission() != 0) return false;
|
||||
|
||||
writeReg(REG_MODE, 0x00); // exit standby, internal trigger
|
||||
writeReg(REG_RTPIN, 0x00); // no real-time playback input
|
||||
writeReg(REG_WAVESEQ1, 1); // default effect: strong click
|
||||
writeReg(REG_WAVESEQ2, 0); // end of sequence
|
||||
writeReg(REG_OVERDRIVE, 0);
|
||||
writeReg(REG_SUSTAINPOS, 0);
|
||||
writeReg(REG_SUSTAINNEG, 0);
|
||||
writeReg(REG_BREAK, 0);
|
||||
writeReg(REG_AUDIOMAX, 0x64);
|
||||
writeReg(REG_FEEDBACK, readReg(REG_FEEDBACK) & 0x7F); // N_ERM_LRA = 0 -> ERM
|
||||
writeReg(REG_CONTROL3, readReg(REG_CONTROL3) | 0x20); // ERM_OPEN_LOOP = 1
|
||||
writeReg(REG_LIBRARY, 1); // ERM effect library 1
|
||||
writeReg(REG_MODE, 0x00); // internal trigger
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fire a single library effect (1 = strong click, 100%).
|
||||
void buzz(uint8_t effect = 1) {
|
||||
writeReg(REG_WAVESEQ1, effect);
|
||||
writeReg(REG_WAVESEQ2, 0);
|
||||
writeReg(REG_GO, 1);
|
||||
}
|
||||
|
||||
// Return the DRV2605 to standby (~1.8uA). begin() must be run again before the
|
||||
// next buzz -- a GO in standby is ignored -- so callers reset their ready flag.
|
||||
void standby() {
|
||||
writeReg(REG_MODE, 0x40); // STANDBY = 1, internal trigger
|
||||
}
|
||||
|
||||
private:
|
||||
static const uint8_t REG_MODE = 0x01;
|
||||
static const uint8_t REG_RTPIN = 0x02;
|
||||
static const uint8_t REG_LIBRARY = 0x03;
|
||||
static const uint8_t REG_WAVESEQ1 = 0x04;
|
||||
static const uint8_t REG_WAVESEQ2 = 0x05;
|
||||
static const uint8_t REG_GO = 0x0C;
|
||||
static const uint8_t REG_OVERDRIVE = 0x0D;
|
||||
static const uint8_t REG_SUSTAINPOS = 0x0E;
|
||||
static const uint8_t REG_SUSTAINNEG = 0x0F;
|
||||
static const uint8_t REG_BREAK = 0x10;
|
||||
static const uint8_t REG_AUDIOMAX = 0x13;
|
||||
static const uint8_t REG_FEEDBACK = 0x1A;
|
||||
static const uint8_t REG_CONTROL3 = 0x1D;
|
||||
|
||||
uint8_t _addr;
|
||||
TwoWire* _wire;
|
||||
|
||||
void writeReg(uint8_t reg, uint8_t val) {
|
||||
_wire->beginTransmission(_addr);
|
||||
_wire->write(reg);
|
||||
_wire->write(val);
|
||||
_wire->endTransmission();
|
||||
}
|
||||
|
||||
uint8_t readReg(uint8_t reg) {
|
||||
_wire->beginTransmission(_addr);
|
||||
_wire->write(reg);
|
||||
_wire->endTransmission(false);
|
||||
_wire->requestFrom(_addr, (uint8_t)1);
|
||||
return _wire->available() ? _wire->read() : 0;
|
||||
}
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <helpers/ui/MomentaryButton.h> // BUTTON_EVENT_* constants
|
||||
#include "TWatchS3Board.h"
|
||||
|
||||
// PMUButton -- a MomentaryButton work-alike for the LilyGo T-Watch S3, whose
|
||||
// only control is the side PWR key. That key is not a GPIO: schematic sheet 1
|
||||
// wires SW7 to net PWR_KEY, which lands on AXP2101 pin 30 (PWRON). Presses are
|
||||
// therefore reported as PMU interrupts and read back over I2C.
|
||||
//
|
||||
// It exposes the three methods UITask uses on `user_btn` (begin, isPressed,
|
||||
// check), so it drops into target.h in place of MomentaryButton.
|
||||
//
|
||||
// Event mapping, set up in TWatchS3Board::power_init():
|
||||
// press < 1s PKEY_SHORT_IRQ -> BUTTON_EVENT_CLICK
|
||||
// 1s <= press < 6s PKEY_LONG_IRQ masked; no event
|
||||
// press >= 6s hardware power-off never reaches firmware
|
||||
//
|
||||
// BUTTON_EVENT_LONG_PRESS, _DOUBLE_CLICK and _TRIPLE_CLICK are never returned:
|
||||
// the long hold belongs to the PMU's power-off, and the AXP2101 reports no
|
||||
// multi-click.
|
||||
//
|
||||
// The IRQ line (PIN_PMU_IRQ, GPIO21) is not used. check() polls the PMU's
|
||||
// latched status registers directly, which removes any dependence on that pin
|
||||
// being wired, pulled up, or serviced by an ISR. Three I2C register reads every
|
||||
// POLL_INTERVAL_MS is negligible on a bus already shared with the RTC and
|
||||
// accelerometer. Note that clearIrqStatus() is write-1-to-clear across all
|
||||
// three status registers, so an edge landing inside the ~200us between the read
|
||||
// and the clear is dropped; press and release are ~100ms apart, so this cannot
|
||||
// swallow a real event.
|
||||
class PMUButton {
|
||||
TWatchS3Board& _board;
|
||||
unsigned long _last_poll = 0;
|
||||
bool _down = false;
|
||||
|
||||
static const unsigned long POLL_INTERVAL_MS = 30;
|
||||
|
||||
public:
|
||||
PMUButton(TWatchS3Board& board) : _board(board) {}
|
||||
|
||||
// The PWRON key needs no pin setup; the PMU is configured in power_init(),
|
||||
// which board.begin() runs before UITask::begin() reaches here.
|
||||
void begin() {}
|
||||
|
||||
// True while the key is held down. Updated by check(), so it is at most
|
||||
// POLL_INTERVAL_MS stale. UITask uses this to abort a pending shutdown.
|
||||
bool isPressed() const { return _down; }
|
||||
|
||||
int check() {
|
||||
if (millis() - _last_poll < POLL_INTERVAL_MS) return BUTTON_EVENT_NONE;
|
||||
_last_poll = millis();
|
||||
|
||||
XPowersAXP2101* pmu = _board.getPMU();
|
||||
if (pmu == NULL) return BUTTON_EVENT_NONE;
|
||||
|
||||
pmu->getIrqStatus(); // latch INTSTS1..3 into the driver's buffer
|
||||
|
||||
// Apply the edges before the click, so that a poll which catches press,
|
||||
// release and short-press together still leaves _down false.
|
||||
if (pmu->isPekeyNegativeIrq()) _down = true; // PWRON is active low: press
|
||||
if (pmu->isPekeyPositiveIrq()) _down = false; // release
|
||||
|
||||
int ev = pmu->isPekeyShortPressIrq() ? BUTTON_EVENT_CLICK : BUTTON_EVENT_NONE;
|
||||
|
||||
pmu->clearIrqStatus();
|
||||
return ev;
|
||||
}
|
||||
};
|
||||
@@ -1,795 +0,0 @@
|
||||
#pragma once
|
||||
// =============================================================================
|
||||
// TWatchComposeScreens — touch keyboard compose system for the LilyGo
|
||||
// T-Watch S3 Plus (ST7789 240x240, capacitive touch, companion_radio ui-new).
|
||||
//
|
||||
// Three UIScreen subclasses, compiled only when TWATCH_COMPOSE_ENABLED is
|
||||
// defined (companion build only). All drawing and hit-testing is in the
|
||||
// LGFXDisplay virtual coordinate space, which is 120x120 on this build
|
||||
// (240px panel / UI_ZOOM=2). getTouch() returns coords already divided by
|
||||
// UI_ZOOM, so a tap coordinate is directly comparable to draw coordinates.
|
||||
//
|
||||
// TWatchChannelPicker -- long-press on the home screen opens this. A single
|
||||
// tap selects the channel row under the finger; a
|
||||
// long-press on a row opens it; the back arrow
|
||||
// (top-left) returns home.
|
||||
// TWatchChannelScreen -- shows the most recent messages (sent + received)
|
||||
// for the selected channel, read live from the shared
|
||||
// ChannelScreen history store. Tap a line to ticker-
|
||||
// scroll it; tap the compose bar to open the keyboard;
|
||||
// back arrow returns home.
|
||||
// TWatchKeyboardScreen — on-screen QWERTY. Bottom-left mode key cycles
|
||||
// lower -> UPPER -> SYM. Bottom row is mode | space |
|
||||
// enter | backspace. Enter sends on the channel; back
|
||||
// arrow returns home.
|
||||
//
|
||||
// Each screen reads getTouch() itself in poll() and does its own release-edge
|
||||
// detection, so actions fire on touch release (finger up) — this keeps the
|
||||
// held finger from carrying into the next screen after a transition. Send/exit
|
||||
// are delegated to UITask via the consume/flag pattern; message history and
|
||||
// unread counts are read from the shared ChannelScreen store.
|
||||
// =============================================================================
|
||||
|
||||
#ifdef TWATCH_COMPOSE_ENABLED
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <string.h>
|
||||
#include <helpers/ui/LGFXDisplay.h>
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include "ChannelScreen.h" // shared message history store (-I examples/companion_radio/ui-new)
|
||||
|
||||
// ---- Tunables ---------------------------------------------------------------
|
||||
#define TW_LONG_PRESS_MS 600 // hold to select a channel in the picker
|
||||
#define TW_OUT_BUF_LEN 134 // MeshCore per-channel msg cap (~133) + NUL
|
||||
#define TW_TICKER_MS_PER_PX 20 // channel-screen ticker scroll speed (ms per pixel)
|
||||
#define TW_CH_NAME_LEN 32
|
||||
#define TW_PICKER_MAX 20 // matches MAX_GROUP_CHANNELS
|
||||
|
||||
// =============================================================================
|
||||
// TWatchChannelPicker
|
||||
// =============================================================================
|
||||
class TWatchChannelPicker : public UIScreen {
|
||||
DisplayDriver* _display;
|
||||
|
||||
struct Entry { uint8_t idx; char name[TW_CH_NAME_LEN]; };
|
||||
Entry _channels[TW_PICKER_MAX];
|
||||
uint8_t _numChannels;
|
||||
uint8_t _highlighted;
|
||||
uint8_t _scrollTop;
|
||||
ChannelScreen* _store; // unread badge source
|
||||
|
||||
// touch edge state
|
||||
bool _touchDown;
|
||||
int _downX, _downY;
|
||||
unsigned long _downAt;
|
||||
|
||||
// cross-screen flags (UITask polls these)
|
||||
bool _confirmed;
|
||||
bool _wantsExit;
|
||||
|
||||
static const int HEADER_H = 14;
|
||||
static const int ROW_H = 14;
|
||||
|
||||
int visibleRows() const {
|
||||
int h = _display ? _display->height() : 120;
|
||||
int rows = (h - HEADER_H) / ROW_H;
|
||||
return rows < 1 ? 1 : rows;
|
||||
}
|
||||
void ensureVisible() {
|
||||
int vis = visibleRows();
|
||||
if (_highlighted < _scrollTop) _scrollTop = _highlighted;
|
||||
else if (_highlighted >= _scrollTop + vis) _scrollTop = (uint8_t)(_highlighted - vis + 1);
|
||||
}
|
||||
void moveUp() {
|
||||
if (_numChannels == 0) return;
|
||||
_highlighted = (_highlighted == 0) ? (uint8_t)(_numChannels - 1) : (uint8_t)(_highlighted - 1);
|
||||
ensureVisible();
|
||||
}
|
||||
void moveDown() {
|
||||
if (_numChannels == 0) return;
|
||||
_highlighted = (uint8_t)((_highlighted + 1) % _numChannels);
|
||||
ensureVisible();
|
||||
}
|
||||
// Map a draw-space y coordinate to a channel index, or -1 if the tap is
|
||||
// above the list or on an empty row. getTouch() returns coords already
|
||||
// divided by UI_ZOOM, so _downY is directly comparable to the row layout.
|
||||
int rowAtY(int y) const {
|
||||
if (y < HEADER_H) return -1;
|
||||
int i = (y - HEADER_H) / ROW_H;
|
||||
if (i < 0 || i >= visibleRows()) return -1;
|
||||
int ci = _scrollTop + i;
|
||||
if (ci >= _numChannels) return -1;
|
||||
return ci;
|
||||
}
|
||||
|
||||
public:
|
||||
TWatchChannelPicker(DisplayDriver* display)
|
||||
: _display(display), _numChannels(0), _highlighted(0), _scrollTop(0),
|
||||
_store(nullptr),
|
||||
_touchDown(false), _downX(0), _downY(0), _downAt(0),
|
||||
_confirmed(false), _wantsExit(false) {
|
||||
memset(_channels, 0, sizeof(_channels));
|
||||
}
|
||||
|
||||
// Shared history store, used for the per-channel unread badges.
|
||||
void setStore(ChannelScreen* store) { _store = store; }
|
||||
|
||||
// Called by UITask before showing the screen.
|
||||
void beginChannelSelect() {
|
||||
_numChannels = 0; _highlighted = 0; _scrollTop = 0;
|
||||
_touchDown = false; _confirmed = false; _wantsExit = false;
|
||||
}
|
||||
void addChannel(uint8_t idx, const char* name) {
|
||||
if (_numChannels >= TW_PICKER_MAX) return;
|
||||
_channels[_numChannels].idx = idx;
|
||||
strncpy(_channels[_numChannels].name, name ? name : "", TW_CH_NAME_LEN - 1);
|
||||
_channels[_numChannels].name[TW_CH_NAME_LEN - 1] = 0;
|
||||
_numChannels++;
|
||||
}
|
||||
|
||||
// UITask bridges
|
||||
bool isConfirmed() const { return _confirmed; }
|
||||
void acknowledgeConfirm() { _confirmed = false; }
|
||||
uint8_t getSelectedChannelIdx() const { return _channels[_highlighted].idx; }
|
||||
const char* getSelectedChannelName() const { return _channels[_highlighted].name; }
|
||||
bool wantsExit() const { return _wantsExit; }
|
||||
void acknowledgeExit() { _wantsExit = false; }
|
||||
|
||||
bool handleInput(char c) override { return false; } // touch handled in poll()
|
||||
|
||||
void poll() override {
|
||||
if (!_display) return;
|
||||
int x, y;
|
||||
bool now = ((LGFXDisplay*)_display)->getTouch(&x, &y);
|
||||
if (now && !_touchDown) {
|
||||
_touchDown = true; _downX = x; _downY = y; _downAt = millis();
|
||||
} else if (!now && _touchDown) {
|
||||
_touchDown = false;
|
||||
unsigned long held = millis() - _downAt;
|
||||
if (_downX < 20 && _downY < HEADER_H) { _wantsExit = true; return; } // back arrow
|
||||
int row = rowAtY(_downY);
|
||||
if (row < 0) return; // tap outside the channel list
|
||||
_highlighted = (uint8_t)row; // single tap selects the tapped row
|
||||
ensureVisible();
|
||||
if (held >= TW_LONG_PRESS_MS) _confirmed = true; // long press opens it
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
const int W = display.width();
|
||||
display.setTextSize(1);
|
||||
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, 0);
|
||||
display.print("<");
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(W / 2, 0, "CHANNELS");
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, HEADER_H - 2, W, 1);
|
||||
|
||||
if (_numChannels == 0) {
|
||||
display.setCursor(2, HEADER_H + 2);
|
||||
display.print("(no channels)");
|
||||
return 500;
|
||||
}
|
||||
|
||||
int vis = visibleRows();
|
||||
int y = HEADER_H;
|
||||
for (int i = 0; i < vis; i++) {
|
||||
int ci = _scrollTop + i;
|
||||
if (ci >= _numChannels) break;
|
||||
if (ci == _highlighted) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.fillRect(0, y, W, ROW_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
int nameMaxW = W - 6;
|
||||
int unread = _store ? _store->getUnreadForChannel(_channels[ci].idx) : 0;
|
||||
if (unread > 0) {
|
||||
char cnt[8];
|
||||
snprintf(cnt, sizeof(cnt), "%d", unread > 99 ? 99 : unread);
|
||||
int cw = display.getTextWidth(cnt);
|
||||
display.setColor((ci == _highlighted) ? DisplayDriver::DARK : DisplayDriver::BLUE);
|
||||
display.drawTextRightAlign(W - 3, y + 2, cnt);
|
||||
display.setColor((ci == _highlighted) ? DisplayDriver::DARK : DisplayDriver::LIGHT);
|
||||
nameMaxW = W - 6 - cw - 4;
|
||||
}
|
||||
display.drawTextEllipsized(3, y + 2, nameMaxW, _channels[ci].name);
|
||||
y += ROW_H;
|
||||
}
|
||||
return 500;
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// TWatchChannelScreen
|
||||
// =============================================================================
|
||||
class TWatchChannelScreen : public UIScreen {
|
||||
DisplayDriver* _display;
|
||||
ChannelScreen* _store; // shared message history store
|
||||
uint8_t _channelIdx;
|
||||
char _channelName[TW_CH_NAME_LEN];
|
||||
char _selfPrefix[TW_CH_NAME_LEN + 2]; // "NodeName: " -- marks sent lines
|
||||
|
||||
bool _touchDown;
|
||||
int _downX, _downY;
|
||||
|
||||
bool _wantsCompose;
|
||||
bool _wantsExit;
|
||||
|
||||
int _selectedN; // recency index shown as ticker, -1 = none
|
||||
unsigned long _tickerStartMs;
|
||||
const void* _lastNewest; // newest store entry seen last render
|
||||
uint32_t _lastNewestTs; // (a change dismisses the ticker)
|
||||
bool _showPath; // hop-path overlay visible
|
||||
int _pathMsgN; // recency index the overlay describes
|
||||
|
||||
static const int HEADER_H = 14;
|
||||
static const int COMPOSE_BAR_H = 18;
|
||||
static const int MSG_LINE_H = 11;
|
||||
static const int MSG_TOP = HEADER_H + 2;
|
||||
|
||||
int rowsThatFit() const {
|
||||
int h = _display ? _display->height() : 120;
|
||||
int rows = (h - COMPOSE_BAR_H - 1 - MSG_TOP) / MSG_LINE_H;
|
||||
return rows < 1 ? 1 : rows;
|
||||
}
|
||||
|
||||
// Store messages available for this channel, capped at what fits on screen.
|
||||
int visibleCount() const {
|
||||
if (!_store) return 0;
|
||||
int maxRows = rowsThatFit();
|
||||
int n = 0;
|
||||
while (n < maxRows && _store->getChannelMsgByRecency(_channelIdx, n)) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Sent messages are stored with path_len 0 and a "NodeName: " prefix.
|
||||
bool isSent(const ChannelScreen::ChannelMessage* m) const {
|
||||
return m->path_len == 0 && _selfPrefix[0] != 0 &&
|
||||
strncmp(m->text, _selfPrefix, strlen(_selfPrefix)) == 0;
|
||||
}
|
||||
|
||||
void drawMsgLine(DisplayDriver& display, int y, const char* text, bool sent) {
|
||||
const int W = display.width();
|
||||
const int maxW = W - 4;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
if (!sent) {
|
||||
display.drawTextEllipsized(2, y, maxW, text); // incoming: left-aligned
|
||||
return;
|
||||
}
|
||||
if (display.getTextWidth(text) <= maxW) { // sent: right-aligned
|
||||
display.drawTextRightAlign(W - 2, y, text);
|
||||
return;
|
||||
}
|
||||
char buf[CHANNEL_MSG_TEXT_LEN + 4];
|
||||
strncpy(buf, text, sizeof(buf) - 4);
|
||||
buf[sizeof(buf) - 4] = 0;
|
||||
int ellW = display.getTextWidth("...");
|
||||
int len = (int)strlen(buf);
|
||||
while (len > 0 && display.getTextWidth(buf) > maxW - ellW) { buf[--len] = 0; }
|
||||
strcat(buf, "...");
|
||||
display.drawTextRightAlign(W - 2, y, buf);
|
||||
}
|
||||
|
||||
void drawTicker(DisplayDriver& display, int y, const char* text, bool sent) {
|
||||
const int W = display.width();
|
||||
int textW = display.getTextWidth(text);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
if (textW <= W - 4) { // fits -> nothing to scroll, keep alignment
|
||||
if (sent) display.drawTextRightAlign(W - 2, y, text);
|
||||
else { display.setCursor(2, y); display.print(text); }
|
||||
return;
|
||||
}
|
||||
int period = textW + 24; // full text width + trailing gap
|
||||
unsigned long elapsed = millis() - _tickerStartMs;
|
||||
int off = (int)((elapsed / TW_TICKER_MS_PER_PX) % (unsigned long)period);
|
||||
// Wrap off while the marquee draws: a long line must clip at the screen
|
||||
// edge, not wrap onto the rows below. Restored straight after.
|
||||
((LGFXDisplay*)_display)->setTextWrap(false);
|
||||
display.setCursor(2 - off, y);
|
||||
display.print(text);
|
||||
display.setCursor(2 - off + period, y);
|
||||
display.print(text);
|
||||
((LGFXDisplay*)_display)->setTextWrap(true);
|
||||
}
|
||||
|
||||
// Renders the "Heard by" list (repeaters that echoed our sent message + the
|
||||
// SNR we heard each at) into the already-drawn overlay box. Data comes from
|
||||
// MyMesh::getHeardBy, keyed by the fingerprint stashed in the sent bubble's
|
||||
// path[]. Same blue box as the hop-path overlay.
|
||||
void drawHeardBy(LGFXDisplay* d, const ChannelScreen::ChannelMessage* m,
|
||||
int bx, int by, int bh) {
|
||||
uint8_t bph = 0, count = 0;
|
||||
uint8_t hashes[16];
|
||||
int8_t snrs[8];
|
||||
(void)bh;
|
||||
the_mesh.getHeardBy(m->path, 12, bph, count, hashes, snrs, 8);
|
||||
|
||||
char head[28];
|
||||
snprintf(head, sizeof(head), "Heard by: %d", count); // distinct repeaters
|
||||
d->setRawColor(0x7BEF);
|
||||
d->printSmallFont(bx + 5, by + 8, head);
|
||||
|
||||
if (count == 0 || bph == 0) {
|
||||
d->setRawColor(0xFFFF);
|
||||
d->printSmallFont(bx + 5, by + 19, "No repeater echoes yet");
|
||||
return;
|
||||
}
|
||||
|
||||
const int maxLines = 5; // 5 repeaters, then a "+N more" 6th line
|
||||
int shown = (count > maxLines) ? maxLines : count;
|
||||
int y = by + 19;
|
||||
for (int i = 0; i < shown; i++) {
|
||||
const int off = i * bph;
|
||||
char hex[6];
|
||||
if (bph == 1) snprintf(hex, sizeof(hex), "%02x", hashes[off]);
|
||||
else snprintf(hex, sizeof(hex), "%02x%02x", hashes[off], hashes[off + 1]);
|
||||
|
||||
char nm[16]; nm[0] = 0;
|
||||
uint32_t numC = the_mesh.getNumContacts();
|
||||
ContactInfo c;
|
||||
for (int pass = 0; pass < 2 && nm[0] == 0; pass++) {
|
||||
for (uint32_t ci = 0; ci < numC; ci++) {
|
||||
if (!the_mesh.getContactByIdx(ci, c)) continue;
|
||||
if (memcmp(c.id.pub_key, &hashes[off], bph) != 0) continue;
|
||||
if (pass == 0 && c.type != ADV_TYPE_REPEATER) continue;
|
||||
strncpy(nm, c.name, sizeof(nm) - 1); nm[sizeof(nm) - 1] = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int8_t s = snrs[i];
|
||||
char line[40];
|
||||
snprintf(line, sizeof(line), "(%s) %s %d.%ddB",
|
||||
hex, nm[0] ? nm : "?", s / 4, ((abs((int)s) % 4) * 10) / 4);
|
||||
d->setRawColor(0xFFFF);
|
||||
d->printSmallFont(bx + 5, y, line);
|
||||
y += 9;
|
||||
}
|
||||
if (count > shown) {
|
||||
d->setRawColor(0xFFFF);
|
||||
char more[16];
|
||||
snprintf(more, sizeof(more), "+%d more", count - shown);
|
||||
d->printSmallFont(bx + 5, y, more);
|
||||
}
|
||||
}
|
||||
|
||||
// Hop-path overlay for the message at recency _pathMsgN. Drawn last so it
|
||||
// sits above the message list; toggled by the button (see handleInput),
|
||||
// dismissed by any tap (see poll).
|
||||
void drawPathOverlay(DisplayDriver& display) {
|
||||
const ChannelScreen::ChannelMessage* m =
|
||||
_store ? _store->getChannelMsgByRecency(_channelIdx, _pathMsgN) : nullptr;
|
||||
if (!m) { _showPath = false; return; }
|
||||
|
||||
LGFXDisplay* d = (LGFXDisplay*)&display;
|
||||
const int bx = 3, bw = display.width() - 6, by = 18, bh = 76;
|
||||
d->setRawColor(0x0000);
|
||||
d->fillRoundRect(bx, by, bw, bh, 4);
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(bx, by, bw, bh, 4);
|
||||
|
||||
// Sent message with a captured fingerprint: show which repeaters echoed it
|
||||
// ("Heard by") instead of a hop path, which a sent bubble doesn't have.
|
||||
if (m->path_len == 0 && m->has_fp) {
|
||||
drawHeardBy(d, m, bx, by, bh);
|
||||
return;
|
||||
}
|
||||
|
||||
char head[28];
|
||||
if (m->path_len == 0) {
|
||||
snprintf(head, sizeof(head), "%s", isSent(m) ? "Sent (no path)" : "Direct (0 hops)");
|
||||
} else if (m->path_len == 0xFF) {
|
||||
snprintf(head, sizeof(head), "%s", "Flood (no path)");
|
||||
} else {
|
||||
snprintf(head, sizeof(head), "%d hops, %d-byte",
|
||||
m->path_len & 63, (m->path_len >> 6) + 1);
|
||||
}
|
||||
d->setRawColor(0x7BEF);
|
||||
d->printSmallFont(bx + 5, by + 8, head);
|
||||
|
||||
if (m->path_len == 0 || m->path_len == 0xFF) return;
|
||||
|
||||
int byteLen = (int)mesh::Packet::getPathByteLenFor(m->path_len);
|
||||
if (byteLen > MSG_PATH_MAX) byteLen = MSG_PATH_MAX;
|
||||
int bph = (m->path_len >> 6) + 1;
|
||||
|
||||
// Hex hops in the path-editor format ("3601,2198,..."), wrapped
|
||||
// token-wise across small-font lines.
|
||||
char line[36];
|
||||
int ll = 0, y = by + 19;
|
||||
d->setRawColor(0xFFFF);
|
||||
for (int b = 0; b < byteLen; b += bph) {
|
||||
char tok[9];
|
||||
int tl = 0;
|
||||
for (int k = 0; k < bph && b + k < byteLen; k++)
|
||||
tl += snprintf(tok + tl, sizeof(tok) - tl, "%02x", m->path[b + k]);
|
||||
if (b + bph < byteLen) { tok[tl++] = ','; tok[tl] = 0; }
|
||||
if (ll + tl > 26) {
|
||||
line[ll] = 0;
|
||||
d->printSmallFont(bx + 5, y, line);
|
||||
y += 9; ll = 0;
|
||||
if (y > by + bh - 8) { d->printSmallFont(bx + 5, y, "..."); return; }
|
||||
}
|
||||
memcpy(line + ll, tok, tl); ll += tl;
|
||||
}
|
||||
if (ll > 0) { line[ll] = 0; d->printSmallFont(bx + 5, y, line); }
|
||||
}
|
||||
|
||||
public:
|
||||
TWatchChannelScreen(DisplayDriver* display)
|
||||
: _display(display), _store(nullptr), _channelIdx(0),
|
||||
_touchDown(false), _downX(0), _downY(0),
|
||||
_wantsCompose(false), _wantsExit(false),
|
||||
_selectedN(-1), _tickerStartMs(0),
|
||||
_lastNewest(nullptr), _lastNewestTs(0),
|
||||
_showPath(false), _pathMsgN(0) {
|
||||
_channelName[0] = 0;
|
||||
_selfPrefix[0] = 0;
|
||||
}
|
||||
|
||||
// Shared history store this screen renders from.
|
||||
void setStore(ChannelScreen* store) { _store = store; }
|
||||
|
||||
// Own node name, used to right-align sent lines ("NodeName: text").
|
||||
void setSelfName(const char* name) {
|
||||
if (!name || !name[0]) { _selfPrefix[0] = 0; return; }
|
||||
snprintf(_selfPrefix, sizeof(_selfPrefix), "%s: ", name);
|
||||
}
|
||||
|
||||
// Switch to a channel (called when a channel is selected in the picker).
|
||||
// Opening a channel marks its messages as read.
|
||||
void activate(uint8_t idx, const char* name) {
|
||||
_channelIdx = idx;
|
||||
strncpy(_channelName, name ? name : "", TW_CH_NAME_LEN - 1);
|
||||
_channelName[TW_CH_NAME_LEN - 1] = 0;
|
||||
_touchDown = false; _wantsCompose = false; _wantsExit = false;
|
||||
_selectedN = -1;
|
||||
_showPath = false;
|
||||
_lastNewest = nullptr; _lastNewestTs = 0;
|
||||
if (_store) _store->markChannelRead(_channelIdx);
|
||||
}
|
||||
|
||||
uint8_t getChannelIdx() const { return _channelIdx; }
|
||||
const char* getChannelName() const { return _channelName; }
|
||||
|
||||
bool wantsCompose() const { return _wantsCompose; }
|
||||
void acknowledgeCompose() { _wantsCompose = false; }
|
||||
bool wantsExit() const { return _wantsExit; }
|
||||
void acknowledgeExit() { _wantsExit = false; }
|
||||
|
||||
// Short press of the PWR key (S3, KEY_ENTER) or the boot button (S3 Plus,
|
||||
// KEY_NEXT): toggle the hop-path overlay for the ticker-selected message
|
||||
// (the newest one if none is selected). Touch is handled in poll().
|
||||
bool handleInput(char c) override {
|
||||
if (c == KEY_ENTER || c == KEY_NEXT) {
|
||||
if (_showPath) { _showPath = false; return true; }
|
||||
int n = (_selectedN >= 0) ? _selectedN : 0;
|
||||
if (_store && _store->getChannelMsgByRecency(_channelIdx, n)) {
|
||||
_pathMsgN = n;
|
||||
_showPath = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void poll() override {
|
||||
if (!_display) return;
|
||||
int x, y;
|
||||
bool now = ((LGFXDisplay*)_display)->getTouch(&x, &y);
|
||||
if (now && !_touchDown) {
|
||||
_touchDown = true; _downX = x; _downY = y;
|
||||
} else if (!now && _touchDown) {
|
||||
_touchDown = false;
|
||||
int H = _display->height();
|
||||
if (_showPath) { _showPath = false; return; } // any tap dismisses the path overlay
|
||||
if (_downX < 20 && _downY < HEADER_H) { _selectedN = -1; _wantsExit = true; return; } // back arrow
|
||||
if (_downY >= H - COMPOSE_BAR_H) { _selectedN = -1; _wantsCompose = true; return; } // compose bar
|
||||
// message area -> tap to open/close ticker
|
||||
if (_downY >= MSG_TOP && _downY < H - COMPOSE_BAR_H) {
|
||||
int visualRow = (_downY - MSG_TOP) / MSG_LINE_H;
|
||||
int count = visibleCount();
|
||||
if (visualRow >= 0 && visualRow < count) {
|
||||
int n = (count - 1) - visualRow; // top row = oldest visible
|
||||
if (_selectedN == n) _selectedN = -1;
|
||||
else { _selectedN = n; _tickerStartMs = millis(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
const int W = display.width();
|
||||
const int H = display.height();
|
||||
display.setTextSize(1);
|
||||
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, 0);
|
||||
display.print("<");
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(W / 2, 0, _channelName);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, HEADER_H - 2, W, 1);
|
||||
|
||||
// A new message (sent or received) shifts the rows, so dismiss any open
|
||||
// ticker when the newest store entry for this channel changes.
|
||||
const ChannelScreen::ChannelMessage* newest =
|
||||
_store ? _store->getChannelMsgByRecency(_channelIdx, 0) : nullptr;
|
||||
if (newest != _lastNewest || (newest && newest->timestamp != _lastNewestTs)) {
|
||||
_lastNewest = newest;
|
||||
_lastNewestTs = newest ? newest->timestamp : 0;
|
||||
_selectedN = -1;
|
||||
}
|
||||
|
||||
int count = visibleCount();
|
||||
if (count == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(2, MSG_TOP);
|
||||
display.print("(no messages)");
|
||||
} else {
|
||||
int y = MSG_TOP;
|
||||
for (int n = count - 1; n >= 0; n--) { // oldest visible (top) -> newest (bottom)
|
||||
const ChannelScreen::ChannelMessage* m = _store->getChannelMsgByRecency(_channelIdx, n);
|
||||
if (!m) continue;
|
||||
bool sent = isSent(m);
|
||||
if (n == _selectedN) drawTicker(display, y, m->text, sent);
|
||||
else drawMsgLine(display, y, m->text, sent);
|
||||
y += MSG_LINE_H;
|
||||
}
|
||||
}
|
||||
|
||||
// compose bar
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, H - COMPOSE_BAR_H, W, COMPOSE_BAR_H - 1);
|
||||
display.drawTextEllipsized(3, H - COMPOSE_BAR_H + 4, W - 6, "Tap to compose");
|
||||
if (_showPath) drawPathOverlay(display);
|
||||
return (_selectedN >= 0 || _showPath) ? 60 : 500;
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// TWatchKeyboardScreen
|
||||
// =============================================================================
|
||||
class TWatchKeyboardScreen : public UIScreen {
|
||||
DisplayDriver* _display;
|
||||
uint8_t _channelIdx;
|
||||
|
||||
public:
|
||||
enum Purpose { TWKB_CHANNEL, TWKB_DM, TWKB_ADMIN_PASSWORD, TWKB_ADMIN_CLI, TWKB_PATH, TWKB_NOTE, TWKB_SCOPE, TWKB_TRACE_PATH };
|
||||
|
||||
private:
|
||||
Purpose _purpose;
|
||||
int _contextIdx; // channel idx (channel) or contact idx (DM/admin)
|
||||
bool _mask; // render composed text as '*' (admin password)
|
||||
unsigned long _lastCharAt; // when the last char was typed (for reveal window)
|
||||
|
||||
static const unsigned long PW_REVEAL_MS = 2000; // show last char this long before masking
|
||||
|
||||
char _outBuf[TW_OUT_BUF_LEN];
|
||||
uint16_t _outLen;
|
||||
|
||||
enum Mode { LOWER, UPPER, SYM };
|
||||
Mode _mode;
|
||||
|
||||
bool _touchDown;
|
||||
int _downX, _downY;
|
||||
|
||||
bool _wantsSend;
|
||||
bool _wantsExit;
|
||||
|
||||
static const int MAXLEN = TW_OUT_BUF_LEN - 1; // 133
|
||||
|
||||
// layout geometry (120x120 virtual space)
|
||||
static const int TOPBAR_H = 15;
|
||||
static const int KEY_W = 12; // 10 * 12 = 120 wide
|
||||
static const int KEY_H = 26;
|
||||
static const int GRID_Y = 15; // row0 top; rows at GRID_Y + row*KEY_H
|
||||
|
||||
const char* const* layout() const {
|
||||
static const char* const lower[3] = { "qwertyuiop", "asdfghjkl'", "zxcvbnm,.?" };
|
||||
static const char* const upper[3] = { "QWERTYUIOP", "ASDFGHJKL\"", "ZXCVBNM<>/" };
|
||||
static const char* const syms[3] = { "123!@#$%^&", "456()[]{}-", "7890+=_:;|" };
|
||||
return _mode == SYM ? syms : (_mode == UPPER ? upper : lower);
|
||||
}
|
||||
const char* modeLabel() const {
|
||||
return _mode == SYM ? "SYM" : (_mode == UPPER ? "A-Z" : "a-z");
|
||||
}
|
||||
|
||||
void appendChar(char ch) {
|
||||
if (_outLen < MAXLEN) { _outBuf[_outLen++] = ch; _outBuf[_outLen] = 0; _lastCharAt = millis(); }
|
||||
}
|
||||
void backspace() {
|
||||
if (_outLen > 0) { _outLen--; _outBuf[_outLen] = 0; _lastCharAt = 0; }
|
||||
}
|
||||
void cycleMode() {
|
||||
_mode = (_mode == LOWER) ? UPPER : (_mode == UPPER ? SYM : LOWER);
|
||||
}
|
||||
// bottom row zones: mode 0..24 | space 24..72 | enter 72..96 | backspace 96..120
|
||||
void handleBottomRow(int x) {
|
||||
if (x < 24) cycleMode();
|
||||
else if (x < 72) appendChar(' ');
|
||||
else if (x < 96) { if (_outLen > 0) _wantsSend = true; }
|
||||
else backspace();
|
||||
}
|
||||
|
||||
public:
|
||||
TWatchKeyboardScreen(DisplayDriver* display)
|
||||
: _display(display), _channelIdx(0),
|
||||
_purpose(TWKB_CHANNEL), _contextIdx(0), _mask(false), _lastCharAt(0),
|
||||
_outLen(0), _mode(LOWER),
|
||||
_touchDown(false), _downX(0), _downY(0),
|
||||
_wantsSend(false), _wantsExit(false) {
|
||||
_outBuf[0] = 0;
|
||||
}
|
||||
|
||||
// Enter compose on the given channel (called when the compose bar is tapped).
|
||||
void activate(uint8_t idx, const char* name) {
|
||||
(void)name;
|
||||
_channelIdx = idx;
|
||||
_purpose = TWKB_CHANNEL;
|
||||
_contextIdx = idx;
|
||||
_mask = false;
|
||||
_outLen = 0; _outBuf[0] = 0;
|
||||
_mode = LOWER;
|
||||
_touchDown = false; _wantsSend = false; _wantsExit = false;
|
||||
}
|
||||
|
||||
// Enter text for a non-channel purpose (DM / admin password / admin CLI).
|
||||
void activateFor(Purpose purpose, int contextIdx) {
|
||||
_purpose = purpose;
|
||||
_contextIdx = contextIdx;
|
||||
_channelIdx = 0;
|
||||
_mask = (purpose == TWKB_ADMIN_PASSWORD);
|
||||
_outLen = 0; _outBuf[0] = 0;
|
||||
_mode = LOWER;
|
||||
_touchDown = false; _wantsSend = false; _wantsExit = false;
|
||||
}
|
||||
|
||||
Purpose getPurpose() const { return _purpose; }
|
||||
int getContextIdx() const { return _contextIdx; }
|
||||
|
||||
uint8_t getChannelIdx() const { return _channelIdx; }
|
||||
bool consumeSendRequest(const char** textOut) {
|
||||
if (!_wantsSend) return false;
|
||||
_wantsSend = false;
|
||||
if (textOut) *textOut = _outBuf;
|
||||
return true;
|
||||
}
|
||||
void clearOutBuf() { _outLen = 0; _outBuf[0] = 0; }
|
||||
// Pre-fill the compose buffer (e.g. editing an existing note). Call after
|
||||
// activateFor(), which clears the buffer; input is clamped at MAXLEN.
|
||||
void setInitialText(const char* s) {
|
||||
_outLen = 0;
|
||||
if (s) {
|
||||
while (s[_outLen] && _outLen < MAXLEN) { _outBuf[_outLen] = s[_outLen]; _outLen++; }
|
||||
}
|
||||
_outBuf[_outLen] = 0;
|
||||
}
|
||||
bool wantsExit() const { return _wantsExit; }
|
||||
void acknowledgeExit() { _wantsExit = false; }
|
||||
|
||||
bool handleInput(char c) override { return false; }
|
||||
|
||||
void poll() override {
|
||||
if (!_display) return;
|
||||
int x, y;
|
||||
bool now = ((LGFXDisplay*)_display)->getTouch(&x, &y);
|
||||
if (now && !_touchDown) {
|
||||
_touchDown = true; _downX = x; _downY = y;
|
||||
} else if (!now && _touchDown) {
|
||||
_touchDown = false;
|
||||
int x0 = _downX, y0 = _downY;
|
||||
if (x0 < 20 && y0 < TOPBAR_H) { _wantsExit = true; return; } // back arrow
|
||||
if (y0 < GRID_Y) return; // top bar (text) area
|
||||
int row = (y0 - GRID_Y) / KEY_H;
|
||||
if (row <= 2) {
|
||||
int col = x0 / KEY_W;
|
||||
char ch = layout()[row][col];
|
||||
if (ch) appendChar(ch);
|
||||
} else {
|
||||
handleBottomRow(x0); // bottom row
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
const int W = display.width();
|
||||
display.setTextSize(1);
|
||||
|
||||
// ---- top bar: back arrow + composed text tail + cursor ----
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, 0);
|
||||
display.print("<");
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
{
|
||||
// Mask the composed text with '*' for the admin password purpose.
|
||||
char masked[TW_OUT_BUF_LEN];
|
||||
const char* src = _outBuf;
|
||||
if (_mask) {
|
||||
int m = (_outLen < TW_OUT_BUF_LEN - 1) ? _outLen : TW_OUT_BUF_LEN - 1;
|
||||
for (int i = 0; i < m; i++) masked[i] = '*';
|
||||
// Reveal the most recently typed char briefly so it can be read.
|
||||
if (m > 0 && (millis() - _lastCharAt < PW_REVEAL_MS)) masked[m - 1] = _outBuf[m - 1];
|
||||
masked[m] = 0;
|
||||
src = masked;
|
||||
}
|
||||
const int avail = W - 16 - 4; // room after the back arrow, minus cursor
|
||||
int len = _outLen;
|
||||
int fit = 0;
|
||||
for (int n = 1; n <= len; n++) {
|
||||
if (display.getTextWidth(src + (len - n)) > avail) break;
|
||||
fit = n;
|
||||
}
|
||||
char tail[TW_OUT_BUF_LEN + 2];
|
||||
strncpy(tail, src + (len - fit), sizeof(tail) - 2);
|
||||
tail[sizeof(tail) - 2] = 0;
|
||||
size_t tl = strlen(tail);
|
||||
tail[tl] = '_'; tail[tl + 1] = 0;
|
||||
display.setCursor(16, 2);
|
||||
display.print(tail);
|
||||
}
|
||||
|
||||
// ---- key grid rows 0..2 ----
|
||||
const char* const* lay = layout();
|
||||
for (int row = 0; row < 3; row++) {
|
||||
int ry = GRID_Y + row * KEY_H;
|
||||
const char* r = lay[row];
|
||||
for (int col = 0; col < 10; col++) {
|
||||
int kx = col * KEY_W;
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawRect(kx, ry, KEY_W, KEY_H);
|
||||
char ch[2] = { r[col], 0 };
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
int tw = display.getTextWidth(ch);
|
||||
display.setCursor(kx + (KEY_W - tw) / 2, ry + (KEY_H - 8) / 2);
|
||||
display.print(ch);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- bottom row: mode | space | enter | backspace ----
|
||||
int by = GRID_Y + 3 * KEY_H;
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.fillRect(0, by, 24, KEY_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
{
|
||||
const char* ml = modeLabel();
|
||||
int tw = display.getTextWidth(ml);
|
||||
display.setCursor((24 - tw) / 2, by + (KEY_H - 8) / 2);
|
||||
display.print(ml);
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(24, by, 48, KEY_H); // space
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.fillRect(72, by, 24, KEY_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
{
|
||||
const char* el = "OK";
|
||||
int tw = display.getTextWidth(el);
|
||||
display.setCursor(72 + (24 - tw) / 2, by + (KEY_H - 8) / 2);
|
||||
display.print(el);
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::ORANGE);
|
||||
display.fillRect(96, by, 24, KEY_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
{
|
||||
const char* bl = "DEL";
|
||||
int tw = display.getTextWidth(bl);
|
||||
display.setCursor(96 + (24 - tw) / 2, by + (KEY_H - 8) / 2);
|
||||
display.print(bl);
|
||||
}
|
||||
|
||||
return 500;
|
||||
}
|
||||
};
|
||||
|
||||
#endif // TWATCH_COMPOSE_ENABLED
|
||||
@@ -1,243 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include "TWatchS3Board.h"
|
||||
#include <SensorBMA423.hpp>
|
||||
#include <esp_bt.h> // power-debug: esp_bt_controller_get_status()
|
||||
|
||||
volatile bool TWatchS3Board::_tilt_flag = false;
|
||||
volatile uint32_t TWatchS3Board::_tilt_isr_count = 0; // TEMP diagnostic
|
||||
|
||||
// memw forces the _tilt_flag store to retire to SRAM before this ISR
|
||||
// returns. Without it, tiltFired() in the main loop can read a stale
|
||||
// value and raise-to-wake is missed. Do not remove.
|
||||
void IRAM_ATTR TWatchS3Board::onTiltISR() { _tilt_flag = true; _tilt_isr_count++; asm volatile("memw" ::: "memory"); }
|
||||
|
||||
// ---- Wrapper-free BMA423 step counter (raw I2C) ----------------------------
|
||||
// SensorLib's SensorBMA423 step-counter methods do not compile in this build,
|
||||
// so the step counter is driven directly over I2C. Register/offset/mask values
|
||||
// are from the Bosch BMA423 driver.
|
||||
#define BMA423_REG_STEP_CNT_OUT 0x1E // 4-byte little-endian step count output
|
||||
#define BMA423_REG_FEATURE_CONFIG 0x5E // 64-byte feature config stream
|
||||
#define BMA423_FEATURE_LEN 64
|
||||
#define BMA423_STEP_EN_BYTE 0x37 // BMA423_STEP_CNTR_OFFSET(0x36) + 1
|
||||
#define BMA423_STEP_EN_BIT 0x10 // BMA423_STEP_CNTR_EN_MSK
|
||||
// Step counter watermark: a 10-bit field (BMA423_STEP_CNTR_WM_MSK = 0x03FF)
|
||||
// spanning feature_config[0x36] as LSB and the low two bits of [0x37] as MSB.
|
||||
// It does not collide with the enable bit, which is bit 4 of [0x37] (bit 12 of
|
||||
// the 16-bit word). Bosch's bma423_step_counter_set_watermark() writes exactly
|
||||
// these bits; SensorLib's enableStepCounter() calls it with 1, and LilyGo's own
|
||||
// firmware calls setStepCounterWatermark(1). Meck never set it, which is the
|
||||
// prime suspect for the undercounting.
|
||||
#define BMA423_STEP_WM_LSB_BYTE 0x36 // BMA423_STEP_CNTR_OFFSET
|
||||
#define BMA423_STEP_WM_MSK 0x03FF // BMA423_STEP_CNTR_WM_MSK
|
||||
#define BMA423_STEP_WM_LEVEL 1 // matches LilyGoLib and the SensorLib example
|
||||
|
||||
#define BMA423_REG_POWER_CONF 0x7C // BMA4_POWER_CONF_ADDR
|
||||
#define BMA423_ADV_PWR_SAVE_BIT 0x01 // BMA4_ADVANCE_POWER_SAVE_MSK
|
||||
|
||||
static bool bma423ReadRegs(uint8_t reg, uint8_t* buf, uint8_t len) {
|
||||
Wire.beginTransmission(I2C_ADDR_ACCEL);
|
||||
Wire.write(reg);
|
||||
if (Wire.endTransmission(false) != 0) return false;
|
||||
if (Wire.requestFrom((int)I2C_ADDR_ACCEL, (int)len) != len) return false;
|
||||
for (uint8_t i = 0; i < len; i++) buf[i] = Wire.read();
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool bma423WriteRegs(uint8_t reg, const uint8_t* buf, uint8_t len) {
|
||||
Wire.beginTransmission(I2C_ADDR_ACCEL);
|
||||
Wire.write(reg);
|
||||
for (uint8_t i = 0; i < len; i++) Wire.write(buf[i]);
|
||||
return Wire.endTransmission() == 0;
|
||||
}
|
||||
|
||||
// Enable the step counter by setting its enable bit in the feature config,
|
||||
// preserving every other byte (tilt lives at a different offset, 0x3A, so it is
|
||||
// untouched). The feature config can only be written with advanced-power-save
|
||||
// disabled, so we bracket the write and restore the prior power state after.
|
||||
static void bma423EnableStepCounter() {
|
||||
uint8_t pc;
|
||||
if (!bma423ReadRegs(BMA423_REG_POWER_CONF, &pc, 1)) return; // save power state
|
||||
uint8_t off = pc & ~BMA423_ADV_PWR_SAVE_BIT; // disable adv power save
|
||||
bma423WriteRegs(BMA423_REG_POWER_CONF, &off, 1);
|
||||
delay(2); // wake from low-power (>=450us)
|
||||
|
||||
uint8_t cfg[BMA423_FEATURE_LEN];
|
||||
if (bma423ReadRegs(BMA423_REG_FEATURE_CONFIG, cfg, BMA423_FEATURE_LEN)) {
|
||||
// Watermark first, then the enable bit, in a single read-modify-write.
|
||||
uint16_t wm = ((uint16_t)cfg[BMA423_STEP_EN_BYTE] << 8) | cfg[BMA423_STEP_WM_LSB_BYTE];
|
||||
wm = (wm & ~BMA423_STEP_WM_MSK) | (BMA423_STEP_WM_LEVEL & BMA423_STEP_WM_MSK);
|
||||
cfg[BMA423_STEP_WM_LSB_BYTE] = (uint8_t)(wm & 0xFF);
|
||||
cfg[BMA423_STEP_EN_BYTE] = (uint8_t)((wm >> 8) & 0xFF);
|
||||
cfg[BMA423_STEP_EN_BYTE] |= BMA423_STEP_EN_BIT;
|
||||
bma423WriteRegs(BMA423_REG_FEATURE_CONFIG, cfg, BMA423_FEATURE_LEN);
|
||||
delay(1); // write settle
|
||||
}
|
||||
|
||||
bma423WriteRegs(BMA423_REG_POWER_CONF, &pc, 1); // restore power state
|
||||
}
|
||||
|
||||
void TWatchS3Board::begin() {
|
||||
ESP32Board::begin();
|
||||
power_init();
|
||||
|
||||
// BMA423 accelerometer (always-on I2C, 0x19): enable the tilt / wrist-raise
|
||||
// feature and its interrupt (routed to PIN1 -> GPIO14) for raise-to-wake.
|
||||
_accel = new SensorBMA423();
|
||||
if (_accel->begin(Wire, I2C_ADDR_ACCEL, PIN_BOARD_SDA, PIN_BOARD_SCL)) {
|
||||
_accel->setRemapAxes(SensorRemap::BOTTOM_LAYER_TOP_RIGHT_CORNER);
|
||||
// 100 Hz ODR: SensorLib's BMA423_StepDetector example runs the pedometer at
|
||||
// 100 Hz, not the 50 Hz used here previously. Everything else already matched
|
||||
// (NORMAL, FS_2G, OSR2_AVG2, CIC_AVG_MODE). Revert this one literal to 50.0f
|
||||
// to A/B it against the watermark change above.
|
||||
_accel->configAccelerometer(OperationMode::NORMAL, AccelFullScaleRange::FS_2G,
|
||||
100.0f, AccelBandwidth::OSR2_AVG2, AccelPerfMode::CIC_AVG_MODE);
|
||||
// INT1 pin electrical config: level trigger, active high, push-pull,
|
||||
// output enabled. INT1_IO_CTRL resets to output-disabled, so without
|
||||
// this the pin never drives and INPUT_PULLDOWN reads low forever.
|
||||
_accel->setInterruptPinConfig(InterruptPinMap::PIN1, false, false, true, false);
|
||||
pinMode(PIN_ACCEL_IRQ, INPUT_PULLDOWN);
|
||||
// Attach the edge ISR BEFORE enabling the tilt source, so the first
|
||||
// assertion cannot occur before the handler is armed (a missed first edge
|
||||
// on a self-clearing line otherwise locks tilt-wake out permanently).
|
||||
attachInterrupt(digitalPinToInterrupt(PIN_ACCEL_IRQ), onTiltISR, RISING);
|
||||
_accel->enableTiltDetector(true, true);
|
||||
// Enable the hardware step counter via raw I2C (SensorLib's wrapper method
|
||||
// does not compile in this build). It then counts in the BMA423 feature
|
||||
// engine with no CPU cost, even while the display is off.
|
||||
bma423EnableStepCounter();
|
||||
}
|
||||
|
||||
esp_reset_reason_t reason = esp_reset_reason();
|
||||
if (reason == ESP_RST_DEEPSLEEP) {
|
||||
long wakeup_source = esp_sleep_get_ext1_wakeup_status();
|
||||
if (wakeup_source & (1 << P_LORA_DIO_1)) {
|
||||
startup_reason = BD_STARTUP_RX_PACKET;
|
||||
}
|
||||
rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS);
|
||||
rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1);
|
||||
}
|
||||
}
|
||||
|
||||
bool TWatchS3Board::power_init() {
|
||||
_axp = new XPowersAXP2101(Wire, PIN_BOARD_SDA, PIN_BOARD_SCL, I2C_ADDR_PMU);
|
||||
PMU = _axp; // same object; see the note in TWatchS3Board.h
|
||||
if (!PMU->init()) {
|
||||
MESH_DEBUG_PRINTLN("Warning: Failed to find AXP2101 power management");
|
||||
delete _axp;
|
||||
_axp = NULL;
|
||||
PMU = NULL;
|
||||
return false;
|
||||
}
|
||||
|
||||
PMU->setChargingLedMode(XPOWERS_CHG_LED_CTRL_CHG);
|
||||
|
||||
// Power rails per the T-Watch S3 PowerManage table, cross-checked against the
|
||||
// schematic (rev 25-03-24):
|
||||
// ALDO1 = unused, ALDO2 = display backlight,
|
||||
// ALDO3 = display + touch, ALDO4 = LoRa (schematic net LDO4 -> HPD16B3 VCC),
|
||||
// BLDO1 = unused (no GNSS), BLDO2 = DRV2605 haptic,
|
||||
// DLDO1 = MAX98357A speaker amp VDD (schematic sheet 6, net SPK_VDD),
|
||||
// VBACKUP = MS412FE rechargeable coin cell backing the PCF8563 RTC domain.
|
||||
//
|
||||
// LilyGo's hardware doc lists DLDO1 as unused. The schematic disagrees: it is
|
||||
// the speaker rail. Meck compiles no audio, so it stays off, which fully
|
||||
// unpowers the amp rather than merely idling it.
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO4, 3300); // LoRa radio
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO4);
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO3, 3300); // display + touch
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO3);
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO2, 3300); // display backlight
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO2);
|
||||
PMU->setPowerChannelVoltage(XPOWERS_BLDO2, 3300); // DRV2605 haptic
|
||||
PMU->enablePowerOutput(XPOWERS_BLDO2);
|
||||
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC2);
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC3);
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC4);
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC5);
|
||||
PMU->disablePowerOutput(XPOWERS_ALDO1); // unused
|
||||
PMU->disablePowerOutput(XPOWERS_BLDO1); // GNSS rail on the Plus; unpopulated here
|
||||
PMU->disablePowerOutput(XPOWERS_DLDO1); // MAX98357A speaker amp -- audio not compiled in
|
||||
PMU->disablePowerOutput(XPOWERS_DLDO2);
|
||||
|
||||
// RTC backup cell. The PCF8563 has a single VDD pin (no separate battery
|
||||
// input), and the schematic diode-ORs it against the MS412FE on J12, which is
|
||||
// charged from the AXP2101 BACKUP pin. Leaving this off drains the cell with
|
||||
// nothing to replenish it. 3300 mV matches LilyGo's own firmware.
|
||||
// setPowerChannelVoltage/enablePowerOutput on XPOWERS_VBACKUP map onto
|
||||
// setButtonBatteryChargeVoltage()/enableButtonBatteryCharge().
|
||||
PMU->setPowerChannelVoltage(XPOWERS_VBACKUP, 3300);
|
||||
PMU->enablePowerOutput(XPOWERS_VBACKUP);
|
||||
|
||||
// PWR key. The side switch (schematic SW7) is wired to PWRON, not a GPIO.
|
||||
// press < 1s -> PKEY_SHORT_IRQ, consumed by PMUButton as a click
|
||||
// 1s <= press < 6s -> nothing (PKEY_LONG_IRQ is left masked)
|
||||
// press >= 6s -> hardware power-off, firmware never sees it
|
||||
// hold 2s from off -> power-on
|
||||
// Matches the 2S ON / 6S OFF behaviour printed on LilyGo's own pin diagram.
|
||||
PMU->setPowerKeyPressOnTime(XPOWERS_POWERON_2S);
|
||||
PMU->setPowerKeyPressOffTime(XPOWERS_POWEROFF_6S);
|
||||
_axp->setIrqLevelTime(XPOWERS_AXP2101_IRQ_TIME_1S); // not on XPowersLibInterface
|
||||
|
||||
PMU->disableIRQ(XPOWERS_AXP2101_ALL_IRQ);
|
||||
PMU->clearIrqStatus();
|
||||
// SHORT gives the click; NEGATIVE/POSITIVE are the press/release edges that
|
||||
// back PMUButton::isPressed().
|
||||
PMU->enableIRQ(XPOWERS_AXP2101_PKEY_SHORT_IRQ |
|
||||
XPOWERS_AXP2101_PKEY_NEGATIVE_IRQ |
|
||||
XPOWERS_AXP2101_PKEY_POSITIVE_IRQ);
|
||||
|
||||
// 150 mA = 0.32C on the 470 mAh cell -- a modest bump over the 125 mA
|
||||
// vendor default (LilyGoWatchS3.cpp uses 125), well under the 0.5C rule.
|
||||
PMU->setChargerConstantCurr(XPOWERS_AXP2101_CHG_CUR_150MA);
|
||||
// The fitted cell (Tewaycell 552530, label verified) is 3.8V nominal LiHV,
|
||||
// i.e. 4.35V max charge. LilyGoLib's init for this board sets 4V35 for the
|
||||
// same reason. Charging to 4V2 forfeits the top ~12-15% of capacity.
|
||||
PMU->setChargeTargetVoltage(XPOWERS_AXP2101_CHG_VOL_4V35);
|
||||
|
||||
PMU->disableTSPinMeasure();
|
||||
PMU->enableSystemVoltageMeasure();
|
||||
PMU->enableVbusVoltageMeasure();
|
||||
PMU->enableBattVoltageMeasure();
|
||||
|
||||
Serial.printf("[PWR] rails: ALDO2(bl)=%d ALDO3(disp/touch)=%d ALDO4(LoRa)=%d BLDO2(haptic)=%d DLDO1(spk)=%d VBACKUP(rtc)=%d\n",
|
||||
PMU->isPowerChannelEnable(XPOWERS_ALDO2),
|
||||
PMU->isPowerChannelEnable(XPOWERS_ALDO3),
|
||||
PMU->isPowerChannelEnable(XPOWERS_ALDO4),
|
||||
PMU->isPowerChannelEnable(XPOWERS_BLDO2),
|
||||
PMU->isPowerChannelEnable(XPOWERS_DLDO1),
|
||||
PMU->isPowerChannelEnable(XPOWERS_VBACKUP));
|
||||
return true;
|
||||
}
|
||||
|
||||
void TWatchS3Board::printPowerDebug() {
|
||||
if (!PMU) return;
|
||||
Serial.printf("[PWR] batt=%dmV %d%% vbus=%dmV charging=%d cpu=%dMHz bt=%d\n",
|
||||
PMU->getBattVoltage(), PMU->getBatteryPercent(),
|
||||
PMU->getVbusVoltage(), PMU->isCharging(),
|
||||
getCpuFrequencyMhz(), (int)esp_bt_controller_get_status());
|
||||
}
|
||||
|
||||
bool TWatchS3Board::tiltFired() {
|
||||
if (_tilt_flag) { // set by the GPIO14 rising-edge ISR
|
||||
_tilt_flag = false;
|
||||
_accel->update(); // reading the status clears the sensor INT
|
||||
return true;
|
||||
}
|
||||
// TEMP diagnostic: periodically report the raw INT pin level and ISR count,
|
||||
// to distinguish "pin stuck high / never re-arms" from "never asserts".
|
||||
static unsigned long _tilt_dbg_next = 0;
|
||||
if (millis() >= _tilt_dbg_next) {
|
||||
_tilt_dbg_next = millis() + 5000;
|
||||
Serial.printf("[TILT] pin=%d isr_count=%u\n",
|
||||
digitalRead(PIN_ACCEL_IRQ), (unsigned)_tilt_isr_count);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t TWatchS3Board::getStepCount() {
|
||||
uint8_t d[4];
|
||||
if (!bma423ReadRegs(BMA423_REG_STEP_CNT_OUT, d, 4)) return 0;
|
||||
return (uint32_t)d[0] | ((uint32_t)d[1] << 8) |
|
||||
((uint32_t)d[2] << 16) | ((uint32_t)d[3] << 24);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "variant.h" // Board-specific pin definitions (I2C, addresses, IRQs)
|
||||
|
||||
#include <Wire.h>
|
||||
#include <Arduino.h>
|
||||
#include "XPowersLib.h"
|
||||
#include "helpers/ESP32Board.h"
|
||||
#include <driver/rtc_io.h>
|
||||
|
||||
// LilyGo T-Watch S3 board (non-GPS, 470 mAh).
|
||||
//
|
||||
// Power is managed by an AXP2101 PMU on the main I2C bus. Two pointers to the
|
||||
// same object are kept, because XPowersLib splits its API by access specifier:
|
||||
// PMU (XPowersLibInterface*) -- the power-channel ops (setPowerChannelVoltage,
|
||||
// enable/disablePowerOutput, isPowerChannelEnable) are PROTECTED on
|
||||
// XPowersAXP2101 and only reachable through the interface.
|
||||
// _axp (XPowersAXP2101*) -- setIrqLevelTime() and the PWRON press/release
|
||||
// edges isPekeyNegativeIrq()/isPekeyPositiveIrq() are AXP2101-only and
|
||||
// absent from the interface.
|
||||
// Everything else is public on both.
|
||||
class SensorBMA423; // full include kept in the .cpp to avoid a BLE-build header clash
|
||||
|
||||
class TWatchS3Board : public ESP32Board {
|
||||
XPowersLibInterface* PMU = NULL;
|
||||
XPowersAXP2101* _axp = NULL; // same object as PMU, concrete type
|
||||
SensorBMA423* _accel = nullptr;
|
||||
static volatile bool _tilt_flag;
|
||||
static volatile uint32_t _tilt_isr_count; // TEMP diagnostic
|
||||
static void IRAM_ATTR onTiltISR(); // defined in the .cpp (IRAM relocation)
|
||||
|
||||
bool power_init();
|
||||
|
||||
public:
|
||||
void begin();
|
||||
|
||||
// Returns true once when the BMA423 tilt (wrist-raise) interrupt has fired.
|
||||
bool tiltFired();
|
||||
|
||||
// The AXP2101 handle, for PMUButton. NULL if the PMU failed to init.
|
||||
XPowersAXP2101* getPMU() { return _axp; }
|
||||
|
||||
void enterDeepSleep(uint32_t secs, int pin_wake_btn) {
|
||||
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
|
||||
|
||||
rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY);
|
||||
rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1);
|
||||
rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS);
|
||||
|
||||
// NOTE: the PWR key is not a GPIO on this board, so it cannot be added to
|
||||
// the ext1 mask. pin_wake_btn is accepted for signature compatibility with
|
||||
// ESP32Board but callers on this variant pass -1.
|
||||
if (pin_wake_btn < 0) {
|
||||
esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH);
|
||||
} else {
|
||||
esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1) | (1ULL << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH);
|
||||
}
|
||||
|
||||
if (secs > 0) {
|
||||
esp_sleep_enable_timer_wakeup(secs * 1000000ULL);
|
||||
}
|
||||
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
|
||||
uint16_t getBattMilliVolts() override {
|
||||
return PMU ? PMU->getBattVoltage() : 0;
|
||||
}
|
||||
|
||||
uint8_t getBatteryPercent() override {
|
||||
return PMU ? PMU->getBatteryPercent() : 0;
|
||||
}
|
||||
|
||||
// Wrapper-free BMA423 step count (raw I2C; defined in the .cpp).
|
||||
uint32_t getStepCount();
|
||||
|
||||
// Power-debug probe: battery, VBUS, CPU clock, BT controller state and live
|
||||
// rail states. Called periodically from UITask::loop.
|
||||
void printPowerDebug();
|
||||
|
||||
const char* getManufacturerName() const override {
|
||||
return "LilyGo T-Watch S3";
|
||||
}
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// LovyanGFX display + touch for the LilyGo T-Watch S3.
|
||||
// ST7789V 240x240 on SPI3 + PWM backlight (GPIO45) + FT6336U capacitive touch
|
||||
// on a separate I2C bus (Wire1: SDA 39 / SCL 40, INT 16, no RST pin).
|
||||
//
|
||||
// LGFXDisplay.h pulls in LovyanGFX (it defines LGFX_USE_V1 and includes
|
||||
// LovyanGFX.hpp), so we do not redefine those here.
|
||||
|
||||
#include <helpers/ui/LGFXDisplay.h>
|
||||
|
||||
class LGFX_TWatchS3 : public lgfx::LGFX_Device {
|
||||
lgfx::Panel_ST7789 _panel_instance;
|
||||
lgfx::Bus_SPI _bus_instance;
|
||||
lgfx::Light_PWM _light_instance;
|
||||
lgfx::Touch_FT5x06 _touch_instance;
|
||||
|
||||
public:
|
||||
LGFX_TWatchS3(void) {
|
||||
{
|
||||
auto cfg = _bus_instance.config();
|
||||
cfg.spi_host = SPI3_HOST;
|
||||
cfg.spi_mode = 0;
|
||||
cfg.freq_write = 40000000;
|
||||
cfg.freq_read = 16000000;
|
||||
cfg.spi_3wire = true;
|
||||
cfg.use_lock = true;
|
||||
cfg.dma_channel = SPI_DMA_CH_AUTO;
|
||||
cfg.pin_sclk = 18;
|
||||
cfg.pin_mosi = 13;
|
||||
cfg.pin_miso = -1;
|
||||
cfg.pin_dc = 38;
|
||||
_bus_instance.config(cfg);
|
||||
_panel_instance.setBus(&_bus_instance);
|
||||
}
|
||||
|
||||
{
|
||||
auto cfg = _panel_instance.config();
|
||||
cfg.pin_cs = 12;
|
||||
cfg.pin_rst = -1;
|
||||
cfg.pin_busy = -1;
|
||||
// ST7789 GRAM is 240x320; memory_height must be 320 so the 80px rotation
|
||||
// offset is applied (otherwise a strip of the panel shows noise).
|
||||
cfg.memory_width = 240;
|
||||
cfg.memory_height = 320;
|
||||
cfg.panel_width = 240;
|
||||
cfg.panel_height = 240;
|
||||
cfg.offset_x = 0;
|
||||
cfg.offset_y = 0;
|
||||
cfg.offset_rotation = 1;
|
||||
cfg.readable = false;
|
||||
cfg.invert = true;
|
||||
cfg.rgb_order = false;
|
||||
cfg.dlen_16bit = false;
|
||||
cfg.bus_shared = false;
|
||||
_panel_instance.config(cfg);
|
||||
}
|
||||
|
||||
{
|
||||
auto cfg = _light_instance.config();
|
||||
cfg.pin_bl = 45;
|
||||
cfg.invert = false;
|
||||
cfg.freq = 44100;
|
||||
cfg.pwm_channel = 7;
|
||||
_light_instance.config(cfg);
|
||||
_panel_instance.setLight(&_light_instance);
|
||||
}
|
||||
|
||||
{
|
||||
auto cfg = _touch_instance.config();
|
||||
cfg.x_min = 0;
|
||||
cfg.x_max = 239;
|
||||
cfg.y_min = 0;
|
||||
cfg.y_max = 239;
|
||||
cfg.pin_int = 16;
|
||||
cfg.pin_rst = -1;
|
||||
cfg.bus_shared = false;
|
||||
cfg.offset_rotation = 2; // touch IC mounted 180deg vs the LCD horizontal axis
|
||||
cfg.i2c_port = 1;
|
||||
cfg.i2c_addr = 0x38;
|
||||
cfg.pin_sda = 39;
|
||||
cfg.pin_scl = 40;
|
||||
cfg.freq = 400000;
|
||||
_touch_instance.config(cfg);
|
||||
_panel_instance.setTouch(&_touch_instance);
|
||||
}
|
||||
|
||||
setPanel(&_panel_instance);
|
||||
}
|
||||
};
|
||||
|
||||
class TWatchS3Display : public LGFXDisplay {
|
||||
LGFX_TWatchS3 disp;
|
||||
public:
|
||||
TWatchS3Display() : LGFXDisplay(240, 240, disp) {}
|
||||
};
|
||||
@@ -1,430 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// WatchAlarmScreen -- vibrate-only alarm clock for the LilyGo T-Watch S3.
|
||||
//
|
||||
// The T-Deck Pro's AlarmScreen.h is bound to ESP32-audioI2S, an SD card and a
|
||||
// 1-21 volume scale, none of which exist here. This is a separate, much smaller
|
||||
// screen that keeps AlarmScreen's conventions (bit 0 = Sunday day-of-week mask,
|
||||
// dowFromEpoch, the 3-buzz / 4s-pause vibrate cadence, effect 14) so the two
|
||||
// stay recognisably related.
|
||||
//
|
||||
// Alarm is the DRV2605L on I2C 0x5A driving the 0827 coin motor. Its BLDO2 rail
|
||||
// is already up from power_init(), so no motorEnable() call is needed.
|
||||
//
|
||||
// A ringing alarm is dismissed ONLY by the PWR key (which arrives as KEY_ENTER),
|
||||
// or by the WATCH_ALARM_RINGING_MS timeout. Taps are swallowed, and UITask::loop
|
||||
// holds this screen current and the display awake for the duration -- otherwise
|
||||
// the buzzing trips the BMA423 tilt detector and raise-to-wake steals the screen.
|
||||
//
|
||||
// Config lives on the LittleFS partition mounted at /maps in main.cpp -- the
|
||||
// same one the Plus uses for map tiles. The watch has no SD card.
|
||||
//
|
||||
// Geometry: the display is 240x240 physical, UI_ZOOM=2, so display.width() and
|
||||
// display.height() both report 120 logical pixels.
|
||||
// =============================================================================
|
||||
|
||||
// NOTE on include order: this header uses NodePrefs, which lives at
|
||||
// examples/companion_radio/NodePrefs.h and is not on this variant's -I path.
|
||||
// UITask.h pulls it in via "../NodePrefs.h", so WatchAlarmScreen.h must always
|
||||
// be included *after* UITask.h. Both call sites (UITask.cpp, main.cpp) do.
|
||||
// This mirrors how TWatchComposeScreens.h depends on ChannelScreen.h.
|
||||
#include <Arduino.h>
|
||||
#include <string.h>
|
||||
#include <LittleFS.h>
|
||||
#include <MeshCore.h> // mesh::RTCClock
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ui/LGFXDisplay.h>
|
||||
#include "DRV2605Haptic.h"
|
||||
|
||||
class UITask;
|
||||
|
||||
#define WATCH_ALARM_SLOT_COUNT 4
|
||||
#define WATCH_ALARM_CFG_PATH "/alarms.cfg"
|
||||
#define WATCH_ALARM_CFG_MAGIC 0x334B414CUL // "LAK3"
|
||||
#define WATCH_ALARM_CFG_VERSION 1
|
||||
|
||||
// Day-of-week bitmask, bit 0 = Sunday (matches AlarmScreen.h)
|
||||
#define WDOW_SUN (1 << 0)
|
||||
#define WDOW_MON (1 << 1)
|
||||
#define WDOW_TUE (1 << 2)
|
||||
#define WDOW_WED (1 << 3)
|
||||
#define WDOW_THU (1 << 4)
|
||||
#define WDOW_FRI (1 << 5)
|
||||
#define WDOW_SAT (1 << 6)
|
||||
#define WDOW_ALL 0x7F
|
||||
#define WDOW_WEEKDAYS (WDOW_MON | WDOW_TUE | WDOW_WED | WDOW_THU | WDOW_FRI)
|
||||
#define WDOW_WEEKEND (WDOW_SAT | WDOW_SUN)
|
||||
|
||||
// Vibrate cadence, identical to AlarmScreen's silent alarm on the T-Deck MAX.
|
||||
#define WATCH_ALARM_EFFECT 14 // library 1: strong buzz, ~1s
|
||||
#define WATCH_ALARM_BUZZ_MS 1200 // spacing between buzz starts
|
||||
#define WATCH_ALARM_GROUP 3 // buzzes per group
|
||||
#define WATCH_ALARM_PAUSE_MS 4000 // pause after each group
|
||||
#define WATCH_ALARM_RINGING_MS 300000 // 5 minute auto-dismiss
|
||||
#define WATCH_ALARM_FIRE_COOLDOWN 90 // seconds; do not re-fire the same slot
|
||||
|
||||
struct WatchAlarmSlot {
|
||||
bool enabled;
|
||||
uint8_t hour; // 0-23
|
||||
uint8_t minute; // 0-59
|
||||
uint8_t days; // day-of-week bitmask; WDOW_ALL = every day
|
||||
};
|
||||
|
||||
struct WatchAlarmConfig {
|
||||
uint32_t magic;
|
||||
uint8_t version;
|
||||
uint8_t _pad[3];
|
||||
WatchAlarmSlot slots[WATCH_ALARM_SLOT_COUNT];
|
||||
};
|
||||
|
||||
class WatchAlarmScreen : public UIScreen {
|
||||
public:
|
||||
enum Mode { LIST, EDIT, RINGING };
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
NodePrefs* _node_prefs;
|
||||
|
||||
WatchAlarmConfig _cfg;
|
||||
Mode _mode = LIST;
|
||||
int _sel = 0; // selected slot in LIST / slot under EDIT
|
||||
WatchAlarmSlot _edit; // working copy while editing
|
||||
|
||||
// Ringing state
|
||||
int _ringSlot = -1;
|
||||
unsigned long _ringStartMs = 0;
|
||||
unsigned long _vibNextMs = 0;
|
||||
int _vibCount = 0;
|
||||
|
||||
// Fire tracking
|
||||
uint32_t _lastFireEpoch[WATCH_ALARM_SLOT_COUNT] = {0};
|
||||
|
||||
DRV2605Haptic _haptic;
|
||||
bool _hapticReady = false;
|
||||
|
||||
static int dowFromEpoch(uint32_t epoch) {
|
||||
// 1970-01-01 was a Thursday (index 4 with 0 = Sunday)
|
||||
return (int)((epoch / 86400 + 4) % 7);
|
||||
}
|
||||
|
||||
int32_t localNow() const {
|
||||
uint32_t utc = _rtc->getCurrentTime();
|
||||
if (utc < 1700000000) return -1; // clock not set yet
|
||||
return (int32_t)utc + ((int32_t)_node_prefs->utc_offset_hours * 3600);
|
||||
}
|
||||
|
||||
static const char* daysLabel(uint8_t d) {
|
||||
if (d == WDOW_ALL) return "Daily";
|
||||
if (d == WDOW_WEEKDAYS) return "Mon-Fri";
|
||||
if (d == WDOW_WEEKEND) return "Sat/Sun";
|
||||
if (d == 0) return "Once";
|
||||
return "Custom";
|
||||
}
|
||||
|
||||
static uint8_t nextDaysPreset(uint8_t d) {
|
||||
if (d == WDOW_ALL) return WDOW_WEEKDAYS;
|
||||
if (d == WDOW_WEEKDAYS) return WDOW_WEEKEND;
|
||||
if (d == WDOW_WEEKEND) return 0; // Once
|
||||
return WDOW_ALL;
|
||||
}
|
||||
|
||||
void loadDefaults() {
|
||||
memset(&_cfg, 0, sizeof(_cfg));
|
||||
_cfg.magic = WATCH_ALARM_CFG_MAGIC;
|
||||
_cfg.version = WATCH_ALARM_CFG_VERSION;
|
||||
for (int i = 0; i < WATCH_ALARM_SLOT_COUNT; i++) {
|
||||
_cfg.slots[i].enabled = false;
|
||||
_cfg.slots[i].hour = 7;
|
||||
_cfg.slots[i].minute = 0;
|
||||
_cfg.slots[i].days = WDOW_ALL;
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
WatchAlarmScreen(UITask* task, mesh::RTCClock* rtc, NodePrefs* node_prefs)
|
||||
: _task(task), _rtc(rtc), _node_prefs(node_prefs) {
|
||||
loadDefaults();
|
||||
}
|
||||
|
||||
// Read the config from LittleFS. Called once from UITask::begin() after the
|
||||
// /maps partition is mounted. A missing or malformed file leaves the defaults.
|
||||
void load() {
|
||||
File f = LittleFS.open(WATCH_ALARM_CFG_PATH, "r");
|
||||
if (!f) return;
|
||||
WatchAlarmConfig tmp;
|
||||
size_t n = f.read((uint8_t*)&tmp, sizeof(tmp));
|
||||
f.close();
|
||||
if (n != sizeof(tmp)) return;
|
||||
if (tmp.magic != WATCH_ALARM_CFG_MAGIC) return;
|
||||
if (tmp.version != WATCH_ALARM_CFG_VERSION) return;
|
||||
for (int i = 0; i < WATCH_ALARM_SLOT_COUNT; i++) {
|
||||
if (tmp.slots[i].hour > 23 || tmp.slots[i].minute > 59) return; // reject junk
|
||||
}
|
||||
_cfg = tmp;
|
||||
}
|
||||
|
||||
void save() {
|
||||
File f = LittleFS.open(WATCH_ALARM_CFG_PATH, "w");
|
||||
if (!f) {
|
||||
Serial.println("WatchAlarmScreen: save FAILED (LittleFS)");
|
||||
return;
|
||||
}
|
||||
f.write((const uint8_t*)&_cfg, sizeof(_cfg));
|
||||
f.close();
|
||||
}
|
||||
|
||||
Mode getMode() const { return _mode; }
|
||||
bool isRinging() const { return _mode == RINGING; }
|
||||
|
||||
void enter() { _mode = LIST; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Firing. Driven from UITask::loop() so an alarm still fires with the display
|
||||
// off or another screen showing. Returns true on the loop where it fires.
|
||||
// ---------------------------------------------------------------------------
|
||||
bool tick() {
|
||||
if (_mode == RINGING) {
|
||||
driveVibrate();
|
||||
if (millis() - _ringStartMs > WATCH_ALARM_RINGING_MS) dismiss();
|
||||
return false;
|
||||
}
|
||||
|
||||
int32_t local = localNow();
|
||||
if (local < 0) return false;
|
||||
|
||||
int hrs = (local / 3600) % 24; if (hrs < 0) hrs += 24;
|
||||
int mins = (local / 60) % 60; if (mins < 0) mins += 60;
|
||||
int dow = dowFromEpoch((uint32_t)local);
|
||||
uint32_t nowEpoch = (uint32_t)local;
|
||||
|
||||
for (int i = 0; i < WATCH_ALARM_SLOT_COUNT; i++) {
|
||||
const WatchAlarmSlot& s = _cfg.slots[i];
|
||||
if (!s.enabled) continue;
|
||||
if (s.hour != hrs || s.minute != mins) continue;
|
||||
if (s.days != 0 && !(s.days & (1 << dow))) continue; // days==0 means Once
|
||||
if (nowEpoch - _lastFireEpoch[i] < WATCH_ALARM_FIRE_COOLDOWN) continue;
|
||||
|
||||
_lastFireEpoch[i] = nowEpoch;
|
||||
if (_cfg.slots[i].days == 0) { // one-shot: disarm after firing
|
||||
_cfg.slots[i].enabled = false;
|
||||
save();
|
||||
}
|
||||
startRinging(i);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void startRinging(int slot) {
|
||||
_mode = RINGING;
|
||||
_ringSlot = slot;
|
||||
_ringStartMs = millis();
|
||||
_vibNextMs = 0;
|
||||
_vibCount = 0;
|
||||
}
|
||||
|
||||
void dismiss() {
|
||||
if (_hapticReady) {
|
||||
_haptic.standby(); // drop DRV2605 back to ~1.8uA once the alarm ends
|
||||
_hapticReady = false; // force begin() (exit standby) on the next alarm
|
||||
}
|
||||
_mode = LIST;
|
||||
_ringSlot = -1;
|
||||
}
|
||||
|
||||
private:
|
||||
void driveVibrate() {
|
||||
if (!_hapticReady) {
|
||||
_hapticReady = _haptic.begin(); // BLDO2 is already up; no motorEnable()
|
||||
if (!_hapticReady) return;
|
||||
}
|
||||
if (millis() < _vibNextMs) return;
|
||||
|
||||
_haptic.buzz(WATCH_ALARM_EFFECT);
|
||||
if (++_vibCount >= WATCH_ALARM_GROUP) {
|
||||
_vibCount = 0;
|
||||
_vibNextMs = millis() + WATCH_ALARM_PAUSE_MS;
|
||||
} else {
|
||||
_vibNextMs = millis() + WATCH_ALARM_BUZZ_MS;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering. 120x120 logical.
|
||||
// ---------------------------------------------------------------------------
|
||||
static const int ROW_Y0 = 22; // first list row top
|
||||
static const int ROW_H = 22; // list row height
|
||||
static const int TOGGLE_X = 88; // left edge of the ON/OFF badge column
|
||||
|
||||
void renderList(DisplayDriver& display) {
|
||||
LGFXDisplay* d = (LGFXDisplay*)&display;
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 6, "Alarms");
|
||||
|
||||
char buf[16];
|
||||
for (int i = 0; i < WATCH_ALARM_SLOT_COUNT; i++) {
|
||||
int y = ROW_Y0 + i * ROW_H;
|
||||
const WatchAlarmSlot& s = _cfg.slots[i];
|
||||
|
||||
if (i == _sel) {
|
||||
d->setRawColor(0x231D); // same blue as the home tile
|
||||
d->drawRoundRect(1, y, display.width() - 2, ROW_H - 2, 3);
|
||||
}
|
||||
|
||||
d->setRawColor(s.enabled ? 0xFFFF : 0x7BEF); // white when armed, grey when off
|
||||
snprintf(buf, sizeof(buf), "%02d:%02d", s.hour, s.minute);
|
||||
display.setCursor(5, y + 3);
|
||||
display.print(buf);
|
||||
|
||||
d->setRawColor(0x7BEF);
|
||||
d->printSmallFont(5, y + 14, daysLabel(s.days));
|
||||
|
||||
d->setRawColor(s.enabled ? 0xC618 : 0x4208);
|
||||
d->printSmallFont(TOGGLE_X + 4, y + 8, s.enabled ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
d->setRawColor(0x7BEF);
|
||||
d->printSmallFont(4, display.height() - 7, "Tap time:edit ON/OFF:arm");
|
||||
}
|
||||
|
||||
void renderEdit(DisplayDriver& display) {
|
||||
LGFXDisplay* d = (LGFXDisplay*)&display;
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 4, "Edit alarm");
|
||||
|
||||
// Up arrows / value / down arrows. Hour occupies x 10..54, minute 66..110.
|
||||
d->setRawColor(0xC618);
|
||||
display.drawTextCentered(32, 22, "+");
|
||||
display.drawTextCentered(88, 22, "+");
|
||||
display.drawTextCentered(32, 74, "-");
|
||||
display.drawTextCentered(88, 74, "-");
|
||||
|
||||
char buf[8];
|
||||
d->setRawColor(0xFFFF);
|
||||
display.setTextSize(2);
|
||||
snprintf(buf, sizeof(buf), "%02d", _edit.hour);
|
||||
display.drawTextCentered(32, 44, buf);
|
||||
snprintf(buf, sizeof(buf), "%02d", _edit.minute);
|
||||
display.drawTextCentered(88, 44, buf);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 48, ":");
|
||||
|
||||
// Footer: left half cycles the day preset, right half saves.
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(1, 92, 58, 18, 3);
|
||||
d->drawRoundRect(61, 92, 58, 18, 3);
|
||||
d->setRawColor(0xC618);
|
||||
d->printSmallFont(6, 100, daysLabel(_edit.days));
|
||||
d->setRawColor(0xFFFF);
|
||||
d->printSmallFont(78, 100, "Save");
|
||||
}
|
||||
|
||||
void renderRinging(DisplayDriver& display) {
|
||||
LGFXDisplay* d = (LGFXDisplay*)&display;
|
||||
char buf[8];
|
||||
|
||||
d->setRawColor(0xFFFF);
|
||||
if (_ringSlot >= 0) {
|
||||
snprintf(buf, sizeof(buf), "%02d:%02d", _cfg.slots[_ringSlot].hour,
|
||||
_cfg.slots[_ringSlot].minute);
|
||||
} else {
|
||||
strcpy(buf, "--:--");
|
||||
}
|
||||
d->printClockFont(display.width() / 2, display.height() / 2 - 22, buf);
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, display.height() - 24, "ALARM");
|
||||
((LGFXDisplay*)&display)->printSmallFont(14, display.height() - 10, "Press PWR to dismiss");
|
||||
}
|
||||
|
||||
public:
|
||||
int render(DisplayDriver& display) override {
|
||||
switch (_mode) {
|
||||
case LIST: renderList(display); return 500;
|
||||
case EDIT: renderEdit(display); return 200;
|
||||
case RINGING: renderRinging(display); return 250;
|
||||
}
|
||||
return 500;
|
||||
}
|
||||
|
||||
// Physical tap in logical (display.width()) coordinates. Returns true if the
|
||||
// tap was consumed. The status-bar strip is handled by main.cpp before this.
|
||||
bool handleTap(int lx, int ly) {
|
||||
// Ringing: swallow taps. Only the PWR key dismisses (see handleInput).
|
||||
if (_mode == RINGING) return true;
|
||||
|
||||
if (_mode == LIST) {
|
||||
for (int i = 0; i < WATCH_ALARM_SLOT_COUNT; i++) {
|
||||
int y = ROW_Y0 + i * ROW_H;
|
||||
if (ly < y || ly >= y + ROW_H - 2) continue;
|
||||
if (lx >= TOGGLE_X) { // ON/OFF badge: arm / disarm
|
||||
_cfg.slots[i].enabled = !_cfg.slots[i].enabled;
|
||||
_sel = i;
|
||||
save();
|
||||
} else { // time area: open the editor
|
||||
_sel = i;
|
||||
_edit = _cfg.slots[i];
|
||||
_mode = EDIT;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// EDIT
|
||||
if (ly >= 92) { // footer
|
||||
if (lx < 60) {
|
||||
_edit.days = nextDaysPreset(_edit.days);
|
||||
} else {
|
||||
commitEdit();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool hourCol = (lx < display_mid());
|
||||
if (ly < 40) { // upper half: increment
|
||||
if (hourCol) _edit.hour = (_edit.hour + 1) % 24;
|
||||
else _edit.minute = (_edit.minute + 1) % 60;
|
||||
return true;
|
||||
}
|
||||
if (ly >= 62) { // lower half: decrement
|
||||
if (hourCol) _edit.hour = (_edit.hour + 23) % 24;
|
||||
else _edit.minute = (_edit.minute + 59) % 60;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// The PMU short press arrives as KEY_ENTER.
|
||||
if (_mode == RINGING) {
|
||||
if (c == KEY_ENTER) dismiss(); // PWR key only
|
||||
return true; // swallow everything else
|
||||
}
|
||||
if (_mode == EDIT && c == KEY_ENTER) { commitEdit(); return true; }
|
||||
if (_mode == LIST && c == KEY_ENTER) {
|
||||
_edit = _cfg.slots[_sel];
|
||||
_mode = EDIT;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
int display_mid() const { return 60; } // half of the 120px logical width
|
||||
|
||||
void commitEdit() {
|
||||
_cfg.slots[_sel] = _edit;
|
||||
_cfg.slots[_sel].enabled = true; // saving an edit arms the alarm
|
||||
save();
|
||||
_mode = LIST;
|
||||
}
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
# Meck -- LilyGo T-Watch S3 16 MB custom partition layout
|
||||
#
|
||||
# Single factory app slot (no OTA): the watch has no SD card and does not
|
||||
# define MECK_OTA_UPDATE, so no firmware-OTA path is compiled -- an OTA
|
||||
# app1 slot would only waste flash. Maps get a dedicated LittleFS partition;
|
||||
# the DataStore keeps its own SPIFFS partition.
|
||||
#
|
||||
# Both data partitions use the 'spiffs' subtype (the standard ESP32 data
|
||||
# subtype). The DataStore is mounted as SPIFFS via SPIFFS.begin() (first
|
||||
# spiffs-subtype partition by offset); maps is mounted as LittleFS by the
|
||||
# explicit "maps" label, so the mount type is chosen at mount time, not by
|
||||
# subtype.
|
||||
#
|
||||
# NOTE: flashing this shifts the layout and wipes the existing SPIFFS --
|
||||
# re-provision identity/contacts after flashing.
|
||||
#
|
||||
# Name, Type, SubType, Offset, Size
|
||||
nvs, data, nvs, 0x9000, 0x5000
|
||||
app0, app, factory, 0x10000, 0x200000
|
||||
spiffs, data, spiffs, 0x210000, 0x400000
|
||||
maps, data, spiffs, 0x610000, 0x9F0000
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 12.
|
@@ -1,140 +0,0 @@
|
||||
[LilyGo_TWatchS3]
|
||||
extends = esp32_base
|
||||
extra_scripts = post:merge_firmware.py
|
||||
board = lilygo_twatch_s3
|
||||
board_build.flash_mode = qio
|
||||
board_build.f_flash = 80000000L
|
||||
board_build.arduino.memory_type = qio_opi
|
||||
board_upload.flash_size = 16MB
|
||||
board_build.partitions = variants/lilygo_twatch_s3/meck_twatch_16mb.csv
|
||||
build_flags =
|
||||
${esp32_base.build_flags}
|
||||
${sensor_base.build_flags}
|
||||
-I variants/lilygo_twatch_s3
|
||||
; MECK_TWATCH gates the watch form factor (240x240, UI_ZOOM=2, touch, lock
|
||||
; screen, tile grid, grey palette) and is shared with the S3 Plus.
|
||||
; LILYGO_TWATCH_S3 gates hardware unique to this board.
|
||||
-D MECK_TWATCH
|
||||
-D LILYGO_TWATCH_S3
|
||||
; The only key is the AXP2101 PWRON. Replaces PIN_USER_BTN, which is undefined
|
||||
; here because there is no GPIO button (see variant.h).
|
||||
-D MECK_PMU_BUTTON
|
||||
-D TWATCH_COMPOSE_ENABLED
|
||||
-D BOARD_HAS_PSRAM=1
|
||||
-D CORE_DEBUG_LEVEL=1
|
||||
-D FORMAT_SPIFFS_IF_FAILED=1
|
||||
-D FORMAT_LITTLEFS_IF_FAILED=1
|
||||
-D ARDUINO_USB_CDC_ON_BOOT=1
|
||||
; ---- LoRa SX1262 ----
|
||||
-D RADIO_CLASS=CustomSX1262
|
||||
-D WRAPPER_CLASS=CustomSX1262Wrapper
|
||||
-D LORA_TX_POWER=22
|
||||
-D SX126X_DIO2_AS_RF_SWITCH
|
||||
-D SX126X_DIO3_TCXO_VOLTAGE=1.8f
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
-D P_LORA_NSS=5
|
||||
-D P_LORA_DIO_1=9
|
||||
-D P_LORA_RESET=8
|
||||
-D P_LORA_BUSY=7
|
||||
-D P_LORA_SCLK=3
|
||||
-D P_LORA_MISO=4
|
||||
-D P_LORA_MOSI=1
|
||||
; ---- Display (ST7789V 240x240) + FT6336U touch via LovyanGFX ----
|
||||
-D DISPLAY_CLASS=TWatchS3Display
|
||||
-D UI_ZOOM=2
|
||||
; ---- Misc ----
|
||||
-D AUTO_SHUTDOWN_MILLIVOLTS=3100
|
||||
-D ARDUINO_LOOP_STACK_SIZE=32768
|
||||
; ---- No GPS ----
|
||||
; HAS_GPS is deliberately left undefined, which drops the watch map screen.
|
||||
; ENV_INCLUDE_GPS must be explicitly zeroed: sensor_base defaults it to 1, and
|
||||
; it gates gpsStream, board.gpsPowerOn/Off and the GPS home page.
|
||||
-D ENV_INCLUDE_GPS=0
|
||||
-D ENV_INCLUDE_AHTX0=0
|
||||
-D ENV_INCLUDE_BME280=0
|
||||
-D ENV_INCLUDE_BMP280=0
|
||||
-D ENV_INCLUDE_SHTC3=0
|
||||
-D ENV_INCLUDE_SHT4X=0
|
||||
-D ENV_INCLUDE_LPS22HB=0
|
||||
-D ENV_INCLUDE_INA3221=0
|
||||
-D ENV_INCLUDE_INA219=0
|
||||
-D ENV_INCLUDE_INA226=0
|
||||
-D ENV_INCLUDE_INA260=0
|
||||
-D ENV_INCLUDE_MLX90614=0
|
||||
-D ENV_INCLUDE_VL53L0X=0
|
||||
-D ENV_INCLUDE_BME680=0
|
||||
-D ENV_INCLUDE_BMP085=0
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/lilygo_twatch_s3>
|
||||
+<helpers/sensors/*.cpp>
|
||||
lib_deps =
|
||||
${esp32_base.lib_deps}
|
||||
${sensor_base.lib_deps}
|
||||
lovyan03/LovyanGFX @ ^1.2.0
|
||||
lewisxhe/XPowersLib @ ^0.2.7
|
||||
lewisxhe/SensorLib @ ^0.4.1
|
||||
adafruit/Adafruit GFX Library @ ^1.11.0
|
||||
bitbank2/PNGdec @ ^1.0.1
|
||||
WebServer
|
||||
DNSServer
|
||||
Update
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Standalone (no BLE companion), touch + display + lock screen.
|
||||
; MAX_CONTACTS=2000 -- contact + sort arrays allocated in PSRAM via
|
||||
; BaseChatMesh::initContacts().
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:meck_twatch_s3_standalone]
|
||||
extends = LilyGo_TWatchS3
|
||||
build_flags =
|
||||
${LilyGo_TWatchS3.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=2000
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D OFFLINE_QUEUE_SIZE=1
|
||||
-D ESP32_CPU_FREQ=80
|
||||
-D FIRMWARE_VERSION='"Meck TWatch S3 v0.3"'
|
||||
build_src_filter = ${LilyGo_TWatchS3.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
-<helpers/esp32/SerialBLEInterface.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/LGFXDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TWatchS3.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
lib_ignore =
|
||||
AsyncTCP
|
||||
ESPAsyncWebServer
|
||||
ESP32 BLE Arduino
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; BLE companion build -- same touch UI as the standalone, plus the BLE serial
|
||||
; interface so a phone app can connect on occasion.
|
||||
; Flash: pio run -e meck_twatch_s3_ble -t upload
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:meck_twatch_s3_ble]
|
||||
extends = LilyGo_TWatchS3
|
||||
build_flags =
|
||||
${LilyGo_TWatchS3.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=2000
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D ESP32_CPU_FREQ=80
|
||||
-D FIRMWARE_VERSION='"Meck TWatch S3 BLE v0.3"'
|
||||
build_src_filter = ${LilyGo_TWatchS3.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/LGFXDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TWatchS3.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
lib_ignore =
|
||||
AsyncTCP
|
||||
ESPAsyncWebServer
|
||||
@@ -1,67 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include "variant.h"
|
||||
#include "target.h"
|
||||
|
||||
TWatchS3Board board;
|
||||
|
||||
// LoRa SX1262 (HPD16B3 module) on its own SPI bus (the display uses SPI3_HOST
|
||||
// separately).
|
||||
static SPIClass loraSpi;
|
||||
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, loraSpi);
|
||||
|
||||
WRAPPER_CLASS radio_driver(radio, board);
|
||||
|
||||
ESP32RTCClock fallback_clock;
|
||||
AutoDiscoverRTCClock rtc_clock(fallback_clock);
|
||||
|
||||
// No GNSS and no environment sensors on this board, so the base SensorManager
|
||||
// is used. node_lat/node_lon stay at 0 and advert location policy has nothing
|
||||
// to publish.
|
||||
SensorManager sensors;
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
DISPLAY_CLASS display;
|
||||
PMUButton user_btn(board);
|
||||
#endif
|
||||
|
||||
bool radio_init() {
|
||||
// NOTE: board.begin() is called by main.cpp setup() before radio_init();
|
||||
// Wire is already initialised there with the correct pins.
|
||||
fallback_clock.begin();
|
||||
rtc_clock.begin(Wire);
|
||||
|
||||
loraSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS);
|
||||
return radio.std_init(&loraSpi);
|
||||
}
|
||||
|
||||
uint32_t radio_get_rng_seed() {
|
||||
return radio.random(0x7FFFFFFF);
|
||||
}
|
||||
|
||||
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
|
||||
radio.setFrequency(freq);
|
||||
radio.setSpreadingFactor(sf);
|
||||
radio.setBandwidth(bw);
|
||||
radio.setCodingRate(cr);
|
||||
|
||||
// Longer preamble for low SF improves reliability -- each symbol is shorter
|
||||
// at low SF, so more symbols are needed for reliable detection.
|
||||
uint16_t preamble = (sf <= 9) ? 32 : 16;
|
||||
radio.setPreambleLength(preamble);
|
||||
}
|
||||
|
||||
void radio_set_tx_power(uint8_t dbm) {
|
||||
radio.setOutputPower(dbm);
|
||||
}
|
||||
|
||||
mesh::LocalIdentity radio_new_identity() {
|
||||
RadioNoiseListener rng(radio);
|
||||
return mesh::LocalIdentity(&rng);
|
||||
}
|
||||
|
||||
void radio_reset_agc() {
|
||||
// Power-saving RX gain on the watch (deliberate): boosted gain costs
|
||||
// ~0.6-0.7 mA continuously for ~+2 dB sensitivity. The SetRxGain write
|
||||
// itself is what resets the AGC; false selects the power-saving value.
|
||||
radio.setRxBoostedGainMode(false);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// Include variant.h first to ensure all board-specific defines are available
|
||||
#include "variant.h"
|
||||
|
||||
#define RADIOLIB_STATIC_ONLY 1
|
||||
#include <RadioLib.h>
|
||||
#include <helpers/radiolib/RadioLibWrappers.h>
|
||||
#include <helpers/radiolib/CustomSX1262Wrapper.h>
|
||||
#include <TWatchS3Board.h>
|
||||
#include <helpers/AutoDiscoverRTCClock.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include <TWatchS3Display.h>
|
||||
#include <PMUButton.h>
|
||||
#endif
|
||||
|
||||
extern TWatchS3Board board;
|
||||
extern WRAPPER_CLASS radio_driver;
|
||||
extern AutoDiscoverRTCClock rtc_clock;
|
||||
extern SensorManager sensors;
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
extern DISPLAY_CLASS display;
|
||||
// Not a MomentaryButton: the only key on this board is the AXP2101 PWRON.
|
||||
extern PMUButton user_btn;
|
||||
#endif
|
||||
|
||||
bool radio_init();
|
||||
uint32_t radio_get_rng_seed();
|
||||
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr);
|
||||
void radio_set_tx_power(uint8_t dbm);
|
||||
mesh::LocalIdentity radio_new_identity();
|
||||
void radio_reset_agc();
|
||||
@@ -1,97 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// LilyGo T-Watch S3 (non-GPS, 470 mAh) - Board-level pin definitions
|
||||
//
|
||||
// Sources, in order of authority:
|
||||
// 1. T_WATCH-S3 schematic (rev 25-03-24)
|
||||
// 2. Xinyuan-LilyGO/TTGO_TWatch_Library, branch t-watch-s3, src/utilities.h
|
||||
// 3. LilyGo hardware doc docs/hardware/lilygo-t-watch-s3.md
|
||||
//
|
||||
// Every pin below is identical to the T-Watch S3 Plus. The two boards differ in
|
||||
// what is *populated*, not in how the ESP32-S3 is wired:
|
||||
// - no onboard GNSS (the Plus has a MIA-M10Q on BLDO1; here BLDO1 is unused
|
||||
// and GPIO 41/42 go to an optional external GPS shield)
|
||||
// - no GPIO user button; the only control is the side PWR key, which is wired
|
||||
// to the AXP2101 PWRON pin (schematic sheet 1: SW7 -> PWR_KEY -> pin 30)
|
||||
// - MAX98357A speaker amp on DLDO1, PDM mic on +3V3, IR LED on GPIO2 -- none
|
||||
// of which Meck compiles in
|
||||
//
|
||||
// NOTE: LoRa (P_LORA_*) pins and the SX126x radio parameters are supplied as
|
||||
// -D build flags in this variant's platformio.ini, matching the T-Watch S3 Plus
|
||||
// and T-Deck Pro convention. The display SPI/backlight and FT6336U touch pins
|
||||
// live inline in TWatchS3Display.h because LovyanGFX needs them in its panel
|
||||
// config.
|
||||
// =============================================================================
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Main I2C bus (shared): AXP2101 PMU, PCF8563 RTC, BMA423 accel, DRV2605 haptic
|
||||
// The FT6336U touch panel is on a SEPARATE bus (Wire1), configured in the
|
||||
// display class -- it is not on this bus.
|
||||
// -----------------------------------------------------------------------------
|
||||
#define I2C_SDA 10
|
||||
#define I2C_SCL 11
|
||||
|
||||
// Aliases for ESP32Board base class compatibility
|
||||
#define PIN_BOARD_SDA I2C_SDA
|
||||
#define PIN_BOARD_SCL I2C_SCL
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// I2C device addresses (7-bit)
|
||||
// -----------------------------------------------------------------------------
|
||||
#define I2C_ADDR_PMU 0x34 // AXP2101 power management
|
||||
#define I2C_ADDR_RTC 0x51 // PCF8563 real-time clock
|
||||
#define I2C_ADDR_ACCEL 0x19 // BMA423 accelerometer
|
||||
#define I2C_ADDR_HAPTIC 0x5A // DRV2605 haptic driver
|
||||
#define I2C_ADDR_TOUCH 0x38 // FT6336U capacitive touch (on Wire1)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Interrupt / control pins
|
||||
// -----------------------------------------------------------------------------
|
||||
#ifndef PIN_PMU_IRQ
|
||||
#define PIN_PMU_IRQ 21 // AXP2101 interrupt (open-drain, active LOW)
|
||||
#endif
|
||||
#define PIN_RTC_IRQ 17 // PCF8563 interrupt
|
||||
#define PIN_ACCEL_IRQ 14 // BMA423 interrupt
|
||||
|
||||
// User button: deliberately NOT defined.
|
||||
//
|
||||
// The T-Watch S3 has no GPIO button. Schematic sheet 1 wires the side tact
|
||||
// switch SW7 to net PWR_KEY, which lands on AXP2101 pin 30 (PWRON). Button
|
||||
// events therefore arrive over I2C as PMU interrupts, not as a pin level, and
|
||||
// are read by PMUButton (see PMUButton.h). MECK_PMU_BUTTON is defined in this
|
||||
// variant's platformio.ini in place of PIN_USER_BTN.
|
||||
//
|
||||
// #define PIN_USER_BTN <none>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Display dimensions (ST7789V 1.54" IPS, 240x240)
|
||||
// -----------------------------------------------------------------------------
|
||||
#define LCD_HOR_SIZE 240
|
||||
#define LCD_VER_SIZE 240
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Storage
|
||||
// -----------------------------------------------------------------------------
|
||||
// The T-Watch S3 has no SD card slot. Notes/Reader/Epub screens reference
|
||||
// SDCARD_CS unconditionally, so it must be defined for them to compile. -1 is a
|
||||
// safe no-op on ESP32 (digitalWrite/pinMode reject out-of-range pins), and SD
|
||||
// mounts will simply fail at runtime.
|
||||
#define SDCARD_CS -1
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// GPS: none. HAS_GPS and ENV_INCLUDE_GPS are left undefined / zeroed in
|
||||
// platformio.ini, which drops the map screen, the GPS home page and the BLDO1
|
||||
// rail control. The optional external GPS shield lands on GPIO41 (RX) /
|
||||
// GPIO42 (TX) if it is ever wired up.
|
||||
// -----------------------------------------------------------------------------
|
||||
// #define HAS_GPS 1
|
||||
// #define GPS_BAUDRATE 38400
|
||||
// #define GPS_RX_PIN 41
|
||||
// #define GPS_TX_PIN 42
|
||||
|
||||
// Fallback for code that references GPS_BAUDRATE without a HAS_GPS guard
|
||||
// (e.g. MyMesh.cpp "gps.baud" CLI command)
|
||||
#ifndef GPS_BAUDRATE
|
||||
#define GPS_BAUDRATE 9600
|
||||
#endif
|
||||
@@ -1,113 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
// CPU Frequency Scaling for ESP32-S3
|
||||
//
|
||||
// Typical current draw (CPU only, rough):
|
||||
// 240 MHz ~70-80 mA
|
||||
// 160 MHz ~50-60 mA
|
||||
// 80 MHz ~30-40 mA
|
||||
// 40 MHz ~15-20 mA (low-power / lock screen mode)
|
||||
//
|
||||
// SPI peripherals and UART use their own clock dividers from the APB clock,
|
||||
// so LoRa, e-ink, and GPS serial all work fine at 80MHz and 40MHz.
|
||||
|
||||
#ifdef ESP32
|
||||
|
||||
#ifndef CPU_FREQ_IDLE
|
||||
#define CPU_FREQ_IDLE 80 // MHz — normal mesh listening
|
||||
#endif
|
||||
|
||||
#ifndef CPU_FREQ_BOOST
|
||||
#define CPU_FREQ_BOOST 240 // MHz — heavy processing
|
||||
#endif
|
||||
|
||||
#ifndef CPU_FREQ_LOW_POWER
|
||||
#define CPU_FREQ_LOW_POWER 80 // MHz — lock screen / idle standby (40 MHz breaks I2C)
|
||||
#endif
|
||||
|
||||
#ifndef CPU_BOOST_TIMEOUT_MS
|
||||
#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds
|
||||
#endif
|
||||
|
||||
class CPUPowerManager {
|
||||
public:
|
||||
CPUPowerManager() : _boosted(false), _lowPower(false), _boost_started(0) {}
|
||||
|
||||
void begin() {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
_boosted = false;
|
||||
_lowPower = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (_boosted && (millis() - _boost_started >= CPU_BOOST_TIMEOUT_MS)) {
|
||||
// Return to low-power if locked, otherwise normal idle
|
||||
if (_lowPower) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_LOW_POWER);
|
||||
MESH_DEBUG_PRINTLN("CPU power: boost expired, returning to low-power %d MHz", CPU_FREQ_LOW_POWER);
|
||||
} else {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
_boosted = false;
|
||||
}
|
||||
}
|
||||
|
||||
void setBoost() {
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_BOOST);
|
||||
_boosted = true;
|
||||
MESH_DEBUG_PRINTLN("CPU power: boosted to %d MHz", CPU_FREQ_BOOST);
|
||||
}
|
||||
_boost_started = millis();
|
||||
}
|
||||
|
||||
void setIdle() {
|
||||
if (_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
_boosted = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
if (_lowPower) {
|
||||
_lowPower = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Low-power mode — drops CPU to 40 MHz for lock screen standby.
|
||||
// If currently boosted, the boost timeout will return to 40 MHz
|
||||
// instead of 80 MHz.
|
||||
void setLowPower() {
|
||||
_lowPower = true;
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_LOW_POWER);
|
||||
MESH_DEBUG_PRINTLN("CPU power: low-power at %d MHz", CPU_FREQ_LOW_POWER);
|
||||
}
|
||||
// If boosted, the loop() timeout will drop to low-power instead of idle
|
||||
}
|
||||
|
||||
// Exit low-power mode — returns to normal idle (80 MHz).
|
||||
// If currently boosted, the boost timeout will return to idle
|
||||
// instead of low-power.
|
||||
void clearLowPower() {
|
||||
_lowPower = false;
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz (low-power cleared)", CPU_FREQ_IDLE);
|
||||
}
|
||||
// If boosted, the loop() timeout will drop to idle as normal
|
||||
}
|
||||
|
||||
bool isBoosted() const { return _boosted; }
|
||||
bool isLowPower() const { return _lowPower; }
|
||||
uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); }
|
||||
|
||||
private:
|
||||
bool _boosted;
|
||||
bool _lowPower;
|
||||
unsigned long _boost_started;
|
||||
};
|
||||
|
||||
#endif // ESP32
|
||||
@@ -1,72 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
// Transparent Stream wrapper that counts NMEA sentences (newline-delimited)
|
||||
// flowing from the GPS serial port to the MicroNMEA parser.
|
||||
//
|
||||
// Usage: Instead of MicroNMEALocationProvider gps(Serial2, &rtc_clock);
|
||||
// Use: GPSStreamCounter gpsStream(Serial2);
|
||||
// MicroNMEALocationProvider gps(gpsStream, &rtc_clock);
|
||||
//
|
||||
// Every read() call passes through to the underlying stream; when a '\n'
|
||||
// is seen the sentence counter increments. This lets the UI display a
|
||||
// live "nmea" count so users can confirm the baud rate is correct and
|
||||
// the GPS module is actually sending data.
|
||||
|
||||
class GPSStreamCounter : public Stream {
|
||||
public:
|
||||
GPSStreamCounter(Stream& inner)
|
||||
: _inner(inner), _sentences(0), _sentences_snapshot(0),
|
||||
_last_snapshot(0), _sentences_per_sec(0) {}
|
||||
|
||||
// --- Stream read interface (passes through) ---
|
||||
int available() override { return _inner.available(); }
|
||||
int peek() override { return _inner.peek(); }
|
||||
|
||||
int read() override {
|
||||
int c = _inner.read();
|
||||
if (c == '\n') {
|
||||
_sentences++;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// --- Stream write interface (pass through for NMEA commands if needed) ---
|
||||
size_t write(uint8_t b) override { return _inner.write(b); }
|
||||
|
||||
// --- Sentence counting API ---
|
||||
|
||||
// Total sentences received since boot (or last reset)
|
||||
uint32_t getSentenceCount() const { return _sentences; }
|
||||
|
||||
// Sentences received per second (updated each time you call it,
|
||||
// with a 1-second rolling window)
|
||||
uint16_t getSentencesPerSec() {
|
||||
unsigned long now = millis();
|
||||
unsigned long elapsed = now - _last_snapshot;
|
||||
if (elapsed >= 1000) {
|
||||
uint32_t delta = _sentences - _sentences_snapshot;
|
||||
// Scale to per-second if interval wasn't exactly 1000ms
|
||||
_sentences_per_sec = (uint16_t)((delta * 1000UL) / elapsed);
|
||||
_sentences_snapshot = _sentences;
|
||||
_last_snapshot = now;
|
||||
}
|
||||
return _sentences_per_sec;
|
||||
}
|
||||
|
||||
// Reset all counters (e.g. when GPS hardware power cycles)
|
||||
void resetCounters() {
|
||||
_sentences = 0;
|
||||
_sentences_snapshot = 0;
|
||||
_sentences_per_sec = 0;
|
||||
_last_snapshot = millis();
|
||||
}
|
||||
|
||||
private:
|
||||
Stream& _inner;
|
||||
volatile uint32_t _sentences;
|
||||
uint32_t _sentences_snapshot;
|
||||
unsigned long _last_snapshot;
|
||||
uint16_t _sentences_per_sec;
|
||||
};
|
||||
@@ -1,795 +0,0 @@
|
||||
#pragma once
|
||||
// =============================================================================
|
||||
// TWatchComposeScreens — touch keyboard compose system for the LilyGo
|
||||
// T-Watch S3 Plus (ST7789 240x240, capacitive touch, companion_radio ui-new).
|
||||
//
|
||||
// Three UIScreen subclasses, compiled only when TWATCH_COMPOSE_ENABLED is
|
||||
// defined (companion build only). All drawing and hit-testing is in the
|
||||
// LGFXDisplay virtual coordinate space, which is 120x120 on this build
|
||||
// (240px panel / UI_ZOOM=2). getTouch() returns coords already divided by
|
||||
// UI_ZOOM, so a tap coordinate is directly comparable to draw coordinates.
|
||||
//
|
||||
// TWatchChannelPicker -- long-press on the home screen opens this. A single
|
||||
// tap selects the channel row under the finger; a
|
||||
// long-press on a row opens it; the back arrow
|
||||
// (top-left) returns home.
|
||||
// TWatchChannelScreen -- shows the most recent messages (sent + received)
|
||||
// for the selected channel, read live from the shared
|
||||
// ChannelScreen history store. Tap a line to ticker-
|
||||
// scroll it; tap the compose bar to open the keyboard;
|
||||
// back arrow returns home.
|
||||
// TWatchKeyboardScreen — on-screen QWERTY. Bottom-left mode key cycles
|
||||
// lower -> UPPER -> SYM. Bottom row is mode | space |
|
||||
// enter | backspace. Enter sends on the channel; back
|
||||
// arrow returns home.
|
||||
//
|
||||
// Each screen reads getTouch() itself in poll() and does its own release-edge
|
||||
// detection, so actions fire on touch release (finger up) — this keeps the
|
||||
// held finger from carrying into the next screen after a transition. Send/exit
|
||||
// are delegated to UITask via the consume/flag pattern; message history and
|
||||
// unread counts are read from the shared ChannelScreen store.
|
||||
// =============================================================================
|
||||
|
||||
#ifdef TWATCH_COMPOSE_ENABLED
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <string.h>
|
||||
#include <helpers/ui/LGFXDisplay.h>
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include "ChannelScreen.h" // shared message history store (-I examples/companion_radio/ui-new)
|
||||
|
||||
// ---- Tunables ---------------------------------------------------------------
|
||||
#define TW_LONG_PRESS_MS 600 // hold to select a channel in the picker
|
||||
#define TW_OUT_BUF_LEN 134 // MeshCore per-channel msg cap (~133) + NUL
|
||||
#define TW_TICKER_MS_PER_PX 20 // channel-screen ticker scroll speed (ms per pixel)
|
||||
#define TW_CH_NAME_LEN 32
|
||||
#define TW_PICKER_MAX 20 // matches MAX_GROUP_CHANNELS
|
||||
|
||||
// =============================================================================
|
||||
// TWatchChannelPicker
|
||||
// =============================================================================
|
||||
class TWatchChannelPicker : public UIScreen {
|
||||
DisplayDriver* _display;
|
||||
|
||||
struct Entry { uint8_t idx; char name[TW_CH_NAME_LEN]; };
|
||||
Entry _channels[TW_PICKER_MAX];
|
||||
uint8_t _numChannels;
|
||||
uint8_t _highlighted;
|
||||
uint8_t _scrollTop;
|
||||
ChannelScreen* _store; // unread badge source
|
||||
|
||||
// touch edge state
|
||||
bool _touchDown;
|
||||
int _downX, _downY;
|
||||
unsigned long _downAt;
|
||||
|
||||
// cross-screen flags (UITask polls these)
|
||||
bool _confirmed;
|
||||
bool _wantsExit;
|
||||
|
||||
static const int HEADER_H = 14;
|
||||
static const int ROW_H = 14;
|
||||
|
||||
int visibleRows() const {
|
||||
int h = _display ? _display->height() : 120;
|
||||
int rows = (h - HEADER_H) / ROW_H;
|
||||
return rows < 1 ? 1 : rows;
|
||||
}
|
||||
void ensureVisible() {
|
||||
int vis = visibleRows();
|
||||
if (_highlighted < _scrollTop) _scrollTop = _highlighted;
|
||||
else if (_highlighted >= _scrollTop + vis) _scrollTop = (uint8_t)(_highlighted - vis + 1);
|
||||
}
|
||||
void moveUp() {
|
||||
if (_numChannels == 0) return;
|
||||
_highlighted = (_highlighted == 0) ? (uint8_t)(_numChannels - 1) : (uint8_t)(_highlighted - 1);
|
||||
ensureVisible();
|
||||
}
|
||||
void moveDown() {
|
||||
if (_numChannels == 0) return;
|
||||
_highlighted = (uint8_t)((_highlighted + 1) % _numChannels);
|
||||
ensureVisible();
|
||||
}
|
||||
// Map a draw-space y coordinate to a channel index, or -1 if the tap is
|
||||
// above the list or on an empty row. getTouch() returns coords already
|
||||
// divided by UI_ZOOM, so _downY is directly comparable to the row layout.
|
||||
int rowAtY(int y) const {
|
||||
if (y < HEADER_H) return -1;
|
||||
int i = (y - HEADER_H) / ROW_H;
|
||||
if (i < 0 || i >= visibleRows()) return -1;
|
||||
int ci = _scrollTop + i;
|
||||
if (ci >= _numChannels) return -1;
|
||||
return ci;
|
||||
}
|
||||
|
||||
public:
|
||||
TWatchChannelPicker(DisplayDriver* display)
|
||||
: _display(display), _numChannels(0), _highlighted(0), _scrollTop(0),
|
||||
_store(nullptr),
|
||||
_touchDown(false), _downX(0), _downY(0), _downAt(0),
|
||||
_confirmed(false), _wantsExit(false) {
|
||||
memset(_channels, 0, sizeof(_channels));
|
||||
}
|
||||
|
||||
// Shared history store, used for the per-channel unread badges.
|
||||
void setStore(ChannelScreen* store) { _store = store; }
|
||||
|
||||
// Called by UITask before showing the screen.
|
||||
void beginChannelSelect() {
|
||||
_numChannels = 0; _highlighted = 0; _scrollTop = 0;
|
||||
_touchDown = false; _confirmed = false; _wantsExit = false;
|
||||
}
|
||||
void addChannel(uint8_t idx, const char* name) {
|
||||
if (_numChannels >= TW_PICKER_MAX) return;
|
||||
_channels[_numChannels].idx = idx;
|
||||
strncpy(_channels[_numChannels].name, name ? name : "", TW_CH_NAME_LEN - 1);
|
||||
_channels[_numChannels].name[TW_CH_NAME_LEN - 1] = 0;
|
||||
_numChannels++;
|
||||
}
|
||||
|
||||
// UITask bridges
|
||||
bool isConfirmed() const { return _confirmed; }
|
||||
void acknowledgeConfirm() { _confirmed = false; }
|
||||
uint8_t getSelectedChannelIdx() const { return _channels[_highlighted].idx; }
|
||||
const char* getSelectedChannelName() const { return _channels[_highlighted].name; }
|
||||
bool wantsExit() const { return _wantsExit; }
|
||||
void acknowledgeExit() { _wantsExit = false; }
|
||||
|
||||
bool handleInput(char c) override { return false; } // touch handled in poll()
|
||||
|
||||
void poll() override {
|
||||
if (!_display) return;
|
||||
int x, y;
|
||||
bool now = ((LGFXDisplay*)_display)->getTouch(&x, &y);
|
||||
if (now && !_touchDown) {
|
||||
_touchDown = true; _downX = x; _downY = y; _downAt = millis();
|
||||
} else if (!now && _touchDown) {
|
||||
_touchDown = false;
|
||||
unsigned long held = millis() - _downAt;
|
||||
if (_downX < 20 && _downY < HEADER_H) { _wantsExit = true; return; } // back arrow
|
||||
int row = rowAtY(_downY);
|
||||
if (row < 0) return; // tap outside the channel list
|
||||
_highlighted = (uint8_t)row; // single tap selects the tapped row
|
||||
ensureVisible();
|
||||
if (held >= TW_LONG_PRESS_MS) _confirmed = true; // long press opens it
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
const int W = display.width();
|
||||
display.setTextSize(1);
|
||||
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, 0);
|
||||
display.print("<");
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(W / 2, 0, "CHANNELS");
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, HEADER_H - 2, W, 1);
|
||||
|
||||
if (_numChannels == 0) {
|
||||
display.setCursor(2, HEADER_H + 2);
|
||||
display.print("(no channels)");
|
||||
return 500;
|
||||
}
|
||||
|
||||
int vis = visibleRows();
|
||||
int y = HEADER_H;
|
||||
for (int i = 0; i < vis; i++) {
|
||||
int ci = _scrollTop + i;
|
||||
if (ci >= _numChannels) break;
|
||||
if (ci == _highlighted) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.fillRect(0, y, W, ROW_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
int nameMaxW = W - 6;
|
||||
int unread = _store ? _store->getUnreadForChannel(_channels[ci].idx) : 0;
|
||||
if (unread > 0) {
|
||||
char cnt[8];
|
||||
snprintf(cnt, sizeof(cnt), "%d", unread > 99 ? 99 : unread);
|
||||
int cw = display.getTextWidth(cnt);
|
||||
display.setColor((ci == _highlighted) ? DisplayDriver::DARK : DisplayDriver::BLUE);
|
||||
display.drawTextRightAlign(W - 3, y + 2, cnt);
|
||||
display.setColor((ci == _highlighted) ? DisplayDriver::DARK : DisplayDriver::LIGHT);
|
||||
nameMaxW = W - 6 - cw - 4;
|
||||
}
|
||||
display.drawTextEllipsized(3, y + 2, nameMaxW, _channels[ci].name);
|
||||
y += ROW_H;
|
||||
}
|
||||
return 500;
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// TWatchChannelScreen
|
||||
// =============================================================================
|
||||
class TWatchChannelScreen : public UIScreen {
|
||||
DisplayDriver* _display;
|
||||
ChannelScreen* _store; // shared message history store
|
||||
uint8_t _channelIdx;
|
||||
char _channelName[TW_CH_NAME_LEN];
|
||||
char _selfPrefix[TW_CH_NAME_LEN + 2]; // "NodeName: " -- marks sent lines
|
||||
|
||||
bool _touchDown;
|
||||
int _downX, _downY;
|
||||
|
||||
bool _wantsCompose;
|
||||
bool _wantsExit;
|
||||
|
||||
int _selectedN; // recency index shown as ticker, -1 = none
|
||||
unsigned long _tickerStartMs;
|
||||
const void* _lastNewest; // newest store entry seen last render
|
||||
uint32_t _lastNewestTs; // (a change dismisses the ticker)
|
||||
bool _showPath; // hop-path overlay visible
|
||||
int _pathMsgN; // recency index the overlay describes
|
||||
|
||||
static const int HEADER_H = 14;
|
||||
static const int COMPOSE_BAR_H = 18;
|
||||
static const int MSG_LINE_H = 11;
|
||||
static const int MSG_TOP = HEADER_H + 2;
|
||||
|
||||
int rowsThatFit() const {
|
||||
int h = _display ? _display->height() : 120;
|
||||
int rows = (h - COMPOSE_BAR_H - 1 - MSG_TOP) / MSG_LINE_H;
|
||||
return rows < 1 ? 1 : rows;
|
||||
}
|
||||
|
||||
// Store messages available for this channel, capped at what fits on screen.
|
||||
int visibleCount() const {
|
||||
if (!_store) return 0;
|
||||
int maxRows = rowsThatFit();
|
||||
int n = 0;
|
||||
while (n < maxRows && _store->getChannelMsgByRecency(_channelIdx, n)) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Sent messages are stored with path_len 0 and a "NodeName: " prefix.
|
||||
bool isSent(const ChannelScreen::ChannelMessage* m) const {
|
||||
return m->path_len == 0 && _selfPrefix[0] != 0 &&
|
||||
strncmp(m->text, _selfPrefix, strlen(_selfPrefix)) == 0;
|
||||
}
|
||||
|
||||
void drawMsgLine(DisplayDriver& display, int y, const char* text, bool sent) {
|
||||
const int W = display.width();
|
||||
const int maxW = W - 4;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
if (!sent) {
|
||||
display.drawTextEllipsized(2, y, maxW, text); // incoming: left-aligned
|
||||
return;
|
||||
}
|
||||
if (display.getTextWidth(text) <= maxW) { // sent: right-aligned
|
||||
display.drawTextRightAlign(W - 2, y, text);
|
||||
return;
|
||||
}
|
||||
char buf[CHANNEL_MSG_TEXT_LEN + 4];
|
||||
strncpy(buf, text, sizeof(buf) - 4);
|
||||
buf[sizeof(buf) - 4] = 0;
|
||||
int ellW = display.getTextWidth("...");
|
||||
int len = (int)strlen(buf);
|
||||
while (len > 0 && display.getTextWidth(buf) > maxW - ellW) { buf[--len] = 0; }
|
||||
strcat(buf, "...");
|
||||
display.drawTextRightAlign(W - 2, y, buf);
|
||||
}
|
||||
|
||||
void drawTicker(DisplayDriver& display, int y, const char* text, bool sent) {
|
||||
const int W = display.width();
|
||||
int textW = display.getTextWidth(text);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
if (textW <= W - 4) { // fits -> nothing to scroll, keep alignment
|
||||
if (sent) display.drawTextRightAlign(W - 2, y, text);
|
||||
else { display.setCursor(2, y); display.print(text); }
|
||||
return;
|
||||
}
|
||||
int period = textW + 24; // full text width + trailing gap
|
||||
unsigned long elapsed = millis() - _tickerStartMs;
|
||||
int off = (int)((elapsed / TW_TICKER_MS_PER_PX) % (unsigned long)period);
|
||||
// Wrap off while the marquee draws: a long line must clip at the screen
|
||||
// edge, not wrap onto the rows below. Restored straight after.
|
||||
((LGFXDisplay*)_display)->setTextWrap(false);
|
||||
display.setCursor(2 - off, y);
|
||||
display.print(text);
|
||||
display.setCursor(2 - off + period, y);
|
||||
display.print(text);
|
||||
((LGFXDisplay*)_display)->setTextWrap(true);
|
||||
}
|
||||
|
||||
// Renders the "Heard by" list (repeaters that echoed our sent message + the
|
||||
// SNR we heard each at) into the already-drawn overlay box. Data comes from
|
||||
// MyMesh::getHeardBy, keyed by the fingerprint stashed in the sent bubble's
|
||||
// path[]. Same blue box as the hop-path overlay.
|
||||
void drawHeardBy(LGFXDisplay* d, const ChannelScreen::ChannelMessage* m,
|
||||
int bx, int by, int bh) {
|
||||
uint8_t bph = 0, count = 0;
|
||||
uint8_t hashes[16];
|
||||
int8_t snrs[8];
|
||||
(void)bh;
|
||||
the_mesh.getHeardBy(m->path, 12, bph, count, hashes, snrs, 8);
|
||||
|
||||
char head[28];
|
||||
snprintf(head, sizeof(head), "Heard by: %d", count); // distinct repeaters
|
||||
d->setRawColor(0x7BEF);
|
||||
d->printSmallFont(bx + 5, by + 8, head);
|
||||
|
||||
if (count == 0 || bph == 0) {
|
||||
d->setRawColor(0xFFFF);
|
||||
d->printSmallFont(bx + 5, by + 19, "No repeater echoes yet");
|
||||
return;
|
||||
}
|
||||
|
||||
const int maxLines = 5; // 5 repeaters, then a "+N more" 6th line
|
||||
int shown = (count > maxLines) ? maxLines : count;
|
||||
int y = by + 19;
|
||||
for (int i = 0; i < shown; i++) {
|
||||
const int off = i * bph;
|
||||
char hex[6];
|
||||
if (bph == 1) snprintf(hex, sizeof(hex), "%02x", hashes[off]);
|
||||
else snprintf(hex, sizeof(hex), "%02x%02x", hashes[off], hashes[off + 1]);
|
||||
|
||||
char nm[16]; nm[0] = 0;
|
||||
uint32_t numC = the_mesh.getNumContacts();
|
||||
ContactInfo c;
|
||||
for (int pass = 0; pass < 2 && nm[0] == 0; pass++) {
|
||||
for (uint32_t ci = 0; ci < numC; ci++) {
|
||||
if (!the_mesh.getContactByIdx(ci, c)) continue;
|
||||
if (memcmp(c.id.pub_key, &hashes[off], bph) != 0) continue;
|
||||
if (pass == 0 && c.type != ADV_TYPE_REPEATER) continue;
|
||||
strncpy(nm, c.name, sizeof(nm) - 1); nm[sizeof(nm) - 1] = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int8_t s = snrs[i];
|
||||
char line[40];
|
||||
snprintf(line, sizeof(line), "(%s) %s %d.%ddB",
|
||||
hex, nm[0] ? nm : "?", s / 4, ((abs((int)s) % 4) * 10) / 4);
|
||||
d->setRawColor(0xFFFF);
|
||||
d->printSmallFont(bx + 5, y, line);
|
||||
y += 9;
|
||||
}
|
||||
if (count > shown) {
|
||||
d->setRawColor(0xFFFF);
|
||||
char more[16];
|
||||
snprintf(more, sizeof(more), "+%d more", count - shown);
|
||||
d->printSmallFont(bx + 5, y, more);
|
||||
}
|
||||
}
|
||||
|
||||
// Hop-path overlay for the message at recency _pathMsgN. Drawn last so it
|
||||
// sits above the message list; toggled by the button (see handleInput),
|
||||
// dismissed by any tap (see poll).
|
||||
void drawPathOverlay(DisplayDriver& display) {
|
||||
const ChannelScreen::ChannelMessage* m =
|
||||
_store ? _store->getChannelMsgByRecency(_channelIdx, _pathMsgN) : nullptr;
|
||||
if (!m) { _showPath = false; return; }
|
||||
|
||||
LGFXDisplay* d = (LGFXDisplay*)&display;
|
||||
const int bx = 3, bw = display.width() - 6, by = 18, bh = 76;
|
||||
d->setRawColor(0x0000);
|
||||
d->fillRoundRect(bx, by, bw, bh, 4);
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(bx, by, bw, bh, 4);
|
||||
|
||||
// Sent message with a captured fingerprint: show which repeaters echoed it
|
||||
// ("Heard by") instead of a hop path, which a sent bubble doesn't have.
|
||||
if (m->path_len == 0 && m->has_fp) {
|
||||
drawHeardBy(d, m, bx, by, bh);
|
||||
return;
|
||||
}
|
||||
|
||||
char head[28];
|
||||
if (m->path_len == 0) {
|
||||
snprintf(head, sizeof(head), "%s", isSent(m) ? "Sent (no path)" : "Direct (0 hops)");
|
||||
} else if (m->path_len == 0xFF) {
|
||||
snprintf(head, sizeof(head), "%s", "Flood (no path)");
|
||||
} else {
|
||||
snprintf(head, sizeof(head), "%d hops, %d-byte",
|
||||
m->path_len & 63, (m->path_len >> 6) + 1);
|
||||
}
|
||||
d->setRawColor(0x7BEF);
|
||||
d->printSmallFont(bx + 5, by + 8, head);
|
||||
|
||||
if (m->path_len == 0 || m->path_len == 0xFF) return;
|
||||
|
||||
int byteLen = (int)mesh::Packet::getPathByteLenFor(m->path_len);
|
||||
if (byteLen > MSG_PATH_MAX) byteLen = MSG_PATH_MAX;
|
||||
int bph = (m->path_len >> 6) + 1;
|
||||
|
||||
// Hex hops in the path-editor format ("3601,2198,..."), wrapped
|
||||
// token-wise across small-font lines.
|
||||
char line[36];
|
||||
int ll = 0, y = by + 19;
|
||||
d->setRawColor(0xFFFF);
|
||||
for (int b = 0; b < byteLen; b += bph) {
|
||||
char tok[9];
|
||||
int tl = 0;
|
||||
for (int k = 0; k < bph && b + k < byteLen; k++)
|
||||
tl += snprintf(tok + tl, sizeof(tok) - tl, "%02x", m->path[b + k]);
|
||||
if (b + bph < byteLen) { tok[tl++] = ','; tok[tl] = 0; }
|
||||
if (ll + tl > 26) {
|
||||
line[ll] = 0;
|
||||
d->printSmallFont(bx + 5, y, line);
|
||||
y += 9; ll = 0;
|
||||
if (y > by + bh - 8) { d->printSmallFont(bx + 5, y, "..."); return; }
|
||||
}
|
||||
memcpy(line + ll, tok, tl); ll += tl;
|
||||
}
|
||||
if (ll > 0) { line[ll] = 0; d->printSmallFont(bx + 5, y, line); }
|
||||
}
|
||||
|
||||
public:
|
||||
TWatchChannelScreen(DisplayDriver* display)
|
||||
: _display(display), _store(nullptr), _channelIdx(0),
|
||||
_touchDown(false), _downX(0), _downY(0),
|
||||
_wantsCompose(false), _wantsExit(false),
|
||||
_selectedN(-1), _tickerStartMs(0),
|
||||
_lastNewest(nullptr), _lastNewestTs(0),
|
||||
_showPath(false), _pathMsgN(0) {
|
||||
_channelName[0] = 0;
|
||||
_selfPrefix[0] = 0;
|
||||
}
|
||||
|
||||
// Shared history store this screen renders from.
|
||||
void setStore(ChannelScreen* store) { _store = store; }
|
||||
|
||||
// Own node name, used to right-align sent lines ("NodeName: text").
|
||||
void setSelfName(const char* name) {
|
||||
if (!name || !name[0]) { _selfPrefix[0] = 0; return; }
|
||||
snprintf(_selfPrefix, sizeof(_selfPrefix), "%s: ", name);
|
||||
}
|
||||
|
||||
// Switch to a channel (called when a channel is selected in the picker).
|
||||
// Opening a channel marks its messages as read.
|
||||
void activate(uint8_t idx, const char* name) {
|
||||
_channelIdx = idx;
|
||||
strncpy(_channelName, name ? name : "", TW_CH_NAME_LEN - 1);
|
||||
_channelName[TW_CH_NAME_LEN - 1] = 0;
|
||||
_touchDown = false; _wantsCompose = false; _wantsExit = false;
|
||||
_selectedN = -1;
|
||||
_showPath = false;
|
||||
_lastNewest = nullptr; _lastNewestTs = 0;
|
||||
if (_store) _store->markChannelRead(_channelIdx);
|
||||
}
|
||||
|
||||
uint8_t getChannelIdx() const { return _channelIdx; }
|
||||
const char* getChannelName() const { return _channelName; }
|
||||
|
||||
bool wantsCompose() const { return _wantsCompose; }
|
||||
void acknowledgeCompose() { _wantsCompose = false; }
|
||||
bool wantsExit() const { return _wantsExit; }
|
||||
void acknowledgeExit() { _wantsExit = false; }
|
||||
|
||||
// Short press of the PWR key (S3, KEY_ENTER) or the boot button (S3 Plus,
|
||||
// KEY_NEXT): toggle the hop-path overlay for the ticker-selected message
|
||||
// (the newest one if none is selected). Touch is handled in poll().
|
||||
bool handleInput(char c) override {
|
||||
if (c == KEY_ENTER || c == KEY_NEXT) {
|
||||
if (_showPath) { _showPath = false; return true; }
|
||||
int n = (_selectedN >= 0) ? _selectedN : 0;
|
||||
if (_store && _store->getChannelMsgByRecency(_channelIdx, n)) {
|
||||
_pathMsgN = n;
|
||||
_showPath = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void poll() override {
|
||||
if (!_display) return;
|
||||
int x, y;
|
||||
bool now = ((LGFXDisplay*)_display)->getTouch(&x, &y);
|
||||
if (now && !_touchDown) {
|
||||
_touchDown = true; _downX = x; _downY = y;
|
||||
} else if (!now && _touchDown) {
|
||||
_touchDown = false;
|
||||
int H = _display->height();
|
||||
if (_showPath) { _showPath = false; return; } // any tap dismisses the path overlay
|
||||
if (_downX < 20 && _downY < HEADER_H) { _selectedN = -1; _wantsExit = true; return; } // back arrow
|
||||
if (_downY >= H - COMPOSE_BAR_H) { _selectedN = -1; _wantsCompose = true; return; } // compose bar
|
||||
// message area -> tap to open/close ticker
|
||||
if (_downY >= MSG_TOP && _downY < H - COMPOSE_BAR_H) {
|
||||
int visualRow = (_downY - MSG_TOP) / MSG_LINE_H;
|
||||
int count = visibleCount();
|
||||
if (visualRow >= 0 && visualRow < count) {
|
||||
int n = (count - 1) - visualRow; // top row = oldest visible
|
||||
if (_selectedN == n) _selectedN = -1;
|
||||
else { _selectedN = n; _tickerStartMs = millis(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
const int W = display.width();
|
||||
const int H = display.height();
|
||||
display.setTextSize(1);
|
||||
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, 0);
|
||||
display.print("<");
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(W / 2, 0, _channelName);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, HEADER_H - 2, W, 1);
|
||||
|
||||
// A new message (sent or received) shifts the rows, so dismiss any open
|
||||
// ticker when the newest store entry for this channel changes.
|
||||
const ChannelScreen::ChannelMessage* newest =
|
||||
_store ? _store->getChannelMsgByRecency(_channelIdx, 0) : nullptr;
|
||||
if (newest != _lastNewest || (newest && newest->timestamp != _lastNewestTs)) {
|
||||
_lastNewest = newest;
|
||||
_lastNewestTs = newest ? newest->timestamp : 0;
|
||||
_selectedN = -1;
|
||||
}
|
||||
|
||||
int count = visibleCount();
|
||||
if (count == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(2, MSG_TOP);
|
||||
display.print("(no messages)");
|
||||
} else {
|
||||
int y = MSG_TOP;
|
||||
for (int n = count - 1; n >= 0; n--) { // oldest visible (top) -> newest (bottom)
|
||||
const ChannelScreen::ChannelMessage* m = _store->getChannelMsgByRecency(_channelIdx, n);
|
||||
if (!m) continue;
|
||||
bool sent = isSent(m);
|
||||
if (n == _selectedN) drawTicker(display, y, m->text, sent);
|
||||
else drawMsgLine(display, y, m->text, sent);
|
||||
y += MSG_LINE_H;
|
||||
}
|
||||
}
|
||||
|
||||
// compose bar
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, H - COMPOSE_BAR_H, W, COMPOSE_BAR_H - 1);
|
||||
display.drawTextEllipsized(3, H - COMPOSE_BAR_H + 4, W - 6, "Tap to compose");
|
||||
if (_showPath) drawPathOverlay(display);
|
||||
return (_selectedN >= 0 || _showPath) ? 60 : 500;
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// TWatchKeyboardScreen
|
||||
// =============================================================================
|
||||
class TWatchKeyboardScreen : public UIScreen {
|
||||
DisplayDriver* _display;
|
||||
uint8_t _channelIdx;
|
||||
|
||||
public:
|
||||
enum Purpose { TWKB_CHANNEL, TWKB_DM, TWKB_ADMIN_PASSWORD, TWKB_ADMIN_CLI, TWKB_PATH, TWKB_NOTE, TWKB_SCOPE, TWKB_TRACE_PATH };
|
||||
|
||||
private:
|
||||
Purpose _purpose;
|
||||
int _contextIdx; // channel idx (channel) or contact idx (DM/admin)
|
||||
bool _mask; // render composed text as '*' (admin password)
|
||||
unsigned long _lastCharAt; // when the last char was typed (for reveal window)
|
||||
|
||||
static const unsigned long PW_REVEAL_MS = 2000; // show last char this long before masking
|
||||
|
||||
char _outBuf[TW_OUT_BUF_LEN];
|
||||
uint16_t _outLen;
|
||||
|
||||
enum Mode { LOWER, UPPER, SYM };
|
||||
Mode _mode;
|
||||
|
||||
bool _touchDown;
|
||||
int _downX, _downY;
|
||||
|
||||
bool _wantsSend;
|
||||
bool _wantsExit;
|
||||
|
||||
static const int MAXLEN = TW_OUT_BUF_LEN - 1; // 133
|
||||
|
||||
// layout geometry (120x120 virtual space)
|
||||
static const int TOPBAR_H = 15;
|
||||
static const int KEY_W = 12; // 10 * 12 = 120 wide
|
||||
static const int KEY_H = 26;
|
||||
static const int GRID_Y = 15; // row0 top; rows at GRID_Y + row*KEY_H
|
||||
|
||||
const char* const* layout() const {
|
||||
static const char* const lower[3] = { "qwertyuiop", "asdfghjkl'", "zxcvbnm,.?" };
|
||||
static const char* const upper[3] = { "QWERTYUIOP", "ASDFGHJKL\"", "ZXCVBNM<>/" };
|
||||
static const char* const syms[3] = { "123!@#$%^&", "456()[]{}-", "7890+=_:;|" };
|
||||
return _mode == SYM ? syms : (_mode == UPPER ? upper : lower);
|
||||
}
|
||||
const char* modeLabel() const {
|
||||
return _mode == SYM ? "SYM" : (_mode == UPPER ? "A-Z" : "a-z");
|
||||
}
|
||||
|
||||
void appendChar(char ch) {
|
||||
if (_outLen < MAXLEN) { _outBuf[_outLen++] = ch; _outBuf[_outLen] = 0; _lastCharAt = millis(); }
|
||||
}
|
||||
void backspace() {
|
||||
if (_outLen > 0) { _outLen--; _outBuf[_outLen] = 0; _lastCharAt = 0; }
|
||||
}
|
||||
void cycleMode() {
|
||||
_mode = (_mode == LOWER) ? UPPER : (_mode == UPPER ? SYM : LOWER);
|
||||
}
|
||||
// bottom row zones: mode 0..24 | space 24..72 | enter 72..96 | backspace 96..120
|
||||
void handleBottomRow(int x) {
|
||||
if (x < 24) cycleMode();
|
||||
else if (x < 72) appendChar(' ');
|
||||
else if (x < 96) { if (_outLen > 0) _wantsSend = true; }
|
||||
else backspace();
|
||||
}
|
||||
|
||||
public:
|
||||
TWatchKeyboardScreen(DisplayDriver* display)
|
||||
: _display(display), _channelIdx(0),
|
||||
_purpose(TWKB_CHANNEL), _contextIdx(0), _mask(false), _lastCharAt(0),
|
||||
_outLen(0), _mode(LOWER),
|
||||
_touchDown(false), _downX(0), _downY(0),
|
||||
_wantsSend(false), _wantsExit(false) {
|
||||
_outBuf[0] = 0;
|
||||
}
|
||||
|
||||
// Enter compose on the given channel (called when the compose bar is tapped).
|
||||
void activate(uint8_t idx, const char* name) {
|
||||
(void)name;
|
||||
_channelIdx = idx;
|
||||
_purpose = TWKB_CHANNEL;
|
||||
_contextIdx = idx;
|
||||
_mask = false;
|
||||
_outLen = 0; _outBuf[0] = 0;
|
||||
_mode = LOWER;
|
||||
_touchDown = false; _wantsSend = false; _wantsExit = false;
|
||||
}
|
||||
|
||||
// Enter text for a non-channel purpose (DM / admin password / admin CLI).
|
||||
void activateFor(Purpose purpose, int contextIdx) {
|
||||
_purpose = purpose;
|
||||
_contextIdx = contextIdx;
|
||||
_channelIdx = 0;
|
||||
_mask = (purpose == TWKB_ADMIN_PASSWORD);
|
||||
_outLen = 0; _outBuf[0] = 0;
|
||||
_mode = LOWER;
|
||||
_touchDown = false; _wantsSend = false; _wantsExit = false;
|
||||
}
|
||||
|
||||
Purpose getPurpose() const { return _purpose; }
|
||||
int getContextIdx() const { return _contextIdx; }
|
||||
|
||||
uint8_t getChannelIdx() const { return _channelIdx; }
|
||||
bool consumeSendRequest(const char** textOut) {
|
||||
if (!_wantsSend) return false;
|
||||
_wantsSend = false;
|
||||
if (textOut) *textOut = _outBuf;
|
||||
return true;
|
||||
}
|
||||
void clearOutBuf() { _outLen = 0; _outBuf[0] = 0; }
|
||||
// Pre-fill the compose buffer (e.g. editing an existing note). Call after
|
||||
// activateFor(), which clears the buffer; input is clamped at MAXLEN.
|
||||
void setInitialText(const char* s) {
|
||||
_outLen = 0;
|
||||
if (s) {
|
||||
while (s[_outLen] && _outLen < MAXLEN) { _outBuf[_outLen] = s[_outLen]; _outLen++; }
|
||||
}
|
||||
_outBuf[_outLen] = 0;
|
||||
}
|
||||
bool wantsExit() const { return _wantsExit; }
|
||||
void acknowledgeExit() { _wantsExit = false; }
|
||||
|
||||
bool handleInput(char c) override { return false; }
|
||||
|
||||
void poll() override {
|
||||
if (!_display) return;
|
||||
int x, y;
|
||||
bool now = ((LGFXDisplay*)_display)->getTouch(&x, &y);
|
||||
if (now && !_touchDown) {
|
||||
_touchDown = true; _downX = x; _downY = y;
|
||||
} else if (!now && _touchDown) {
|
||||
_touchDown = false;
|
||||
int x0 = _downX, y0 = _downY;
|
||||
if (x0 < 20 && y0 < TOPBAR_H) { _wantsExit = true; return; } // back arrow
|
||||
if (y0 < GRID_Y) return; // top bar (text) area
|
||||
int row = (y0 - GRID_Y) / KEY_H;
|
||||
if (row <= 2) {
|
||||
int col = x0 / KEY_W;
|
||||
char ch = layout()[row][col];
|
||||
if (ch) appendChar(ch);
|
||||
} else {
|
||||
handleBottomRow(x0); // bottom row
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
const int W = display.width();
|
||||
display.setTextSize(1);
|
||||
|
||||
// ---- top bar: back arrow + composed text tail + cursor ----
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, 0);
|
||||
display.print("<");
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
{
|
||||
// Mask the composed text with '*' for the admin password purpose.
|
||||
char masked[TW_OUT_BUF_LEN];
|
||||
const char* src = _outBuf;
|
||||
if (_mask) {
|
||||
int m = (_outLen < TW_OUT_BUF_LEN - 1) ? _outLen : TW_OUT_BUF_LEN - 1;
|
||||
for (int i = 0; i < m; i++) masked[i] = '*';
|
||||
// Reveal the most recently typed char briefly so it can be read.
|
||||
if (m > 0 && (millis() - _lastCharAt < PW_REVEAL_MS)) masked[m - 1] = _outBuf[m - 1];
|
||||
masked[m] = 0;
|
||||
src = masked;
|
||||
}
|
||||
const int avail = W - 16 - 4; // room after the back arrow, minus cursor
|
||||
int len = _outLen;
|
||||
int fit = 0;
|
||||
for (int n = 1; n <= len; n++) {
|
||||
if (display.getTextWidth(src + (len - n)) > avail) break;
|
||||
fit = n;
|
||||
}
|
||||
char tail[TW_OUT_BUF_LEN + 2];
|
||||
strncpy(tail, src + (len - fit), sizeof(tail) - 2);
|
||||
tail[sizeof(tail) - 2] = 0;
|
||||
size_t tl = strlen(tail);
|
||||
tail[tl] = '_'; tail[tl + 1] = 0;
|
||||
display.setCursor(16, 2);
|
||||
display.print(tail);
|
||||
}
|
||||
|
||||
// ---- key grid rows 0..2 ----
|
||||
const char* const* lay = layout();
|
||||
for (int row = 0; row < 3; row++) {
|
||||
int ry = GRID_Y + row * KEY_H;
|
||||
const char* r = lay[row];
|
||||
for (int col = 0; col < 10; col++) {
|
||||
int kx = col * KEY_W;
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawRect(kx, ry, KEY_W, KEY_H);
|
||||
char ch[2] = { r[col], 0 };
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
int tw = display.getTextWidth(ch);
|
||||
display.setCursor(kx + (KEY_W - tw) / 2, ry + (KEY_H - 8) / 2);
|
||||
display.print(ch);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- bottom row: mode | space | enter | backspace ----
|
||||
int by = GRID_Y + 3 * KEY_H;
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.fillRect(0, by, 24, KEY_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
{
|
||||
const char* ml = modeLabel();
|
||||
int tw = display.getTextWidth(ml);
|
||||
display.setCursor((24 - tw) / 2, by + (KEY_H - 8) / 2);
|
||||
display.print(ml);
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(24, by, 48, KEY_H); // space
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.fillRect(72, by, 24, KEY_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
{
|
||||
const char* el = "OK";
|
||||
int tw = display.getTextWidth(el);
|
||||
display.setCursor(72 + (24 - tw) / 2, by + (KEY_H - 8) / 2);
|
||||
display.print(el);
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::ORANGE);
|
||||
display.fillRect(96, by, 24, KEY_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
{
|
||||
const char* bl = "DEL";
|
||||
int tw = display.getTextWidth(bl);
|
||||
display.setCursor(96 + (24 - tw) / 2, by + (KEY_H - 8) / 2);
|
||||
display.print(bl);
|
||||
}
|
||||
|
||||
return 500;
|
||||
}
|
||||
};
|
||||
|
||||
#endif // TWATCH_COMPOSE_ENABLED
|
||||
@@ -1,220 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include "TWatchS3PlusBoard.h"
|
||||
#include <SensorBMA423.hpp>
|
||||
#include <esp_bt.h> // TEMP power-debug: esp_bt_controller_get_status()
|
||||
|
||||
volatile bool TWatchS3PlusBoard::_tilt_flag = false;
|
||||
|
||||
// memw forces the _tilt_flag store to retire to SRAM before this ISR
|
||||
// returns. Without it, tiltFired() in the main loop can read a stale
|
||||
// value and raise-to-wake is missed. Do not remove.
|
||||
void IRAM_ATTR TWatchS3PlusBoard::onTiltISR() { _tilt_flag = true; asm volatile("memw" ::: "memory"); }
|
||||
|
||||
// ---- Wrapper-free BMA423 step counter (raw I2C) ----------------------------
|
||||
// SensorLib's SensorBMA423 step-counter methods do not compile in this build,
|
||||
// so the step counter is driven directly over I2C. Register/offset/mask values
|
||||
// are from the Bosch BMA423 driver.
|
||||
#define BMA423_REG_STEP_CNT_OUT 0x1E // 4-byte little-endian step count output
|
||||
#define BMA423_REG_FEATURE_CONFIG 0x5E // 64-byte feature config stream
|
||||
#define BMA423_FEATURE_LEN 64
|
||||
#define BMA423_STEP_EN_BYTE 0x37 // BMA423_STEP_CNTR_OFFSET(0x36) + 1
|
||||
#define BMA423_STEP_EN_BIT 0x10 // BMA423_STEP_CNTR_EN_MSK
|
||||
// Step counter watermark: a 10-bit field (BMA423_STEP_CNTR_WM_MSK = 0x03FF)
|
||||
// spanning feature_config[0x36] as LSB and the low two bits of [0x37] as MSB.
|
||||
// It does not collide with the enable bit, which is bit 4 of [0x37] (bit 12 of
|
||||
// the 16-bit word). Bosch's bma423_step_counter_set_watermark() writes exactly
|
||||
// these bits; SensorLib's enableStepCounter() calls it with 1, and LilyGo's own
|
||||
// firmware calls setStepCounterWatermark(1). Meck never set it, which is the
|
||||
// prime suspect for the undercounting.
|
||||
#define BMA423_STEP_WM_LSB_BYTE 0x36 // BMA423_STEP_CNTR_OFFSET
|
||||
#define BMA423_STEP_WM_MSK 0x03FF // BMA423_STEP_CNTR_WM_MSK
|
||||
#define BMA423_STEP_WM_LEVEL 1 // matches LilyGoLib and the SensorLib example
|
||||
|
||||
#define BMA423_REG_POWER_CONF 0x7C // BMA4_POWER_CONF_ADDR
|
||||
#define BMA423_ADV_PWR_SAVE_BIT 0x01 // BMA4_ADVANCE_POWER_SAVE_MSK
|
||||
|
||||
static bool bma423ReadRegs(uint8_t reg, uint8_t* buf, uint8_t len) {
|
||||
Wire.beginTransmission(I2C_ADDR_ACCEL);
|
||||
Wire.write(reg);
|
||||
if (Wire.endTransmission(false) != 0) return false;
|
||||
if (Wire.requestFrom((int)I2C_ADDR_ACCEL, (int)len) != len) return false;
|
||||
for (uint8_t i = 0; i < len; i++) buf[i] = Wire.read();
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool bma423WriteRegs(uint8_t reg, const uint8_t* buf, uint8_t len) {
|
||||
Wire.beginTransmission(I2C_ADDR_ACCEL);
|
||||
Wire.write(reg);
|
||||
for (uint8_t i = 0; i < len; i++) Wire.write(buf[i]);
|
||||
return Wire.endTransmission() == 0;
|
||||
}
|
||||
|
||||
// Enable the step counter by setting its enable bit in the feature config,
|
||||
// preserving every other byte (tilt lives at a different offset, 0x3A, so it is
|
||||
// untouched). The feature config can only be written with advanced-power-save
|
||||
// disabled, so we bracket the write and restore the prior power state after.
|
||||
static void bma423EnableStepCounter() {
|
||||
uint8_t pc;
|
||||
if (!bma423ReadRegs(BMA423_REG_POWER_CONF, &pc, 1)) return; // save power state
|
||||
uint8_t off = pc & ~BMA423_ADV_PWR_SAVE_BIT; // disable adv power save
|
||||
bma423WriteRegs(BMA423_REG_POWER_CONF, &off, 1);
|
||||
delay(2); // wake from low-power (>=450us)
|
||||
|
||||
uint8_t cfg[BMA423_FEATURE_LEN];
|
||||
if (bma423ReadRegs(BMA423_REG_FEATURE_CONFIG, cfg, BMA423_FEATURE_LEN)) {
|
||||
// Watermark first, then the enable bit, in a single read-modify-write.
|
||||
uint16_t wm = ((uint16_t)cfg[BMA423_STEP_EN_BYTE] << 8) | cfg[BMA423_STEP_WM_LSB_BYTE];
|
||||
wm = (wm & ~BMA423_STEP_WM_MSK) | (BMA423_STEP_WM_LEVEL & BMA423_STEP_WM_MSK);
|
||||
cfg[BMA423_STEP_WM_LSB_BYTE] = (uint8_t)(wm & 0xFF);
|
||||
cfg[BMA423_STEP_EN_BYTE] = (uint8_t)((wm >> 8) & 0xFF);
|
||||
cfg[BMA423_STEP_EN_BYTE] |= BMA423_STEP_EN_BIT;
|
||||
bma423WriteRegs(BMA423_REG_FEATURE_CONFIG, cfg, BMA423_FEATURE_LEN);
|
||||
delay(1); // write settle
|
||||
}
|
||||
|
||||
bma423WriteRegs(BMA423_REG_POWER_CONF, &pc, 1); // restore power state
|
||||
}
|
||||
|
||||
void TWatchS3PlusBoard::begin() {
|
||||
ESP32Board::begin();
|
||||
power_init();
|
||||
|
||||
// BMA423 accelerometer (always-on I2C, 0x19): enable the tilt / wrist-raise
|
||||
// feature and its interrupt (routed to PIN1 -> GPIO14) for raise-to-wake.
|
||||
_accel = new SensorBMA423();
|
||||
if (_accel->begin(Wire, I2C_ADDR_ACCEL, PIN_BOARD_SDA, PIN_BOARD_SCL)) {
|
||||
_accel->setRemapAxes(SensorRemap::BOTTOM_LAYER_TOP_RIGHT_CORNER);
|
||||
// 100 Hz ODR: SensorLib's BMA423_StepDetector example runs the pedometer at
|
||||
// 100 Hz, not the 50 Hz used here previously. Everything else already matched
|
||||
// (NORMAL, FS_2G, OSR2_AVG2, CIC_AVG_MODE). Revert this one literal to 50.0f
|
||||
// to A/B it against the watermark change above.
|
||||
_accel->configAccelerometer(OperationMode::NORMAL, AccelFullScaleRange::FS_2G,
|
||||
100.0f, AccelBandwidth::OSR2_AVG2, AccelPerfMode::CIC_AVG_MODE);
|
||||
// INT1 pin electrical config: level trigger, active high, push-pull,
|
||||
// output enabled. INT1_IO_CTRL resets to output-disabled, so without
|
||||
// this the pin never drives and INPUT_PULLDOWN reads low forever.
|
||||
_accel->setInterruptPinConfig(InterruptPinMap::PIN1, false, false, true, false);
|
||||
pinMode(PIN_ACCEL_IRQ, INPUT_PULLDOWN);
|
||||
// Attach the edge ISR BEFORE enabling the tilt source, so the first
|
||||
// assertion cannot occur before the handler is armed (a missed first edge
|
||||
// on a self-clearing line otherwise locks tilt-wake out permanently).
|
||||
attachInterrupt(digitalPinToInterrupt(PIN_ACCEL_IRQ), onTiltISR, RISING);
|
||||
_accel->enableTiltDetector(true, true);
|
||||
// Enable the hardware step counter via raw I2C (SensorLib's wrapper method
|
||||
// does not compile in this build). It then counts in the BMA423 feature
|
||||
// engine with no CPU cost, even while the display is off.
|
||||
bma423EnableStepCounter();
|
||||
}
|
||||
|
||||
esp_reset_reason_t reason = esp_reset_reason();
|
||||
if (reason == ESP_RST_DEEPSLEEP) {
|
||||
long wakeup_source = esp_sleep_get_ext1_wakeup_status();
|
||||
if (wakeup_source & (1 << P_LORA_DIO_1)) {
|
||||
startup_reason = BD_STARTUP_RX_PACKET;
|
||||
}
|
||||
rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS);
|
||||
rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1);
|
||||
}
|
||||
}
|
||||
|
||||
bool TWatchS3PlusBoard::power_init() {
|
||||
PMU = new XPowersAXP2101(Wire, PIN_BOARD_SDA, PIN_BOARD_SCL, I2C_ADDR_PMU);
|
||||
if (!PMU->init()) {
|
||||
MESH_DEBUG_PRINTLN("Warning: Failed to find AXP2101 power management");
|
||||
delete PMU;
|
||||
PMU = NULL;
|
||||
return false;
|
||||
}
|
||||
|
||||
PMU->setChargingLedMode(XPOWERS_CHG_LED_CTRL_CHG);
|
||||
|
||||
// Power rails per the T-Watch S3 Plus PowerManage table:
|
||||
// ALDO2 = display backlight, ALDO3 = display + touch,
|
||||
// ALDO4 = LoRa, BLDO1 = GNSS, BLDO2 = DRV2605, ALDO1 = unused.
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO4, 3300); // LoRa radio
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO4);
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO3, 3300); // display + touch
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO3);
|
||||
PMU->setPowerChannelVoltage(XPOWERS_ALDO2, 3300); // display backlight
|
||||
PMU->enablePowerOutput(XPOWERS_ALDO2);
|
||||
PMU->setPowerChannelVoltage(XPOWERS_BLDO2, 3300); // DRV2605 haptic
|
||||
PMU->enablePowerOutput(XPOWERS_BLDO2);
|
||||
// GNSS (MIA-M10Q) on BLDO1 -- set the rail voltage but leave it OFF at boot.
|
||||
// It is powered on demand via gpsPowerOn() when gps_enabled is set.
|
||||
PMU->setPowerChannelVoltage(XPOWERS_BLDO1, 3300);
|
||||
PMU->disablePowerOutput(XPOWERS_BLDO1);
|
||||
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC2);
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC3);
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC4);
|
||||
PMU->disablePowerOutput(XPOWERS_DCDC5);
|
||||
PMU->disablePowerOutput(XPOWERS_ALDO1); // unused on the Plus
|
||||
PMU->disablePowerOutput(XPOWERS_DLDO1);
|
||||
PMU->disablePowerOutput(XPOWERS_DLDO2);
|
||||
PMU->disablePowerOutput(XPOWERS_VBACKUP);
|
||||
|
||||
PMU->disableIRQ(XPOWERS_AXP2101_ALL_IRQ);
|
||||
PMU->clearIrqStatus();
|
||||
|
||||
PMU->setChargerConstantCurr(XPOWERS_AXP2101_CHG_CUR_200MA);
|
||||
// The fitted cell (Tewaycell 112530-2P, label verified) is 3.8V nominal
|
||||
// LiHV -- i.e. 4.35V max charge, same chemistry as the S3's cell. Charging
|
||||
// to 4V2 forfeits the top ~12-15% of capacity.
|
||||
PMU->setChargeTargetVoltage(XPOWERS_AXP2101_CHG_VOL_4V35);
|
||||
|
||||
PMU->disableTSPinMeasure();
|
||||
PMU->enableSystemVoltageMeasure();
|
||||
PMU->enableVbusVoltageMeasure();
|
||||
PMU->enableBattVoltageMeasure();
|
||||
|
||||
PMU->setPowerKeyPressOffTime(XPOWERS_POWEROFF_4S);
|
||||
|
||||
// TEMP power-debug: one-shot rail dump at boot. Rail OFF means the attached
|
||||
// chip has no power at all (not merely sleeping).
|
||||
Serial.printf("[PWR] rails: ALDO2(bl)=%d ALDO3(disp/touch)=%d ALDO4(LoRa)=%d BLDO1(GPS)=%d BLDO2(haptic)=%d\n",
|
||||
PMU->isPowerChannelEnable(XPOWERS_ALDO2),
|
||||
PMU->isPowerChannelEnable(XPOWERS_ALDO3),
|
||||
PMU->isPowerChannelEnable(XPOWERS_ALDO4),
|
||||
PMU->isPowerChannelEnable(XPOWERS_BLDO1),
|
||||
PMU->isPowerChannelEnable(XPOWERS_BLDO2));
|
||||
Serial.printf("[PWR] GPS rail (BLDO1) at boot: %s\n",
|
||||
PMU->isPowerChannelEnable(XPOWERS_BLDO1) ? "ON" : "OFF (fully powered down)");
|
||||
return true;
|
||||
}
|
||||
|
||||
// TEMP power-debug probe.
|
||||
void TWatchS3PlusBoard::printPowerDebug() {
|
||||
if (!PMU) return;
|
||||
Serial.printf("[PWR] batt=%dmV %d%% vbus=%dmV charging=%d cpu=%dMHz bt=%d gps_rail=%s\n",
|
||||
PMU->getBattVoltage(), PMU->getBatteryPercent(),
|
||||
PMU->getVbusVoltage(), PMU->isCharging(),
|
||||
getCpuFrequencyMhz(), (int)esp_bt_controller_get_status(),
|
||||
PMU->isPowerChannelEnable(XPOWERS_BLDO1) ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
void TWatchS3PlusBoard::gpsPowerOn() {
|
||||
if (PMU) {
|
||||
PMU->enablePowerOutput(XPOWERS_BLDO1);
|
||||
delay(100); // allow the module to boot before we expect NMEA
|
||||
}
|
||||
}
|
||||
|
||||
void TWatchS3PlusBoard::gpsPowerOff() {
|
||||
if (PMU) PMU->disablePowerOutput(XPOWERS_BLDO1);
|
||||
}
|
||||
|
||||
bool TWatchS3PlusBoard::tiltFired() {
|
||||
if (_tilt_flag) { // set by the GPIO14 rising-edge ISR
|
||||
_tilt_flag = false;
|
||||
_accel->update(); // reading the status clears the sensor INT
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t TWatchS3PlusBoard::getStepCount() {
|
||||
uint8_t d[4];
|
||||
if (!bma423ReadRegs(BMA423_REG_STEP_CNT_OUT, d, 4)) return 0;
|
||||
return (uint32_t)d[0] | ((uint32_t)d[1] << 8) |
|
||||
((uint32_t)d[2] << 16) | ((uint32_t)d[3] << 24);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "variant.h" // Board-specific pin definitions (I2C, user btn, addresses)
|
||||
|
||||
#include <Wire.h>
|
||||
#include <Arduino.h>
|
||||
#include "XPowersLib.h"
|
||||
#include "helpers/ESP32Board.h"
|
||||
#include <driver/rtc_io.h>
|
||||
|
||||
// LilyGo T-Watch S3 Plus board.
|
||||
//
|
||||
// Power is managed by an AXP2101 PMU on the main I2C bus. The PMU power rails
|
||||
// (per the T-Watch S3 Plus PowerManage table) are brought up in power_init().
|
||||
class SensorBMA423; // full include kept in the .cpp to avoid a BLE-build header clash
|
||||
|
||||
class TWatchS3PlusBoard : public ESP32Board {
|
||||
XPowersLibInterface* PMU = NULL;
|
||||
SensorBMA423* _accel = nullptr;
|
||||
static volatile bool _tilt_flag;
|
||||
static void IRAM_ATTR onTiltISR(); // defined in the .cpp (IRAM relocation)
|
||||
|
||||
bool power_init();
|
||||
|
||||
public:
|
||||
void begin();
|
||||
|
||||
// Returns true once when the BMA423 tilt (wrist-raise) interrupt has fired.
|
||||
bool tiltFired();
|
||||
|
||||
void enterDeepSleep(uint32_t secs, int pin_wake_btn) {
|
||||
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
|
||||
|
||||
rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY);
|
||||
rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1);
|
||||
rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS);
|
||||
|
||||
if (pin_wake_btn < 0) {
|
||||
esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH);
|
||||
} else {
|
||||
esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1) | (1ULL << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH);
|
||||
}
|
||||
|
||||
if (secs > 0) {
|
||||
esp_sleep_enable_timer_wakeup(secs * 1000000ULL);
|
||||
}
|
||||
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
|
||||
uint16_t getBattMilliVolts() override {
|
||||
return PMU ? PMU->getBattVoltage() : 0;
|
||||
}
|
||||
|
||||
uint8_t getBatteryPercent() override {
|
||||
return PMU ? PMU->getBatteryPercent() : 0;
|
||||
}
|
||||
|
||||
// Wrapper-free BMA423 step count (raw I2C; defined in the .cpp).
|
||||
uint32_t getStepCount();
|
||||
|
||||
// GPS power is the AXP2101 BLDO1 rail. Off at boot; toggled at runtime via
|
||||
// the gps_enabled pref (boot) and the "gps on/off" CLI command (live).
|
||||
void gpsPowerOn();
|
||||
void gpsPowerOff();
|
||||
|
||||
// TEMP power-debug probe: battery, VBUS, CPU clock, BT controller state and
|
||||
// live rail states (incl. GPS/BLDO1). Called periodically from UITask::loop.
|
||||
void printPowerDebug();
|
||||
|
||||
const char* getManufacturerName() const override {
|
||||
return "LilyGo T-Watch S3 Plus";
|
||||
}
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// LovyanGFX display + touch for the LilyGo T-Watch S3 Plus.
|
||||
// ST7789V3 240x240 on SPI3 + PWM backlight (GPIO45) + FT6336U capacitive touch
|
||||
// on a separate I2C bus (Wire1: SDA 39 / SCL 40, INT 16, no RST pin).
|
||||
//
|
||||
// LGFXDisplay.h pulls in LovyanGFX (it defines LGFX_USE_V1 and includes
|
||||
// LovyanGFX.hpp), so we do not redefine those here.
|
||||
|
||||
#include <helpers/ui/LGFXDisplay.h>
|
||||
|
||||
class LGFX_TWatchS3Plus : public lgfx::LGFX_Device {
|
||||
lgfx::Panel_ST7789 _panel_instance;
|
||||
lgfx::Bus_SPI _bus_instance;
|
||||
lgfx::Light_PWM _light_instance;
|
||||
lgfx::Touch_FT5x06 _touch_instance;
|
||||
|
||||
public:
|
||||
LGFX_TWatchS3Plus(void) {
|
||||
{
|
||||
auto cfg = _bus_instance.config();
|
||||
cfg.spi_host = SPI3_HOST;
|
||||
cfg.spi_mode = 0;
|
||||
cfg.freq_write = 40000000;
|
||||
cfg.freq_read = 16000000;
|
||||
cfg.spi_3wire = true;
|
||||
cfg.use_lock = true;
|
||||
cfg.dma_channel = SPI_DMA_CH_AUTO;
|
||||
cfg.pin_sclk = 18;
|
||||
cfg.pin_mosi = 13;
|
||||
cfg.pin_miso = -1;
|
||||
cfg.pin_dc = 38;
|
||||
_bus_instance.config(cfg);
|
||||
_panel_instance.setBus(&_bus_instance);
|
||||
}
|
||||
|
||||
{
|
||||
auto cfg = _panel_instance.config();
|
||||
cfg.pin_cs = 12;
|
||||
cfg.pin_rst = -1;
|
||||
cfg.pin_busy = -1;
|
||||
// ST7789 GRAM is 240x320; memory_height must be 320 so the 80px rotation
|
||||
// offset is applied (otherwise a strip of the panel shows noise).
|
||||
cfg.memory_width = 240;
|
||||
cfg.memory_height = 320;
|
||||
cfg.panel_width = 240;
|
||||
cfg.panel_height = 240;
|
||||
cfg.offset_x = 0;
|
||||
cfg.offset_y = 0;
|
||||
cfg.offset_rotation = 1;
|
||||
cfg.readable = false;
|
||||
cfg.invert = true;
|
||||
cfg.rgb_order = false;
|
||||
cfg.dlen_16bit = false;
|
||||
cfg.bus_shared = false;
|
||||
_panel_instance.config(cfg);
|
||||
}
|
||||
|
||||
{
|
||||
auto cfg = _light_instance.config();
|
||||
cfg.pin_bl = 45;
|
||||
cfg.invert = false;
|
||||
cfg.freq = 44100;
|
||||
cfg.pwm_channel = 7;
|
||||
_light_instance.config(cfg);
|
||||
_panel_instance.setLight(&_light_instance);
|
||||
}
|
||||
|
||||
{
|
||||
auto cfg = _touch_instance.config();
|
||||
cfg.x_min = 0;
|
||||
cfg.x_max = 239;
|
||||
cfg.y_min = 0;
|
||||
cfg.y_max = 239;
|
||||
cfg.pin_int = 16;
|
||||
cfg.pin_rst = -1;
|
||||
cfg.bus_shared = false;
|
||||
cfg.offset_rotation = 2; // touch IC mounted 180deg vs the LCD horizontal axis
|
||||
cfg.i2c_port = 1;
|
||||
cfg.i2c_addr = 0x38;
|
||||
cfg.pin_sda = 39;
|
||||
cfg.pin_scl = 40;
|
||||
cfg.freq = 400000;
|
||||
_touch_instance.config(cfg);
|
||||
_panel_instance.setTouch(&_touch_instance);
|
||||
}
|
||||
|
||||
setPanel(&_panel_instance);
|
||||
}
|
||||
};
|
||||
|
||||
class TWatchS3PlusDisplay : public LGFXDisplay {
|
||||
LGFX_TWatchS3Plus disp;
|
||||
public:
|
||||
TWatchS3PlusDisplay() : LGFXDisplay(240, 240, disp) {}
|
||||
};
|
||||
@@ -1,382 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// WatchChannelConfigScreen -- per-channel settings for the T-Watch S3 / S3 Plus.
|
||||
//
|
||||
// The SettingsScreen SUB_CHANNELS sub-screen drives every per-channel action
|
||||
// from physical-keyboard hotkeys on the selected row (Ent: edit region scope,
|
||||
// N: cycle notifications, T: tone, X/Hold: delete), none of which exist on the
|
||||
// touch-only watch. This standalone screen replaces it on MECK_TWATCH builds,
|
||||
// following the WatchAlarmScreen / WatchNotesScreen conventions.
|
||||
//
|
||||
// LIST mode shows each channel with its region scope tag and notification
|
||||
// state; tap a row to select it, tap again (or PWR/Enter) to open it. DETAIL
|
||||
// mode offers: Region (opens the watch keyboard via TWKB_SCOPE, pre-filled
|
||||
// with the current scope), Clear region (reverts to the device default),
|
||||
// Notify (tap-cycles All -> Mentions -> Off, mirroring the desktop order),
|
||||
// and Delete via the button (channel 1+ only): a short press of the PWR key
|
||||
// (S3) or the boot button (S3 Plus) arms it, a second press within 3s
|
||||
// confirms. The tone picker is deliberately absent -- no audio path.
|
||||
//
|
||||
// Exit: the footer Back returns to the Settings screen (via a flag polled by
|
||||
// UITask::loop); the universal top-strip tap still goes home.
|
||||
//
|
||||
// Geometry: 240x240 physical, UI_ZOOM=2, so display.width()/height() = 120.
|
||||
// =============================================================================
|
||||
|
||||
// NOTE on include order: mirrors WatchNotesScreen.h -- this header uses
|
||||
// NodePrefs (NOTIF_* constants, channel_notif[]) and the_mesh, which the two
|
||||
// call sites (UITask.cpp, main.cpp) already have in scope before including it.
|
||||
#include <Arduino.h>
|
||||
#include <string.h>
|
||||
#include <MeshCore.h>
|
||||
#include <helpers/ChannelDetails.h>
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ui/LGFXDisplay.h>
|
||||
|
||||
class UITask;
|
||||
|
||||
#ifndef MAX_GROUP_CHANNELS
|
||||
#define MAX_GROUP_CHANNELS 20
|
||||
#endif
|
||||
|
||||
#define WCC_ROWS 4 // list rows per page
|
||||
|
||||
class WatchChannelConfigScreen : public UIScreen {
|
||||
public:
|
||||
enum Mode { LIST, DETAIL };
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _node_prefs;
|
||||
|
||||
uint8_t _chIdx[MAX_GROUP_CHANNELS]; // populated channel slots, in order
|
||||
int _numCh = 0;
|
||||
|
||||
Mode _mode = LIST;
|
||||
int _sel = 0; // selected list row (index into _chIdx)
|
||||
int _scrollTop = 0;
|
||||
int _detail = -1; // channel slot open in DETAIL (value from _chIdx)
|
||||
bool _confirmDelete = false;
|
||||
unsigned long _confirmAt = 0;
|
||||
|
||||
// Keyboard / exit handshake -- UITask::loop() polls these (UITask is only
|
||||
// forward declared here, so this screen cannot call it directly).
|
||||
bool _wantKeyboard = false;
|
||||
bool _wantExit = false;
|
||||
char _editScope[31]; // pre-fill text handed to the keyboard
|
||||
|
||||
// Layout (logical pixels)
|
||||
static const int HEADER_H = 14;
|
||||
static const int ROW_H = 20;
|
||||
static const int FOOTER_Y = 100;
|
||||
|
||||
void rebuildList() {
|
||||
// Scan ALL slots -- the companion app may write non-contiguously, and
|
||||
// gaps can appear after deletion (same scan as SettingsScreen).
|
||||
_numCh = 0;
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
_chIdx[_numCh++] = i;
|
||||
}
|
||||
}
|
||||
if (_sel >= _numCh) _sel = _numCh > 0 ? _numCh - 1 : 0;
|
||||
if (_scrollTop > _sel) _scrollTop = _sel;
|
||||
}
|
||||
|
||||
const char* notifLabel(uint8_t idx) const {
|
||||
uint8_t n = _node_prefs->channel_notif[idx];
|
||||
return (n == NOTIF_NONE) ? "Off" : (n == NOTIF_MENTIONS) ? "@" : "All";
|
||||
}
|
||||
|
||||
// Replicates SettingsScreen::deleteChannel (kept in sync manually). Note:
|
||||
// channel_notif[] is not shifted during compaction -- this matches the
|
||||
// desktop behaviour exactly.
|
||||
void deleteChannel(uint8_t idx) {
|
||||
ChannelDetails empty;
|
||||
memset(&empty, 0, sizeof(empty));
|
||||
int total = 0;
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
total = i + 1;
|
||||
}
|
||||
}
|
||||
for (int i = idx; i < total - 1; i++) {
|
||||
ChannelDetails next;
|
||||
if (the_mesh.getChannel(i + 1, next)) {
|
||||
the_mesh.setChannel(i, next);
|
||||
}
|
||||
}
|
||||
the_mesh.setChannel(total - 1, empty);
|
||||
the_mesh.saveChannels();
|
||||
Serial.printf("WatchChannelCfg: Deleted channel at idx %d, compacted %d channels\n", idx, total);
|
||||
}
|
||||
|
||||
void ensureVisible() {
|
||||
if (_sel < _scrollTop) _scrollTop = _sel;
|
||||
if (_sel >= _scrollTop + WCC_ROWS) _scrollTop = _sel - WCC_ROWS + 1;
|
||||
}
|
||||
|
||||
public:
|
||||
WatchChannelConfigScreen(UITask* task, NodePrefs* node_prefs)
|
||||
: _task(task), _node_prefs(node_prefs) {
|
||||
_editScope[0] = 0;
|
||||
}
|
||||
|
||||
void enter() {
|
||||
rebuildList();
|
||||
_mode = LIST;
|
||||
_detail = -1;
|
||||
_confirmDelete = false;
|
||||
_wantKeyboard = false;
|
||||
_wantExit = false;
|
||||
}
|
||||
|
||||
// --- handshake (polled from UITask::loop) ---
|
||||
bool wantsKeyboard() const { return _wantKeyboard; }
|
||||
void clearWantKeyboard() { _wantKeyboard = false; }
|
||||
bool wantsExit() const { return _wantExit; }
|
||||
void clearWantExit() { _wantExit = false; }
|
||||
const char* getEditText() const { return _editScope; }
|
||||
|
||||
// Save the keyboard result as the open channel's region scope. An empty
|
||||
// string clears it back to the device default (same as Clear). Returns
|
||||
// NULL on success, else an error message.
|
||||
const char* applyComposedScope(const char* txt) {
|
||||
if (_detail < 0) return "No channel open";
|
||||
ChannelDetails ch;
|
||||
if (!the_mesh.getChannel((uint8_t)_detail, ch)) return "Channel gone";
|
||||
if (txt == nullptr) txt = "";
|
||||
strncpy(ch.scope_name, txt, sizeof(ch.scope_name));
|
||||
ch.scope_name[30] = '\0';
|
||||
the_mesh.setChannel((uint8_t)_detail, ch);
|
||||
the_mesh.saveChannels();
|
||||
Serial.printf("WatchChannelCfg: Channel %d scope set to '%s'\n",
|
||||
_detail, ch.scope_name[0] ? ch.scope_name : "(device default)");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering. 120x120 logical.
|
||||
// ---------------------------------------------------------------------------
|
||||
private:
|
||||
void renderList(DisplayDriver& display) {
|
||||
LGFXDisplay* d = (LGFXDisplay*)&display;
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 4, "Channels");
|
||||
|
||||
if (_numCh == 0) {
|
||||
d->setRawColor(0x7BEF);
|
||||
display.drawTextCentered(display.width() / 2, 48, "No channels");
|
||||
}
|
||||
|
||||
char buf[40];
|
||||
int visible = _numCh - _scrollTop;
|
||||
if (visible > WCC_ROWS) visible = WCC_ROWS;
|
||||
for (int i = 0; i < visible; i++) {
|
||||
uint8_t idx = _chIdx[_scrollTop + i];
|
||||
int y = HEADER_H + i * ROW_H;
|
||||
ChannelDetails ch;
|
||||
if (!the_mesh.getChannel(idx, ch)) continue;
|
||||
|
||||
if (_scrollTop + i == _sel) {
|
||||
d->setRawColor(0x231D); // same blue as the home tile
|
||||
d->drawRoundRect(1, y, display.width() - 2, ROW_H - 2, 3);
|
||||
}
|
||||
d->setRawColor(0xFFFF);
|
||||
char nameBuf[15];
|
||||
strncpy(nameBuf, ch.name, sizeof(nameBuf) - 1);
|
||||
nameBuf[sizeof(nameBuf) - 1] = 0;
|
||||
display.setCursor(5, y + 2);
|
||||
display.print(nameBuf);
|
||||
|
||||
snprintf(buf, sizeof(buf), "[%s] %s",
|
||||
ch.scope_name[0] ? ch.scope_name : "*", notifLabel(idx));
|
||||
d->setRawColor(0x7BEF);
|
||||
d->printSmallFont(5, y + 13, buf);
|
||||
}
|
||||
|
||||
// Footer: Back (left) and, when the list overflows a page, More (right).
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(1, FOOTER_Y, 58, 18, 3);
|
||||
d->setRawColor(0xFFFF);
|
||||
d->printSmallFont(20, FOOTER_Y + 8, "Back");
|
||||
if (_numCh > WCC_ROWS) {
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(61, FOOTER_Y, 58, 18, 3);
|
||||
d->setRawColor(0xC618);
|
||||
d->printSmallFont(78, FOOTER_Y + 8, "More");
|
||||
}
|
||||
}
|
||||
|
||||
void renderDetail(DisplayDriver& display) {
|
||||
LGFXDisplay* d = (LGFXDisplay*)&display;
|
||||
char buf[44];
|
||||
|
||||
ChannelDetails ch;
|
||||
if (_detail < 0 || !the_mesh.getChannel((uint8_t)_detail, ch)) {
|
||||
_mode = LIST;
|
||||
return;
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(1);
|
||||
char nameBuf[18];
|
||||
strncpy(nameBuf, ch.name, sizeof(nameBuf) - 1);
|
||||
nameBuf[sizeof(nameBuf) - 1] = 0;
|
||||
display.drawTextCentered(display.width() / 2, 3, nameBuf);
|
||||
|
||||
// Region row -- opens the keyboard.
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(1, 16, display.width() - 2, 18, 3);
|
||||
snprintf(buf, sizeof(buf), "Region: %s",
|
||||
ch.scope_name[0] ? ch.scope_name : "(default)");
|
||||
d->setRawColor(0xFFFF);
|
||||
d->printSmallFont(6, 24, buf);
|
||||
|
||||
// Clear-region row -- dim when there is nothing to clear.
|
||||
d->setRawColor(ch.scope_name[0] ? 0x231D : 0x18C3);
|
||||
d->drawRoundRect(1, 36, display.width() - 2, 18, 3);
|
||||
d->setRawColor(ch.scope_name[0] ? 0xC618 : 0x4208);
|
||||
d->printSmallFont(6, 44, "Clear region (use default)");
|
||||
|
||||
// Notify row -- tap cycles All -> Mentions -> Off.
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(1, 56, display.width() - 2, 18, 3);
|
||||
uint8_t n = _node_prefs->channel_notif[(uint8_t)_detail];
|
||||
snprintf(buf, sizeof(buf), "Notify: %s",
|
||||
(n == NOTIF_NONE) ? "Off" : (n == NOTIF_MENTIONS) ? "Mentions" : "All");
|
||||
d->setRawColor(0xFFFF);
|
||||
d->printSmallFont(6, 64, buf);
|
||||
|
||||
// Delete row -- status only; deletion is driven by the button (see
|
||||
// handleInput). Channel 0 (public) cannot be deleted, same as desktop.
|
||||
bool canDel = (_detail > 0);
|
||||
d->setRawColor(canDel ? 0x231D : 0x18C3);
|
||||
d->drawRoundRect(1, 76, display.width() - 2, 18, 3);
|
||||
d->setRawColor(canDel ? (_confirmDelete ? 0xF800 : 0xC618) : 0x4208);
|
||||
d->printSmallFont(6, 84, !canDel ? "Delete (public ch)"
|
||||
: _confirmDelete ? "Delete? press again" : "Button: delete");
|
||||
|
||||
// Footer: Back to the list.
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(1, FOOTER_Y, 58, 18, 3);
|
||||
d->setRawColor(0xFFFF);
|
||||
d->printSmallFont(20, FOOTER_Y + 8, "Back");
|
||||
}
|
||||
|
||||
public:
|
||||
int render(DisplayDriver& display) override {
|
||||
if (_confirmDelete && millis() - _confirmAt > 3000) _confirmDelete = false;
|
||||
switch (_mode) {
|
||||
case LIST: renderList(display); return 500;
|
||||
case DETAIL: renderDetail(display); return 500;
|
||||
}
|
||||
return 500;
|
||||
}
|
||||
|
||||
// Physical tap in logical coordinates. The top-strip home tap is handled by
|
||||
// main.cpp before this is called. Returns true if the tap was consumed.
|
||||
bool handleTap(int lx, int ly) {
|
||||
if (_mode == LIST) {
|
||||
if (ly >= FOOTER_Y) {
|
||||
if (lx < 60) { // Back -> Settings
|
||||
_wantExit = true;
|
||||
} else if (_numCh > WCC_ROWS) { // More: next page (wraps)
|
||||
_scrollTop += WCC_ROWS;
|
||||
if (_scrollTop >= _numCh) _scrollTop = 0;
|
||||
_sel = _scrollTop;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (ly >= HEADER_H) {
|
||||
int i = (ly - HEADER_H) / ROW_H;
|
||||
int idx = _scrollTop + i;
|
||||
if (i < WCC_ROWS && idx < _numCh) {
|
||||
if (idx == _sel) { // second tap on the row: open it
|
||||
_detail = _chIdx[idx];
|
||||
_mode = DETAIL;
|
||||
_confirmDelete = false;
|
||||
} else {
|
||||
_sel = idx; // first tap: select
|
||||
ensureVisible();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// DETAIL
|
||||
if (ly >= FOOTER_Y) { // Back -> list
|
||||
_confirmDelete = false;
|
||||
_mode = LIST;
|
||||
rebuildList();
|
||||
return true;
|
||||
}
|
||||
ChannelDetails ch;
|
||||
if (_detail < 0 || !the_mesh.getChannel((uint8_t)_detail, ch)) {
|
||||
_mode = LIST;
|
||||
return true;
|
||||
}
|
||||
if (ly >= 16 && ly < 34) { // Region -> keyboard, pre-filled
|
||||
strncpy(_editScope, ch.scope_name, sizeof(_editScope));
|
||||
_editScope[30] = '\0';
|
||||
_wantKeyboard = true;
|
||||
return true;
|
||||
}
|
||||
if (ly >= 36 && ly < 54) { // Clear region
|
||||
if (ch.scope_name[0]) {
|
||||
ch.scope_name[0] = '\0';
|
||||
the_mesh.setChannel((uint8_t)_detail, ch);
|
||||
the_mesh.saveChannels();
|
||||
Serial.printf("WatchChannelCfg: Channel %d scope cleared\n", _detail);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (ly >= 56 && ly < 74) { // Notify: cycle All -> @ -> Off
|
||||
uint8_t cur = _node_prefs->channel_notif[(uint8_t)_detail];
|
||||
_node_prefs->channel_notif[(uint8_t)_detail] = (cur + 1) % 3;
|
||||
the_mesh.savePrefs();
|
||||
return true;
|
||||
}
|
||||
return true; // swallow other taps (incl. delete row)
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// The PMU short press (S3) arrives as KEY_ENTER; the S3 Plus boot button
|
||||
// arrives as KEY_NEXT.
|
||||
if (_mode == LIST && c == KEY_ENTER) {
|
||||
if (_sel < _numCh) {
|
||||
_detail = _chIdx[_sel];
|
||||
_mode = DETAIL;
|
||||
_confirmDelete = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (_mode == DETAIL && (c == KEY_ENTER || c == KEY_NEXT)) {
|
||||
// Button click deletes the open channel (channel 0 is protected):
|
||||
// first press arms, a second press within 3s confirms. Back is the
|
||||
// touch footer.
|
||||
if (_detail > 0) {
|
||||
if (_confirmDelete) {
|
||||
deleteChannel((uint8_t)_detail);
|
||||
_confirmDelete = false;
|
||||
_detail = -1;
|
||||
rebuildList();
|
||||
_mode = LIST;
|
||||
} else {
|
||||
_confirmDelete = true;
|
||||
_confirmAt = millis();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
# Meck -- LilyGo T-Watch S3 Plus 16 MB custom partition layout
|
||||
#
|
||||
# Single factory app slot (no OTA): the watch has no SD card and does not
|
||||
# define MECK_OTA_UPDATE, so no firmware-OTA path is compiled -- an OTA
|
||||
# app1 slot would only waste flash. Maps get a dedicated LittleFS partition;
|
||||
# the DataStore keeps its own SPIFFS partition.
|
||||
#
|
||||
# Both data partitions use the 'spiffs' subtype (the standard ESP32 data
|
||||
# subtype). The DataStore is mounted as SPIFFS via SPIFFS.begin() (first
|
||||
# spiffs-subtype partition by offset); maps is mounted as LittleFS by the
|
||||
# explicit "maps" label, so the mount type is chosen at mount time, not by
|
||||
# subtype.
|
||||
#
|
||||
# NOTE: flashing this shifts the layout and wipes the existing SPIFFS --
|
||||
# re-provision identity/contacts after flashing.
|
||||
#
|
||||
# Name, Type, SubType, Offset, Size
|
||||
nvs, data, nvs, 0x9000, 0x5000
|
||||
app0, app, factory, 0x10000, 0x200000
|
||||
spiffs, data, spiffs, 0x210000, 0x400000
|
||||
maps, data, spiffs, 0x610000, 0x9F0000
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 12.
|
@@ -1,156 +0,0 @@
|
||||
[LilyGo_TWatchS3Plus]
|
||||
extends = esp32_base
|
||||
extra_scripts = post:merge_firmware.py
|
||||
board = lilygo_twatch_s3_plus
|
||||
board_build.flash_mode = qio
|
||||
board_build.f_flash = 80000000L
|
||||
board_build.arduino.memory_type = qio_opi
|
||||
board_upload.flash_size = 16MB
|
||||
board_build.partitions = variants/lilygo_twatch_s3_plus/meck_twatch_16mb.csv
|
||||
build_flags =
|
||||
${esp32_base.build_flags}
|
||||
${sensor_base.build_flags}
|
||||
-I variants/lilygo_twatch_s3_plus
|
||||
; MECK_TWATCH gates the watch form factor (240x240, UI_ZOOM=2, touch, lock
|
||||
; screen, tile grid, grey palette). LILYGO_TWATCH_S3_PLUS gates hardware that
|
||||
; is specific to the Plus (onboard GNSS on BLDO1, GPIO0 user button).
|
||||
-D MECK_TWATCH
|
||||
-D LILYGO_TWATCH_S3_PLUS
|
||||
-D TWATCH_COMPOSE_ENABLED
|
||||
-D BOARD_HAS_PSRAM=1
|
||||
-D CORE_DEBUG_LEVEL=1
|
||||
-D FORMAT_SPIFFS_IF_FAILED=1
|
||||
-D FORMAT_LITTLEFS_IF_FAILED=1
|
||||
-D ARDUINO_USB_CDC_ON_BOOT=1
|
||||
; ---- LoRa SX1262 ----
|
||||
-D RADIO_CLASS=CustomSX1262
|
||||
-D WRAPPER_CLASS=CustomSX1262Wrapper
|
||||
-D LORA_TX_POWER=22
|
||||
-D SX126X_DIO2_AS_RF_SWITCH
|
||||
-D SX126X_DIO3_TCXO_VOLTAGE=1.8f
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
-D P_LORA_NSS=5
|
||||
-D P_LORA_DIO_1=9
|
||||
-D P_LORA_RESET=8
|
||||
-D P_LORA_BUSY=7
|
||||
-D P_LORA_SCLK=3
|
||||
-D P_LORA_MISO=4
|
||||
-D P_LORA_MOSI=1
|
||||
; ---- Display (ST7789 240x240) + FT6336U touch via LovyanGFX ----
|
||||
-D DISPLAY_CLASS=TWatchS3PlusDisplay
|
||||
-D UI_ZOOM=2
|
||||
; ---- Misc ----
|
||||
-D AUTO_SHUTDOWN_MILLIVOLTS=3100
|
||||
-D ARDUINO_LOOP_STACK_SIZE=32768
|
||||
; ---- GPS (u-blox MIA-M10Q on Serial2) ----
|
||||
; Rail-managed: BLDO1 is off at boot and gated by the gps_enabled pref
|
||||
; (defaults to 0) plus the "gps on/off" CLI command.
|
||||
; HAS_GPS is a build flag (not only variant.h) so it is defined globally when
|
||||
; UITask.h is parsed -- the gotoMapScreen() declaration is behind #if HAS_GPS,
|
||||
; and UITask.h is included before target.h/variant.h in the .cpp TUs.
|
||||
; PIN_GPS_RX/PIN_GPS_TX feed the shared initBasicGPS() Serial1 path and mirror
|
||||
; the GPS_RX_PIN/GPS_TX_PIN values in variant.h (used by the Serial2 provider).
|
||||
-D HAS_GPS=1
|
||||
-D ENV_INCLUDE_GPS=1
|
||||
-D ENV_SKIP_GPS_DETECT=1
|
||||
-D PIN_GPS_RX=41
|
||||
-D PIN_GPS_TX=42
|
||||
-D ENV_INCLUDE_AHTX0=0
|
||||
-D ENV_INCLUDE_BME280=0
|
||||
-D ENV_INCLUDE_BMP280=0
|
||||
-D ENV_INCLUDE_SHTC3=0
|
||||
-D ENV_INCLUDE_SHT4X=0
|
||||
-D ENV_INCLUDE_LPS22HB=0
|
||||
-D ENV_INCLUDE_INA3221=0
|
||||
-D ENV_INCLUDE_INA219=0
|
||||
-D ENV_INCLUDE_INA226=0
|
||||
-D ENV_INCLUDE_INA260=0
|
||||
-D ENV_INCLUDE_MLX90614=0
|
||||
-D ENV_INCLUDE_VL53L0X=0
|
||||
-D ENV_INCLUDE_BME680=0
|
||||
-D ENV_INCLUDE_BMP085=0
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/lilygo_twatch_s3_plus>
|
||||
+<helpers/sensors/*.cpp>
|
||||
lib_deps =
|
||||
${esp32_base.lib_deps}
|
||||
${sensor_base.lib_deps}
|
||||
lovyan03/LovyanGFX @ ^1.2.0
|
||||
lewisxhe/XPowersLib @ ^0.2.7
|
||||
lewisxhe/SensorLib @ ^0.4.1
|
||||
adafruit/Adafruit GFX Library @ ^1.11.0
|
||||
bitbank2/PNGdec @ ^1.0.1
|
||||
WebServer
|
||||
DNSServer
|
||||
Update
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Phase 1: standalone (no BLE companion), touch + display + lock screen.
|
||||
; MAX_CONTACTS=2000 -- contact + sort arrays allocated in PSRAM via
|
||||
; BaseChatMesh::initContacts(). GPS is compiled in but its BLDO1 rail stays
|
||||
; off until toggled on ("gps on"); maps, tiles and the on-screen keyboard are
|
||||
; deferred to later phases.
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:meck_twatch_s3_plus_standalone]
|
||||
extends = LilyGo_TWatchS3Plus
|
||||
build_flags =
|
||||
${LilyGo_TWatchS3Plus.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=2000
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D OFFLINE_QUEUE_SIZE=1
|
||||
-D ESP32_CPU_FREQ=80
|
||||
-D FIRMWARE_VERSION='"Meck TWatch v0.3"'
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
build_src_filter = ${LilyGo_TWatchS3Plus.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
-<helpers/esp32/SerialBLEInterface.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/LGFXDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TWatchS3Plus.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
lib_ignore =
|
||||
AsyncTCP
|
||||
ESPAsyncWebServer
|
||||
ESP32 BLE Arduino
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; BLE companion build -- same touch UI as the standalone, plus the BLE serial
|
||||
; interface so a phone app can connect on occasion. Differs from standalone:
|
||||
; BLE_PIN_CODE set, OFFLINE_QUEUE_SIZE raised, SerialBLEInterface.cpp compiled
|
||||
; in (no longer excluded), and the BLE Arduino lib no longer in lib_ignore.
|
||||
; Contacts stay PSRAM-allocated via initContacts(), covering the BLE stack's
|
||||
; internal-SRAM use. ESP32_CPU_FREQ=80 sets the boot clock low; note that
|
||||
; CPUPowerManager already governs the idle clock to 80 MHz at runtime.
|
||||
; Flash: pio run -e meck_twatch_s3_plus_ble -t upload
|
||||
; ---------------------------------------------------------------------------
|
||||
[env:meck_twatch_s3_plus_ble]
|
||||
extends = LilyGo_TWatchS3Plus
|
||||
build_flags =
|
||||
${LilyGo_TWatchS3Plus.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=2000
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D ESP32_CPU_FREQ=80
|
||||
-D FIRMWARE_VERSION='"Meck TWatch BLE v0.3"'
|
||||
;-D MESH_PACKET_LOGGING=1
|
||||
;-D MESH_DEBUG=1
|
||||
build_src_filter = ${LilyGo_TWatchS3Plus.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/LGFXDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TWatchS3Plus.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
lib_ignore =
|
||||
AsyncTCP
|
||||
ESPAsyncWebServer
|
||||
@@ -1,72 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include "variant.h"
|
||||
#include "target.h"
|
||||
|
||||
TWatchS3PlusBoard board;
|
||||
|
||||
// LoRa SX1262 on its own SPI bus (the display uses SPI3_HOST separately).
|
||||
static SPIClass loraSpi;
|
||||
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, loraSpi);
|
||||
|
||||
WRAPPER_CLASS radio_driver(radio, board);
|
||||
|
||||
ESP32RTCClock fallback_clock;
|
||||
AutoDiscoverRTCClock rtc_clock(fallback_clock);
|
||||
|
||||
// GPS: u-blox MIA-M10Q on Serial2 (the GPS pins are reclaimed from Serial1 to
|
||||
// Serial2 in main.cpp setup(), matching the Meck T-Deck Pro pattern). The
|
||||
// BLDO1 rail that powers the module is off at boot and gated by gps_enabled.
|
||||
#if HAS_GPS
|
||||
GPSStreamCounter gpsStream(Serial2);
|
||||
MicroNMEALocationProvider gps(gpsStream, &rtc_clock);
|
||||
EnvironmentSensorManager sensors(gps);
|
||||
#else
|
||||
SensorManager sensors;
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
DISPLAY_CLASS display;
|
||||
MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
|
||||
#endif
|
||||
|
||||
bool radio_init() {
|
||||
// NOTE: board.begin() is called by main.cpp setup() before radio_init();
|
||||
// Wire is already initialised there with the correct pins.
|
||||
fallback_clock.begin();
|
||||
rtc_clock.begin(Wire);
|
||||
|
||||
loraSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS);
|
||||
return radio.std_init(&loraSpi);
|
||||
}
|
||||
|
||||
uint32_t radio_get_rng_seed() {
|
||||
return radio.random(0x7FFFFFFF);
|
||||
}
|
||||
|
||||
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
|
||||
radio.setFrequency(freq);
|
||||
radio.setSpreadingFactor(sf);
|
||||
radio.setBandwidth(bw);
|
||||
radio.setCodingRate(cr);
|
||||
|
||||
// Longer preamble for low SF improves reliability -- each symbol is shorter
|
||||
// at low SF, so more symbols are needed for reliable detection.
|
||||
uint16_t preamble = (sf <= 9) ? 32 : 16;
|
||||
radio.setPreambleLength(preamble);
|
||||
}
|
||||
|
||||
void radio_set_tx_power(uint8_t dbm) {
|
||||
radio.setOutputPower(dbm);
|
||||
}
|
||||
|
||||
mesh::LocalIdentity radio_new_identity() {
|
||||
RadioNoiseListener rng(radio);
|
||||
return mesh::LocalIdentity(&rng);
|
||||
}
|
||||
|
||||
void radio_reset_agc() {
|
||||
// Power-saving RX gain on the watch (deliberate): boosted gain costs
|
||||
// ~0.6-0.7 mA continuously for ~+2 dB sensitivity. The SetRxGain write
|
||||
// itself is what resets the AGC; false selects the power-saving value.
|
||||
radio.setRxBoostedGainMode(false);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// Include variant.h first to ensure all board-specific defines are available
|
||||
#include "variant.h"
|
||||
|
||||
#define RADIOLIB_STATIC_ONLY 1
|
||||
#include <RadioLib.h>
|
||||
#include <helpers/radiolib/RadioLibWrappers.h>
|
||||
#include <helpers/radiolib/CustomSX1262Wrapper.h>
|
||||
#include <TWatchS3PlusBoard.h>
|
||||
#include <helpers/AutoDiscoverRTCClock.h>
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include <TWatchS3PlusDisplay.h>
|
||||
#include <helpers/ui/MomentaryButton.h>
|
||||
#endif
|
||||
|
||||
#if HAS_GPS
|
||||
#include "helpers/sensors/EnvironmentSensorManager.h"
|
||||
#include "helpers/sensors/MicroNMEALocationProvider.h"
|
||||
#include "GPSStreamCounter.h"
|
||||
#else
|
||||
#include <helpers/SensorManager.h>
|
||||
#endif
|
||||
|
||||
extern TWatchS3PlusBoard board;
|
||||
extern WRAPPER_CLASS radio_driver;
|
||||
extern AutoDiscoverRTCClock rtc_clock;
|
||||
|
||||
#if HAS_GPS
|
||||
extern GPSStreamCounter gpsStream;
|
||||
extern EnvironmentSensorManager sensors;
|
||||
#else
|
||||
extern SensorManager sensors;
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
extern DISPLAY_CLASS display;
|
||||
extern MomentaryButton user_btn;
|
||||
#endif
|
||||
|
||||
bool radio_init();
|
||||
uint32_t radio_get_rng_seed();
|
||||
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr);
|
||||
void radio_set_tx_power(uint8_t dbm);
|
||||
mesh::LocalIdentity radio_new_identity();
|
||||
void radio_reset_agc();
|
||||
@@ -1,79 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// LilyGo T-Watch S3 Plus - Board-level pin definitions
|
||||
// Source: LilyGo hardware doc (lilygo-t-watch-s3-plus.md) and the working
|
||||
// MeshCore companion build.
|
||||
//
|
||||
// NOTE: LoRa (P_LORA_*) pins and the SX126x radio parameters are supplied as
|
||||
// -D build flags in this variant's platformio.ini, matching the Meck T-Deck
|
||||
// Pro convention. The display SPI/backlight and FT6336U touch pins live inline
|
||||
// in TWatchS3PlusDisplay.h because LovyanGFX needs them in its panel config.
|
||||
// =============================================================================
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Main I2C bus (shared): AXP2101 PMU, PCF8563 RTC, BMA423 accel, DRV2605 haptic
|
||||
// The FT6336U touch panel is on a SEPARATE bus (Wire1), configured in the
|
||||
// display class -- it is not on this bus.
|
||||
// -----------------------------------------------------------------------------
|
||||
#define I2C_SDA 10
|
||||
#define I2C_SCL 11
|
||||
|
||||
// Aliases for ESP32Board base class compatibility
|
||||
#define PIN_BOARD_SDA I2C_SDA
|
||||
#define PIN_BOARD_SCL I2C_SCL
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// I2C device addresses (7-bit)
|
||||
// -----------------------------------------------------------------------------
|
||||
#define I2C_ADDR_PMU 0x34 // AXP2101 power management
|
||||
#define I2C_ADDR_RTC 0x51 // PCF8563 real-time clock
|
||||
#define I2C_ADDR_ACCEL 0x19 // BMA423 accelerometer
|
||||
#define I2C_ADDR_HAPTIC 0x5A // DRV2605 haptic driver
|
||||
#define I2C_ADDR_TOUCH 0x38 // FT6336U capacitive touch (on Wire1)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Interrupt / control pins
|
||||
// -----------------------------------------------------------------------------
|
||||
#ifndef PIN_PMU_IRQ
|
||||
#define PIN_PMU_IRQ 21 // AXP2101 interrupt
|
||||
#endif
|
||||
#define PIN_RTC_IRQ 17 // PCF8563 interrupt
|
||||
#define PIN_ACCEL_IRQ 14 // BMA423 interrupt
|
||||
|
||||
// User button -- GPIO0 "Custom Button" on the T-Watch S3 Plus (idles HIGH,
|
||||
// active LOW), the same arrangement Meck uses on the T-Deck Pro.
|
||||
#define PIN_USER_BTN 0
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Display dimensions (ST7789V3 IPS, 240x240)
|
||||
// -----------------------------------------------------------------------------
|
||||
#define LCD_HOR_SIZE 240
|
||||
#define LCD_VER_SIZE 240
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Storage
|
||||
// -----------------------------------------------------------------------------
|
||||
// The T-Watch S3 Plus has no SD card slot. Notes/Reader/Epub screens reference
|
||||
// SDCARD_CS unconditionally, so it must be defined for them to compile. -1 is a
|
||||
// safe no-op on ESP32 (digitalWrite/pinMode reject out-of-range pins), and SD
|
||||
// mounts will simply fail at runtime. Persistent storage arrives in Phase 3 via
|
||||
// a LittleFS partition.
|
||||
#define SDCARD_CS -1
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// GPS (u-blox MIA-M10Q on Serial2)
|
||||
// Pin values mirror the working MeshCore companion build. Power is on the
|
||||
// AXP2101 BLDO1 rail and is gated at runtime via the gps_enabled pref +
|
||||
// board.gpsPowerOn()/gpsPowerOff() -- it is NOT a GPIO enable line, so
|
||||
// PIN_GPS_EN is deliberately left undefined (the main.cpp PIN_GPS_EN blocks
|
||||
// are skipped and the watch branch toggles BLDO1 instead).
|
||||
//
|
||||
// NOTE: GPS RX/TX wiring labelling is ambiguous between the LilyGo doc and the
|
||||
// MeshCore build. These values match the verified MeshCore config; if the
|
||||
// module never acquires, swapping GPS_RX_PIN/GPS_TX_PIN is the first thing to try.
|
||||
// -----------------------------------------------------------------------------
|
||||
#define HAS_GPS 1
|
||||
#define GPS_BAUDRATE 38400
|
||||
#define GPS_RX_PIN 41
|
||||
#define GPS_TX_PIN 42
|
||||
Reference in New Issue
Block a user