26 Commits
v1.0 ... v1.2

Author SHA1 Message Date
pelgraine
17f8233402 fix readme typo in last heard 2026-03-20 21:36:08 +11:00
pelgraine
1c9e9079f0 Merge branch 'dev' 2026-03-20 21:31:03 +11:00
pelgraine
69dc62fa78 update readme and txt reader guides for Meck v1.2 2026-03-20 21:17:18 +11:00
pelgraine
f118a0949f fix td pro platformio version whioops; tdpro reader screen ui fix - press enter to go to page 2026-03-20 20:52:39 +11:00
pelgraine
f78824cdc4 tdpro & t5s3 pro - lock screen power saving improvements; fix stupid stupid merged firmware - bug 2026-03-20 20:22:07 +11:00
pelgraine
f81de07830 t5s3 - improved cardkb notes rendering; fix notes generic filename save type 2026-03-20 08:05:23 +11:00
pelgraine
3ae988c0bb t5s3 cardkb support; update firmware build date 2026-03-20 06:23:05 +11:00
pelgraine
5bed26cb72 mostly t5s3 and some tdpro fixes - chunked save infrastructure, chunked save implementation, non-blocking lazy save, favourite contacts edit double confirmation added, hibernate 4g modem properly 2026-03-20 05:27:20 +11:00
pelgraine
c28d22e6cc Update README.md
Add discord link
2026-03-20 03:41:43 +11:00
pelgraine
8e1f2a3a87 t5s3 - last heard touch fix; lock screen 15 min refresh fix; update firmware build date 2026-03-19 17:05:40 +11:00
pelgraine
6d1447a45c fix accidental battery size commit from tdeckboard.h 2026-03-18 22:29:26 +11:00
pelgraine
77c92b3567 td pro: footer consistency text updates; improve key polling responsiveness; Add Last Heard screen, access by pressing h key; update mymesh firmware version and date 2026-03-18 22:22:11 +11:00
pelgraine
6db7b672ca t5s3 - improvements for page navigation to text reader 2026-03-17 19:17:51 +11:00
pelgraine
046cce6f43 tdpro - bugfix for slow responsiveness occurring if key is pressed during toaster popup message 2026-03-17 18:55:10 +11:00
pelgraine
c2c2d8cf21 tdpro - reduce occurrences of slow key responsiveness on boot 2026-03-17 18:42:12 +11:00
pelgraine
148f8cea4f tdpro lock screen stage 2 - auto lock settings preferences implemented 2026-03-17 17:42:10 +11:00
pelgraine
cd69ea546f tdpro lock screen stage 1 - double click user/boot to lock/unlock screen 2026-03-17 16:56:55 +11:00
pelgraine
7780a0d76e tdpro intial touch file selector implementation stage 1 2026-03-17 16:35:44 +11:00
pelgraine
33a3352692 tdpro - improved cpu usage for maps and increased key responsiveness after boot; updated firmware date and build 2026-03-17 15:46:42 +11:00
pelgraine
4004acf15d tdpro darkmode regression bugfixes; update readme 2026-03-15 15:36:18 +11:00
pelgraine
0b9402b530 updated readme for v.1.1 changes 2026-03-15 14:50:24 +11:00
pelgraine
e55799f8a5 tdpro settings screen updates and ui changes; gps baudrate selector kept to settings screen only; firmware version and build date updated 2026-03-15 14:41:03 +11:00
pelgraine
0549efa627 tdpro v1.0 gps debug fix 2026-03-15 14:17:05 +11:00
pelgraine
a52cf166cb update firmware build date 2026-03-14 20:14:38 +11:00
pelgraine
facffe9f07 t5s3 settings screen fix for add channels; t5s3 home screen new message screen refresh fix 2026-03-14 20:14:13 +11:00
pelgraine
148fb7f001 t5s3 minor ui settings screen channel delete fixes 2026-03-14 15:36:40 +11:00
27 changed files with 2100 additions and 379 deletions

107
README.md
View File

@@ -2,6 +2,8 @@
A fork created specifically to focus on enabling BLE & WiFi companion firmware for the LilyGo T-Deck Pro & LilyGo T5 E-Paper S3 Pro. Created with the assistance of Claude AI using Meshcore v1.11 code.
[Check out the Meck discussion channel on the MeshCore Discord](https://discord.com/channels/1343693475589263471/1460136499390447670)
<img src="https://github.com/user-attachments/assets/b30ce6bd-79af-44d3-93c4-f5e7e21e5621" alt="IMG_1453" width="300" height="650">
### Contents
@@ -16,6 +18,7 @@ A fork created specifically to focus on enabling BLE & WiFi companion firmware f
- [Keyboard Controls](#t-deck-pro-keyboard-controls)
- [Navigation (Home Screen)](#navigation-home-screen)
- [Bluetooth (BLE)](#bluetooth-ble)
- [WiFi Companion](#wifi-companion)
- [Clock & Timezone](#clock--timezone)
- [Channel Message Screen](#channel-message-screen)
- [Contacts Screen](#contacts-screen)
@@ -27,6 +30,7 @@ A fork created specifically to focus on enabling BLE & WiFi companion firmware f
- [Emoji Picker](#emoji-picker)
- [SMS & Phone App (4G only)](#sms--phone-app-4g-only)
- [Web Browser & IRC](#web-browser--irc)
- [Lock Screen (T-Deck Pro)](#lock-screen-t-deck-pro)
- [T5S3 E-Paper Pro](#t5s3-e-paper-pro)
- [Build Variants](#t5s3-build-variants)
- [Touch Navigation](#touch-navigation)
@@ -76,7 +80,7 @@ Download the latest firmware from the [Releases](https://github.com/pelgraine/Me
| File Type | When to Use |
|-----------|-------------|
| `*_merged.bin` | **First-time flash** — includes bootloader, partition table, and firmware in a single file. Flash at address `0x0`. |
| `*-merged.bin` | **First-time flash** — includes bootloader, partition table, and firmware in a single file. Flash at address `0x0`. |
| `*.bin` (non-merged) | **Upgrading existing firmware** — firmware image only. Also used when loading firmware from an SD card via the Launcher. |
### First-Time Flash (Merged Firmware)
@@ -87,7 +91,7 @@ If the device has never had Meck firmware (or you want a clean start), use the *
```
esptool.py --chip esp32s3 --port /dev/ttyACM0 --baud 921600 \
write_flash 0x0 meck_t5s3_standalone_merged.bin
write_flash 0x0 meck_t5s3_standalone-merged.bin
```
On macOS the port is typically `/dev/cu.usbmodem*`. On Windows it will be a COM port like `COM3`.
@@ -99,7 +103,7 @@ On macOS the port is typically `/dev/cu.usbmodem*`. On Windows it will be a COM
3. Select the **merged** `.bin` file you downloaded
4. Click **Flash**, select your device in the popup, and click **Connect**
> **Note:** The MeshCore Flasher flashes at address `0x0` by default, so the merged file is the correct choice here for first-time flashes.
> **Note:** The MeshCore Flasher detects merged firmware by the `-merged.bin` suffix in the filename and automatically flashes at address `0x0`. If the filename doesn't end with `-merged.bin`, the flasher writes at `0x10000` instead, which will fail on a clean device.
### Upgrading Firmware
@@ -149,8 +153,10 @@ For a detailed explanation of what multibyte path hash means and why it matters,
| Variant | Environment | BLE | WiFi | 4G Modem | Audio DAC | Web Reader | Max Contacts |
|---------|------------|-----|------|----------|-----------|------------|-------------|
| Audio + BLE | `meck_audio_ble` | Yes | Yes (via BLE stack) | — | PCM5102A | Yes | 500 |
| Audio + WiFi | `meck_audio_wifi` | — | Yes (TCP:5000) | — | PCM5102A | Yes | 1,500 |
| Audio + Standalone | `meck_audio_standalone` | — | — | — | PCM5102A | No | 1,500 |
| 4G + BLE | `meck_4g_ble` | Yes | Yes | A7682E | — | Yes | 500 |
| 4G + WiFi | `meck_4g_wifi` | — | Yes (TCP:5000) | A7682E | — | Yes | 1,500 |
| 4G + Standalone | `meck_4g_standalone` | — | Yes | A7682E | — | Yes | 1,500 |
The audio DAC and 4G modem occupy the same hardware slot and are mutually exclusive.
@@ -175,8 +181,10 @@ The T-Deck Pro firmware includes full keyboard support for standalone messaging
| T | Open SMS & Phone app (4G variant only) |
| P | Open audiobook player (audio variant only) |
| F | Open node discovery (search for nearby repeaters/nodes) |
| H | Open last heard list (passive advert history) |
| G | Open map screen (shows contacts with GPS positions) |
| Q | Back to home screen |
| Double-click Boot | Lock / unlock screen |
### Bluetooth (BLE)
@@ -184,12 +192,29 @@ BLE is **disabled by default** at boot to support standalone-first operation. Th
To connect to the MeshCore companion app, navigate to the **Bluetooth** home page (use D to page through) and press **Enter** to toggle BLE on. The BLE PIN will be displayed on screen. Toggle it off again the same way when you're done.
### WiFi Companion
The WiFi companion variants (`meck_audio_wifi`, `meck_4g_wifi`) connect to the MeshCore web app, meshcore.js, or Python CLI over your local network via TCP on port 5000. WiFi credentials are stored on the SD card at `/web/wifi.cfg`.
**Connecting:**
1. Navigate to the **WiFi** home page (use D to page through)
2. Press **Enter** to toggle WiFi on
3. The device scans for networks — select yours and enter the password
4. Once connected, the IP address is displayed on the WiFi home page
Connect the MeshCore web app or meshcore.js to `<device IP>:5000`.
WiFi is also used by the web reader and IRC client on WiFi variants. The web reader shares the same connection — no extra setup needed.
> **Tip:** WiFi variants support up to 1,500 contacts (vs 500 for BLE variants) because they are not constrained by the BLE protocol ceiling.
### Clock & Timezone
The T-Deck Pro does not include a dedicated RTC chip, so after each reboot the device clock starts unset. The clock will appear in the nav bar (between node name and battery) once the time has been synced by one of two methods:
The T-Deck Pro does not include a dedicated RTC chip, so after each reboot the device clock starts unset. The clock will appear in the nav bar (between node name and battery) once the time has been synced by one of these methods:
1. **GPS fix** (standalone) — Once the GPS acquires a satellite fix, the time is automatically synced from the NMEA data. No phone or BLE connection required. Typical time to first fix is 3090 seconds outdoors with clear sky.
2. **BLE companion app** — If BLE is enabled and connected to the MeshCore companion app, the app will push the current time to the device.
2. **BLE/WiFi companion app** — If connected to the MeshCore companion app (via BLE or WiFi), the app will push the current time to the device.
**Setting your timezone:**
@@ -230,7 +255,7 @@ Press **C** from the home screen to open the contacts list. All known mesh conta
| R | Import contacts from SD card (wait 510 seconds for confirmation popup) |
| Q | Back to home screen |
**Contact limits:** Standalone variants support up to 1,500 contacts (stored in PSRAM). BLE variants (both Audio-BLE and 4G-BLE) are limited to 500 contacts due to BLE protocol constraints.
**Contact limits:** Standalone and WiFi variants support up to 1,500 contacts (stored in PSRAM). BLE variants (Audio-BLE and 4G-BLE) are limited to 500 contacts due to BLE protocol constraints.
### Sending a Direct Message
@@ -266,8 +291,8 @@ Press **S** from the home screen to open settings. On first boot (when the devic
| Key | Action |
|-----|--------|
| W / S | Navigate up / down through settings |
| Enter | Edit selected setting |
| Q | Back to home screen |
| Enter | Edit selected setting, or enter a sub-screen |
| Q | Back one level (sub-screen → top level → home screen) |
**Available settings:**
@@ -275,17 +300,26 @@ Press **S** from the home screen to open settings. On first boot (when the devic
|---------|-------------|
| Device Name | Text entry — type a name, Enter to confirm |
| Radio Preset | A / D to cycle presets (MeshCore Default, Long Range, Fast/Short, EU Default), Enter to apply |
| Frequency | W / S to adjust, Enter to confirm |
| Frequency | Text entry — type exact value (e.g. 916.575), Enter to confirm |
| Bandwidth | W / S to cycle standard values (31.25 / 62.5 / 125 / 250 / 500 kHz), Enter to confirm |
| Spreading Factor | W / S to adjust (512), Enter to confirm |
| Coding Rate | W / S to adjust (58), Enter to confirm |
| TX Power | W / S to adjust (120 dBm), Enter to confirm |
| UTC Offset | W / S to adjust (-12 to +14), Enter to confirm |
| Path Hash Mode | A / D to cycle (0 = 1-byte, 1 = 2-byte, 2 = 3-byte), Enter to confirm |
| Channels | View existing channels, add hashtag channels, or delete non-primary channels (X) |
| Msg Rcvd LED Pulse | Toggle keyboard backlight flash on new message (Enter to toggle) |
| GPS Baud Rate | A / D to cycle (Default 38400 / 4800 / 9600 / 19200 / 38400 / 57600 / 115200), Enter to confirm. **Requires reboot to take effect.** |
| Path Hash Mode | W / S to cycle (1-byte / 2-byte / 3-byte), Enter to confirm |
| Dark Mode | Toggle inverted display — white text on black background (Enter to toggle) |
| Auto Lock | A / D to cycle timeout (None / 2 / 5 / 10 / 15 / 30 min), Enter to confirm |
| Contacts >> | Opens the Contacts sub-screen (see below) |
| Channels >> | Opens the Channels sub-screen (see below) |
| Device Info | Public key and firmware version (read-only) |
The bottom of the settings screen also displays your node ID and firmware version. On the 4G variant, IMEI, carrier name, and APN details are shown here as well.
**Contacts sub-screen** — press Enter on the `Contacts >>` row to open. Contains the contact auto-add mode picker (Auto All / Custom / Manual) and, when set to Custom, per-type toggles for Chat, Repeater, Room Server, Sensor, and an Overwrite Oldest option. Press Q to return to the top-level settings list.
**Channels sub-screen** — press Enter on the `Channels >>` row to open. Lists all current channels, with an option to add hashtag channels or delete non-primary channels (X). Press Q to return to the top-level settings list.
The top-level settings screen also displays your node ID and firmware version. On the 4G variant, IMEI, carrier name, and APN details are shown here as well.
When adding a hashtag channel, type the channel name and press Enter. The channel secret is automatically derived from the name via SHA-256, matching the standard MeshCore hashtag convention.
@@ -346,7 +380,7 @@ For full documentation including key mappings, dialpad usage, contacts managemen
### Web Browser & IRC
Press **B** from the home screen to open the web reader. This is available on the BLE and 4G variants (not the standalone audio variant, which excludes WiFi to preserve lowest-battery-usage design).
Press **B** from the home screen to open the web reader. This is available on the BLE, WiFi, and 4G variants (not the standalone audio variant, which excludes WiFi to preserve lowest-battery-usage design).
The web reader home screen provides access to the **IRC client**, the **URL bar**, and your **bookmarks** and **history**. Select IRC Chat and press Enter to configure and connect to an IRC server. Select the URL bar to enter a web address, or scroll down to open a bookmark or history entry.
@@ -354,6 +388,14 @@ The browser is a text-centric reader best suited to text-heavy websites. It also
For full documentation including key mappings, WiFi setup, bookmarks, IRC configuration, and SD card structure, see the [Web App Guide](Web_App_Guide.md).
### Lock Screen (T-Deck Pro)
Double-click the Boot button to lock the screen. The lock screen shows the current time, battery percentage, and unread message count. The CPU drops to 40 MHz while locked to reduce power consumption.
Double-click the Boot button again to unlock and return to whatever screen you were on.
An auto-lock timer can be configured in **Settings → Auto Lock** (None / 2 / 5 / 10 / 15 / 30 minutes of idle time).
---
## T5S3 E-Paper Pro
@@ -423,6 +465,8 @@ Long press the Boot button to lock the device. The lock screen shows:
Touch input is completely disabled while locked. Long press the Boot button again to unlock and return to whatever screen you were on.
An auto-lock timer can be configured in **Settings → Auto Lock** (None / 2 / 5 / 10 / 15 / 30 minutes of idle time). The CPU drops to 40 MHz while locked to reduce power consumption.
### Virtual Keyboard
Since the T5S3 has no physical keyboard, a full-screen QWERTY virtual keyboard appears automatically when text input is needed (composing messages, entering WiFi passwords, editing settings, etc.).
@@ -435,14 +479,20 @@ The virtual keyboard supports:
Tap keys to type. Tap **Enter** to submit, or press the **Boot button** to cancel and close the keyboard.
### External Keyboard (CardKB)
The T5S3 supports the M5Stack CardKB (or compatible I2C keyboard) connected via the QWIIC port. When detected at boot, the CardKB can be used for all text input — composing messages, entering URLs, editing notes, and navigating menus — without the on-screen virtual keyboard.
The CardKB is auto-detected on the I2C bus at address `0x5F`. No configuration is needed — just plug it in.
### Display Settings
The T5S3 Settings screen includes two additional options not available on the T-Deck Pro:
The T5S3 Settings screen includes one additional display option not available on the T-Deck Pro:
| Setting | Description |
|---------|-------------|
| **Dark Mode** | Inverts the display — white text on black background. Tap to toggle on/off. |
| **Portrait Mode** | Rotates the display 90° from landscape (960×540) to portrait (540×960). Touch coordinates are automatically remapped. Text reader layout recalculates on orientation change. |
| **Dark Mode** | Inverts the display — white text on black background. Tap to toggle on/off. Available on both T-Deck Pro and T5S3. |
| **Portrait Mode** | Rotates the display 90° from landscape (960×540) to portrait (540×960). Touch coordinates are automatically remapped. Text reader layout recalculates on orientation change. T5S3 only. |
These settings are persisted and survive reboots.
@@ -508,6 +558,7 @@ The UTC offset is configured in the Settings screen (same as T-Deck Pro) and is
| Gesture | Action |
|---------|--------|
| Tap anywhere | Next page |
| Tap footer bar | Go to page number (via virtual keyboard) |
| Swipe left | Next page |
| Swipe right | Previous page |
| Swipe up / down | Next / previous page |
@@ -548,8 +599,16 @@ The UTC offset is configured in the Settings screen (same as T-Deck Pro) and is
| Gesture | Action |
|---------|--------|
| Swipe up / down | Scroll node list |
| Tap | Add selected node to contacts |
| Long press | Rescan for nodes |
#### Last Heard
| Gesture | Action |
|---------|--------|
| Swipe up / down | Scroll advert list |
| Tap | Add to or delete from contacts |
#### Repeater Admin
| Gesture | Action |
@@ -610,9 +669,9 @@ For developers:
**Companion Firmware**
The companion firmware can be connected to via BLE (T-Deck Pro and T5S3 BLE variants) or WiFi (T5S3 WiFi variant, TCP port 5000).
The companion firmware can be connected to via BLE (T-Deck Pro and T5S3 BLE variants) or WiFi (T-Deck Pro WiFi variants and T5S3 WiFi variant, TCP port 5000).
> **Note:** On both the T-Deck Pro and T5S3, BLE is disabled by default at boot. On the T-Deck Pro, navigate to the Bluetooth home page and press Enter to enable BLE. On the T5S3, navigate to the Bluetooth home page and long-press the screen to toggle BLE on.
> **Note:** On both the T-Deck Pro and T5S3, BLE and WiFi are disabled by default at boot. On the T-Deck Pro, navigate to the Bluetooth or WiFi home page and press Enter to enable. On the T5S3, navigate to the Bluetooth home page and long-press the screen to toggle BLE on.
- Web: https://app.meshcore.nz
- Android: https://play.google.com/store/apps/details?id=com.liamcottle.meshcore.android
@@ -649,17 +708,20 @@ There are a number of fairly major features in the pipeline, with no particular
- [X] Settings screen with radio presets, channel management, and first-boot onboarding
- [X] Expand SMS app to enable phone calls
- [X] Basic web reader app with IRC client
- [X] Lock screen with auto-lock timer and low-power standby
- [X] Last heard passive advert list
- [X] Touch-to-select on contacts, discovery, settings, text reader, notes screens
- [X] Map screen with GPS tile rendering
- [ ] Fix M4B rendering to enable chaptered audiobook playback
- [ ] Better JPEG and PNG decoding
- [ ] Improve EPUB rendering and EPUB format handling
- [ ] Map support with GPS
- [ ] WiFi companion environment
- [X] WiFi companion environment
**T5S3 E-Paper Pro:**
- [X] Core port: display, touch input, LoRa, battery, RTC
- [X] Touch-navigable home screen with tappable tile grid
- [X] Full virtual keyboard for text entry
- [X] Lock screen with clock, battery, and unread count
- [X] Lock screen with clock, battery, unread count, and auto-lock timer
- [X] Backlight control (double/triple-click Boot button)
- [X] Dark mode and portrait mode display settings
- [X] Channel messages with swipe navigation and touch compose
@@ -668,6 +730,9 @@ There are a number of fairly major features in the pipeline, with no particular
- [X] Web reader with virtual keyboard URL/search entry (WiFi variant)
- [X] Settings screen with touch editing
- [X] Serial clock sync for hardware RTC
- [X] CardKB external keyboard support (via QWIIC)
- [X] Last heard passive advert list
- [X] Tap-to-select on contacts, discovery, settings, text reader, notes screens
- [ ] Emoji sprites on home tiles
- [ ] Portrait mode toggle via quadruple-click Boot button
- [ ] Hibernate should auto-off backlight

View File

@@ -2,7 +2,7 @@
## Overview
This adds a text reader accessible via the **R** key from the home screen.
This adds a text reader accessible via the **E** key from the home screen.
**Features:**
- Browse `.txt` and `.epub` files from `/books/` folder on SD card
@@ -13,17 +13,27 @@ This adds a text reader accessible via the **R** key from the home screen.
- Index files cached to SD for instant re-opens
- Bookmark indicator (`*`) on files with saved positions
**Key Mapping:**
**Key Mapping (T-Deck Pro):**
| Context | Key | Action |
|---------|-----|--------|
| Home screen | E | Open text reader |
| File list | W/S | Navigate up/down |
| File list | Enter | Open selected file |
| File list | Tap / Enter | Open selected file |
| File list | Q | Back to home screen |
| Reading | W/A | Previous page |
| Reading | S/D/Space/Enter | Next page |
| Reading | S/D/Space | Next page |
| Reading | Enter | Go to page number (type digits, Enter to confirm, Q to cancel) |
| Reading | Q | Close book → file list |
| Reading | C | Enter compose mode |
**Touch Gestures (T5S3):**
| Context | Gesture | Action |
|---------|---------|--------|
| File list | Swipe up/down | Scroll file list |
| File list | Tap | Open selected book |
| Reading | Tap | Next page |
| Reading | Swipe left/right | Next / previous page |
| Reading | Tap footer | Go to page number (via virtual keyboard) |
| Reading | Long press | Close book → file list |
---
@@ -113,4 +123,4 @@ The conversion is handled by three components:
- Page content is pre-read from SD into a memory buffer during `handleInput()`, then rendered from buffer during `render()` — this avoids SPI bus conflicts during display refresh
- Layout metrics (chars per line, lines per page) are calculated dynamically from the display driver's font metrics on first entry
- EPUB conversion runs synchronously in `openBook()` — the e-ink splash screen keeps the user informed while the ESP32 processes the archive
- ZIP extraction uses the ESP32-S3's hardware-optimised ROM `tinfl` inflate, avoiding external compression library dependencies and the linker conflicts they cause
- ZIP extraction uses the ESP32-S3's hardware-optimised ROM `tinfl` inflate, avoiding external compression library dependencies and the linker conflicts they cause

View File

@@ -443,6 +443,101 @@ void DataStore::saveContacts(DataStoreHost* host) {
}
}
// =========================================================================
// Chunked contact save — non-blocking across multiple loop iterations
// =========================================================================
bool DataStore::beginSaveContacts(DataStoreHost* host) {
if (_saveInProgress) return false; // Already saving
FILESYSTEM* fs = _getContactsChannelsFS();
_saveFile = openWrite(fs, "/contacts3.tmp");
if (!_saveFile) {
Serial.println("DataStore: chunked save FAILED — cannot open tmp file");
return false;
}
_saveHost = host;
_saveIdx = 0;
_saveRecordsWritten = 0;
_saveWriteOk = true;
_saveInProgress = true;
Serial.println("DataStore: chunked save started");
return true;
}
bool DataStore::saveContactsChunk(int batchSize) {
if (!_saveInProgress || !_saveWriteOk) return false;
ContactInfo c;
uint8_t unused = 0;
int written = 0;
while (written < batchSize && _saveHost->getContactForSave(_saveIdx, c)) {
bool success = (_saveFile.write(c.id.pub_key, 32) == 32);
success = success && (_saveFile.write((uint8_t *)&c.name, 32) == 32);
success = success && (_saveFile.write(&c.type, 1) == 1);
success = success && (_saveFile.write(&c.flags, 1) == 1);
success = success && (_saveFile.write(&unused, 1) == 1);
success = success && (_saveFile.write((uint8_t *)&c.sync_since, 4) == 4);
success = success && (_saveFile.write((uint8_t *)&c.out_path_len, 1) == 1);
success = success && (_saveFile.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
success = success && (_saveFile.write(c.out_path, 64) == 64);
success = success && (_saveFile.write((uint8_t *)&c.lastmod, 4) == 4);
success = success && (_saveFile.write((uint8_t *)&c.gps_lat, 4) == 4);
success = success && (_saveFile.write((uint8_t *)&c.gps_lon, 4) == 4);
if (!success) {
_saveWriteOk = false;
Serial.printf("DataStore: chunked save write error at record %d\n", _saveIdx);
return false; // Error — finishSaveContacts will clean up
}
_saveRecordsWritten++;
_saveIdx++;
written++;
}
// Check if there are more contacts to write
ContactInfo peek;
if (_saveHost->getContactForSave(_saveIdx, peek)) {
return true; // More to write
}
return false; // Done
}
void DataStore::finishSaveContacts() {
if (!_saveInProgress) return;
_saveFile.close();
_saveInProgress = false;
FILESYSTEM* fs = _getContactsChannelsFS();
const char* finalPath = "/contacts3";
const char* tmpPath = "/contacts3.tmp";
// Verify
size_t expectedBytes = _saveRecordsWritten * 152;
File verify = openRead(fs, tmpPath);
size_t bytesWritten = verify ? verify.size() : 0;
if (verify) verify.close();
if (!_saveWriteOk || bytesWritten != expectedBytes) {
Serial.printf("DataStore: chunked save ABORTED — wrote %d bytes, expected %d (%d records)\n",
(int)bytesWritten, (int)expectedBytes, _saveRecordsWritten);
fs->remove(tmpPath);
return;
}
fs->remove(finalPath);
if (fs->rename(tmpPath, finalPath)) {
Serial.printf("DataStore: saved %d contacts (%d bytes, chunked)\n",
_saveRecordsWritten, (int)bytesWritten);
} else {
Serial.println("DataStore: rename failed, tmp file preserved");
}
}
void DataStore::loadChannels(DataStoreHost* host) {
FILESYSTEM* fs = _getContactsChannelsFS();

View File

@@ -24,6 +24,14 @@ class DataStore {
void checkAdvBlobFile();
#endif
// Chunked save state
File _saveFile;
DataStoreHost* _saveHost = nullptr;
uint32_t _saveIdx = 0;
uint32_t _saveRecordsWritten = 0;
bool _saveInProgress = false;
bool _saveWriteOk = true;
public:
DataStore(FILESYSTEM& fs, mesh::RTCClock& clock);
DataStore(FILESYSTEM& fs, FILESYSTEM& fsExtra, mesh::RTCClock& clock);
@@ -37,6 +45,14 @@ public:
void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon);
void loadContacts(DataStoreHost* host);
void saveContacts(DataStoreHost* host);
// Chunked save — splits contact write across multiple loop iterations
// to prevent blocking the main loop for 500ms+ on large contact lists.
// Call beginSaveContacts(), then saveContactsChunk() each loop until it
// returns false (done), then finishSaveContacts() to verify and commit.
bool beginSaveContacts(DataStoreHost* host);
bool saveContactsChunk(int batchSize = 20); // returns true if more to write
void finishSaveContacts();
bool isSaveInProgress() const { return _saveInProgress; }
void loadChannels(DataStoreHost* host);
void saveChannels(DataStoreHost* host);
void migrateToSecondaryFS();
@@ -51,4 +67,4 @@ public:
private:
FILESYSTEM* _getContactsChannelsFS() const { if (_fsExtra) return _fsExtra; return _fs;};
};
};

View File

@@ -380,6 +380,7 @@ void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path
memcpy(p->pubkey_prefix, contact.id.pub_key, sizeof(p->pubkey_prefix));
strcpy(p->name, contact.name);
p->type = contact.type;
p->recv_timestamp = getRTCClock()->getCurrentTime();
p->path_len = mesh::Packet::copyPath(p->path, path, path_len);
}
@@ -427,6 +428,10 @@ int MyMesh::getRecentlyHeard(AdvertPath dest[], int max_num) {
return max_num;
}
void MyMesh::scheduleLazyContactSave() {
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
}
void MyMesh::onContactPathUpdated(const ContactInfo &contact) {
out_frame[0] = PUSH_CODE_PATH_UPDATED;
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
@@ -1193,12 +1198,24 @@ void MyMesh::begin(bool has_display) {
_prefs.gps_baudrate != 9600 && _prefs.gps_baudrate != 19200 &&
_prefs.gps_baudrate != 38400 && _prefs.gps_baudrate != 57600 &&
_prefs.gps_baudrate != 115200) {
Serial.printf("PREFS: invalid gps_baudrate=%lu — reset to 0 (default)\n",
(unsigned long)_prefs.gps_baudrate);
_prefs.gps_baudrate = 0; // reset to default if invalid
}
// interference_threshold: 0 = disabled, minimum functional value is 14
// interference_threshold: 0 = disabled, minimum functional value is 14, max sane ~30
if (_prefs.interference_threshold > 0 && _prefs.interference_threshold < 14) {
_prefs.interference_threshold = 0;
}
if (_prefs.interference_threshold > 50) {
Serial.printf("PREFS: invalid interference_threshold=%d — reset to 0 (disabled)\n",
_prefs.interference_threshold);
_prefs.interference_threshold = 0; // garbage from prefs upgrade — disable
}
// Clamp remaining v1.0 fields that may contain garbage after upgrade from older firmware
if (_prefs.path_hash_mode > 2) _prefs.path_hash_mode = 0;
if (_prefs.autoadd_max_hops > 64) _prefs.autoadd_max_hops = 0;
if (_prefs.dark_mode > 1) _prefs.dark_mode = 0;
if (_prefs.portrait_mode > 1) _prefs.portrait_mode = 0;
#ifdef BLE_PIN_CODE // 123456 by default
if (_prefs.ble_pin == 0) {
@@ -2982,10 +2999,19 @@ void MyMesh::loop() {
// is there are pending dirty contacts write needed?
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
saveContacts();
if (!_store->isSaveInProgress()) {
_store->beginSaveContacts(this);
}
dirty_contacts_expiry = 0;
}
// Drive chunked contact save — write a batch each loop iteration
if (_store->isSaveInProgress()) {
if (!_store->saveContactsChunk(20)) { // 20 contacts per chunk (~3KB, ~30ms)
_store->finishSaveContacts(); // Done or error — verify and commit
}
}
// Discovery scan timeout
if (_discoveryActive && millisHasNowPassed(_discoveryTimeout)) {
_discoveryActive = false;

View File

@@ -8,11 +8,11 @@
#define FIRMWARE_VER_CODE 10
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "13 March 2026"
#define FIRMWARE_BUILD_DATE "20 March 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v1.0"
#define FIRMWARE_VERSION "Meck v1.2"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -79,6 +79,7 @@
struct AdvertPath {
uint8_t pubkey_prefix[7];
uint8_t path_len;
uint8_t type; // ADV_TYPE_* (Chat/Repeater/Room/Sensor)
char name[32];
uint32_t recv_timestamp;
uint8_t path[MAX_PATH_SIZE];
@@ -119,6 +120,12 @@ public:
int getDiscoveredCount() const { return _discoveredCount; }
const DiscoveredNode& getDiscovered(int idx) const { return _discovered[idx]; }
bool addDiscoveredToContacts(int idx); // promote a discovered node into contacts
// Last Heard — public wrappers for contact add/remove from UI
void scheduleLazyContactSave();
int getContactBlob(const uint8_t key[], int key_len, uint8_t dest_buf[]) {
return getBlobByKey(key, key_len, dest_buf);
}
// Queue a sent channel message for BLE app sync
void queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text);
@@ -262,7 +269,7 @@ private:
AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table
int next_ack_idx;
#define ADVERT_PATH_TABLE_SIZE 16
#define ADVERT_PATH_TABLE_SIZE 40
AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table
// Sent message repeat tracking

View File

@@ -37,4 +37,5 @@ struct NodePrefs { // persisted to file
uint8_t interference_threshold; // Interference threshold in dB (0=disabled, 14+=enabled)
uint8_t dark_mode; // 0=off (white bg), 1=on (black bg)
uint8_t portrait_mode; // 0=landscape, 1=portrait — T5S3 only
uint8_t auto_lock_minutes; // 0=disabled, 2/5/10/15/30=auto-lock after idle
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
#pragma once
// =============================================================================
// CardKBKeyboard — M5Stack CardKB (or compatible) I2C keyboard driver
//
// Polls 0x5F on the shared I2C bus via QWIIC connector.
// Maps CardKB special key codes to Meck key constants.
//
// Usage:
// CardKBKeyboard cardkb;
// if (cardkb.begin()) { /* detected */ }
// char key = cardkb.readKey(); // returns 0 if no key
// =============================================================================
#if defined(LilyGo_T5S3_EPaper_Pro) && defined(MECK_CARDKB)
#ifndef CARDKB_KEYBOARD_H
#define CARDKB_KEYBOARD_H
#include <Arduino.h>
#include <Wire.h>
#include "variant.h" // For I2C_SDA, I2C_SCL (bus recovery)
// I2C address (defined in variant.h, fallback here)
#ifndef CARDKB_I2C_ADDR
#define CARDKB_I2C_ADDR 0x5F
#endif
// CardKB special key codes (from M5Stack documentation)
#define CARDKB_KEY_UP 0xB5
#define CARDKB_KEY_DOWN 0xB6
#define CARDKB_KEY_LEFT 0xB4
#define CARDKB_KEY_RIGHT 0xB7
#define CARDKB_KEY_TAB 0x09
#define CARDKB_KEY_ESC 0x1B
#define CARDKB_KEY_BS 0x08
#define CARDKB_KEY_ENTER 0x0D
#define CARDKB_KEY_DEL 0x7F
#define CARDKB_KEY_FN 0x00 // Fn modifier (swallowed by CardKB internally)
class CardKBKeyboard {
public:
CardKBKeyboard() : _detected(false) {}
// Probe for CardKB on the I2C bus. Call after Wire.begin().
bool begin() {
Wire.beginTransmission(CARDKB_I2C_ADDR);
_detected = (Wire.endTransmission() == 0);
if (_detected) {
Serial.println("[CardKB] Detected at 0x5F");
}
return _detected;
}
// Re-probe (e.g. for hot-plug detection every few seconds)
bool probe() {
Wire.beginTransmission(CARDKB_I2C_ADDR);
_detected = (Wire.endTransmission() == 0);
return _detected;
}
bool isDetected() const { return _detected; }
// Poll for a keypress. Returns 0 if no key available.
// Returns raw ASCII for printable chars, or Meck KEY_* constants for nav keys.
// Throttled to avoid flooding I2C bus — polls at most every 50ms.
// On read failure, backs off 500ms and re-inits Wire to recover bus state.
char readKey() {
if (!_detected) return 0;
unsigned long now = millis();
if (now - _lastPoll < _pollInterval) return 0;
_lastPoll = now;
Wire.requestFrom((uint8_t)CARDKB_I2C_ADDR, (uint8_t)1);
if (!Wire.available()) {
_errorCount++;
if (_errorCount >= 3) {
// I2C bus may be stuck — re-init to recover
Wire.begin(I2C_SDA, I2C_SCL);
Wire.setClock(100000);
_pollInterval = 500; // Back off for 500ms
_errorCount = 0;
Serial.println("[CardKB] I2C error recovery — bus re-init");
}
return 0;
}
_errorCount = 0;
_pollInterval = 50; // Normal polling rate
uint8_t raw = Wire.read();
if (raw == 0) return 0;
// Map CardKB special keys to Meck constants
switch (raw) {
case CARDKB_KEY_UP: return 0xF2; // KEY_PREV
case CARDKB_KEY_DOWN: return 0xF1; // KEY_NEXT
case CARDKB_KEY_LEFT: return 0xF3; // KEY_LEFT
case CARDKB_KEY_RIGHT: return 0xF4; // KEY_RIGHT
case CARDKB_KEY_ENTER: return '\r';
case CARDKB_KEY_BS: return '\b';
case CARDKB_KEY_DEL: return '\b'; // Treat delete same as backspace
case CARDKB_KEY_ESC: return 0x1B; // ESC — handled by caller
case CARDKB_KEY_TAB: return 0x09; // Tab — available for future use
default:
// Printable ASCII — pass through unchanged
if (raw >= 0x20 && raw <= 0x7E) {
return (char)raw;
}
// Unknown code — ignore
return 0;
}
}
private:
bool _detected;
unsigned long _lastPoll = 0;
unsigned long _pollInterval = 50; // ms between polls (increases on error)
uint8_t _errorCount = 0;
};
#endif // CARDKB_KEYBOARD_H
#endif // LilyGo_T5S3_EPaper_Pro && MECK_CARDKB

View File

@@ -152,6 +152,31 @@ public:
FilterMode getFilter() const { return _filter; }
// Tap-to-select: given virtual Y, select contact row.
// Returns: 0=miss, 1=moved, 2=tapped current row.
int selectRowAtVY(int vy) {
if (_filteredCount == 0) return 0;
const int headerH = 14, footerH = 14, lineH = 9;
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = headerH;
#else
const int bodyTop = headerH + 5;
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
int maxVisible = (128 - headerH - footerH) / lineH;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_scrollPos - maxVisible / 2,
_filteredCount - maxVisible));
int tappedRow = startIdx + (vy - bodyTop) / lineH;
if (tappedRow < 0 || tappedRow >= _filteredCount) return 0;
if (tappedRow == _scrollPos) return 2;
_scrollPos = tappedRow;
return 1;
}
// Get the raw contact table index for the currently highlighted item
// Returns -1 if no valid selection
int getSelectedContactIdx() const {
@@ -314,15 +339,10 @@ public:
#else
// Left: Q:Bk
display.setCursor(0, footerY);
display.print("Q:Bk");
display.print("Q:Bk A/D:Filter");
// Center: A/D:Filter
const char* mid = "A/D:Filtr";
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
display.print(mid);
// Right: F:Dscvr
const char* right = "F:Dscvr";
// Right: Tap/Ent:Select
const char* right = "Tap/Ent:Select";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#endif

View File

@@ -44,6 +44,32 @@ public:
int getSelectedIdx() const { return _scrollPos; }
// Tap-to-select: given virtual Y, select discovered node row.
// Returns: 0=miss, 1=moved, 2=tapped current row.
int selectRowAtVY(int vy) {
int count = the_mesh.getDiscoveredCount();
if (count == 0) return 0;
const int headerH = 14, footerH = 14, lineH = 9;
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = headerH;
#else
const int bodyTop = headerH + 5;
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
int maxVisible = (128 - headerH - footerH) / lineH;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_scrollPos - maxVisible / 2,
count - maxVisible));
int tappedRow = startIdx + (vy - bodyTop) / lineH;
if (tappedRow < 0 || tappedRow >= count) return 0;
if (tappedRow == _scrollPos) return 2;
_scrollPos = tappedRow;
return 1;
}
int render(DisplayDriver& display) override {
int count = the_mesh.getDiscoveredCount();
bool active = the_mesh.isDiscoveryActive();
@@ -177,13 +203,9 @@ public:
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#else
display.print("Q:Back");
display.print("Q:Bk F:Rescan");
const char* mid = "Ent:Add";
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
display.print(mid);
const char* right = "F:Rescan";
const char* right = "Tap/Ent:Add";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#endif

View File

@@ -0,0 +1,237 @@
#pragma once
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/AdvertDataHelpers.h>
#include <MeshCore.h>
extern MyMesh the_mesh;
// ==========================================================================
// Last Heard Screen — passive advert list
// Shows all recently heard nodes from the advert path table, sorted by
// recency. Unlike Discovery (active zero-hop scan), this is purely passive
// — it shows nodes whose adverts have been received over time.
// ==========================================================================
class LastHeardScreen : public UIScreen {
mesh::RTCClock* _rtc;
int _scrollPos;
// Local sorted copy of advert paths (refreshed each render)
AdvertPath _entries[ADVERT_PATH_TABLE_SIZE];
int _count;
static char typeChar(uint8_t adv_type) {
switch (adv_type) {
case ADV_TYPE_CHAT: return 'C';
case ADV_TYPE_REPEATER: return 'R';
case ADV_TYPE_ROOM: return 'S';
case ADV_TYPE_SENSOR: return 'N';
default: return '?';
}
}
// Format age as human-readable string (e.g. "2m", "1h", "3d")
static void formatAge(uint32_t now, uint32_t timestamp, char* buf, int bufLen) {
if (timestamp == 0 || now < timestamp) {
snprintf(buf, bufLen, "---");
return;
}
uint32_t age = now - timestamp;
if (age < 60) snprintf(buf, bufLen, "%ds", age);
else if (age < 3600) snprintf(buf, bufLen, "%dm", age / 60);
else if (age < 86400) snprintf(buf, bufLen, "%dh", age / 3600);
else snprintf(buf, bufLen, "%dd", age / 86400);
}
public:
LastHeardScreen(mesh::RTCClock* rtc)
: _rtc(rtc), _scrollPos(0), _count(0) {}
void resetScroll() { _scrollPos = 0; }
int getSelectedIdx() const { return _scrollPos; }
// Check if selected node is already in contacts
bool isSelectedInContacts() const {
if (_scrollPos < 0 || _scrollPos >= _count) return false;
return the_mesh.lookupContactByPubKey(_entries[_scrollPos].pubkey_prefix, 8) != nullptr;
}
// Get selected entry (for add/delete operations)
const AdvertPath* getSelectedEntry() const {
if (_scrollPos < 0 || _scrollPos >= _count) return nullptr;
return &_entries[_scrollPos];
}
// Tap-to-select: given virtual Y, select row.
// Returns: 0=miss, 1=moved, 2=tapped current row.
int selectRowAtVY(int vy) {
if (_count == 0) return 0;
const int headerH = 14, footerH = 14, lineH = 9;
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = headerH;
#else
const int bodyTop = headerH + 5;
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
int maxVisible = (128 - headerH - footerH) / lineH;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_scrollPos - maxVisible / 2,
_count - maxVisible));
int tappedRow = startIdx + (vy - bodyTop) / lineH;
if (tappedRow < 0 || tappedRow >= _count) return 0;
if (tappedRow == _scrollPos) return 2;
_scrollPos = tappedRow;
return 1;
}
int render(DisplayDriver& display) override {
// Refresh sorted list from mesh
_count = the_mesh.getRecentlyHeard(_entries, ADVERT_PATH_TABLE_SIZE);
// Filter out empty entries (recv_timestamp == 0)
int validCount = 0;
for (int i = 0; i < _count; i++) {
if (_entries[i].recv_timestamp > 0) validCount++;
else break; // sorted by recency, so first zero means rest are empty
}
_count = validCount;
if (_scrollPos >= _count) _scrollPos = max(0, _count - 1);
uint32_t now = _rtc->getCurrentTime();
// === Header ===
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
char hdr[32];
snprintf(hdr, sizeof(hdr), "Last Heard: %d nodes", _count);
display.print(hdr);
display.drawRect(0, 11, display.width(), 1);
// === Body — node rows ===
display.setTextSize(0);
int lineHeight = 9;
int headerHeight = 14;
int footerHeight = 14;
int maxY = display.height() - footerHeight;
int y = headerHeight;
if (_count == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(4, 28);
display.print("No adverts received yet");
display.setCursor(4, 38);
display.print("Nodes appear as adverts arrive");
} else {
int maxVisible = (maxY - headerHeight) / lineHeight;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_scrollPos - maxVisible / 2,
_count - maxVisible));
int endIdx = min(_count, startIdx + maxVisible);
for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) {
const AdvertPath& entry = _entries[i];
bool selected = (i == _scrollPos);
// Highlight selected row
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + 5, display.width(), lineHeight);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
// Prefix: cursor + type char
char prefix[4];
snprintf(prefix, sizeof(prefix), "%c%c",
selected ? '>' : ' ', typeChar(entry.type));
display.print(prefix);
// Right side: age + hops + [★] for favourites, [+] for other contacts
char rightStr[20];
char ageBuf[8];
formatAge(now, entry.recv_timestamp, ageBuf, sizeof(ageBuf));
ContactInfo* ci = the_mesh.lookupContactByPubKey(entry.pubkey_prefix, 8);
bool inContacts = (ci != nullptr);
bool isFav = inContacts && (ci->flags & 0x01);
if (isFav) {
snprintf(rightStr, sizeof(rightStr), "%s %dh [*]", ageBuf, entry.path_len & 63);
} else if (inContacts) {
snprintf(rightStr, sizeof(rightStr), "%s %dh [+]", ageBuf, entry.path_len & 63);
} else {
snprintf(rightStr, sizeof(rightStr), "%s %dh", ageBuf, entry.path_len & 63);
}
int rightWidth = display.getTextWidth(rightStr) + 2;
// Name (truncated with ellipsis)
char filteredName[32];
display.translateUTF8ToBlocks(filteredName, entry.name, sizeof(filteredName));
int nameX = display.getTextWidth(prefix) + 2;
int nameMaxW = display.width() - nameX - rightWidth - 2;
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
// Right-aligned info
display.setCursor(display.width() - rightWidth, y);
display.print(rightStr);
y += lineHeight;
}
}
display.setTextSize(1);
// === Footer ===
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe:Scroll");
const char* right = "Tap:Add/Del";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#else
display.print("Q:Bk");
const char* right = "Tap/Ent:Add/Del";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#endif
return 5000; // refresh every 5s to update ages
}
bool handleInput(char c) override {
// Scroll up
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_scrollPos > 0) { _scrollPos--; return true; }
return false;
}
// Scroll down
if (c == 's' || c == 'S' || c == 0xF1) {
if (_scrollPos < _count - 1) { _scrollPos++; return true; }
return false;
}
// Enter — handled by main.cpp (needs access to private MyMesh methods)
// Q — handled by main.cpp (navigation)
return false;
}
};

View File

@@ -137,22 +137,18 @@ public:
_zoomMin(MAP_MIN_ZOOM),
_zoomMax(MAP_MAX_ZOOM),
_pngBuf(nullptr),
_lineBuf(nullptr),
_tileFound(false)
{
// Allocate marker array in PSRAM at construction (~20KB)
// so addMarker() works before enter() is called
_markers = (MapMarker*)ps_calloc(MAP_MAX_MARKERS, sizeof(MapMarker));
if (_markers) {
Serial.printf("MapScreen: markers allocated (%d × %d = %d bytes PSRAM)\n",
MAP_MAX_MARKERS, (int)sizeof(MapMarker),
MAP_MAX_MARKERS * (int)sizeof(MapMarker));
} else {
Serial.println("MapScreen: marker PSRAM alloc FAILED");
}
// Marker array and PNG buffers are deferred to enter() to avoid
// consuming 20KB+ PSRAM at boot when the map may never be opened.
_markers = nullptr;
_numMarkers = 0;
}
~MapScreen() {
if (_pngBuf) { free(_pngBuf); _pngBuf = nullptr; }
if (_lineBuf) { free(_lineBuf); _lineBuf = nullptr; }
if (_markers) { free(_markers); _markers = nullptr; }
}
@@ -184,7 +180,12 @@ public:
// Add a location marker (call once per contact before entering map)
void clearMarkers() { _numMarkers = 0; }
void addMarker(double lat, double lon, const char* name = "", uint8_t type = 0) {
if (!_markers || _numMarkers >= MAP_MAX_MARKERS) return;
// Lazy-allocate markers on first use (deferred from constructor)
if (!_markers) {
_markers = (MapMarker*)ps_calloc(MAP_MAX_MARKERS, sizeof(MapMarker));
if (!_markers) return; // Alloc failed — skip silently
}
if (_numMarkers >= MAP_MAX_MARKERS) return;
if (lat == 0.0 && lon == 0.0) return; // Skip no-location contacts
_markers[_numMarkers].lat = lat;
_markers[_numMarkers].lon = lon;
@@ -203,6 +204,18 @@ public:
_einkDisplay = static_cast<GxEPDDisplay*>(&display);
_needsRedraw = true;
// Allocate marker array in PSRAM on first use (~20KB)
if (!_markers) {
_markers = (MapMarker*)ps_calloc(MAP_MAX_MARKERS, sizeof(MapMarker));
if (_markers) {
Serial.printf("MapScreen: markers allocated (%d × %d = %d bytes PSRAM)\n",
MAP_MAX_MARKERS, (int)sizeof(MapMarker),
MAP_MAX_MARKERS * (int)sizeof(MapMarker));
} else {
Serial.println("MapScreen: marker PSRAM alloc FAILED");
}
}
// Allocate PNG read buffer in PSRAM on first use
if (!_pngBuf) {
_pngBuf = (uint8_t*)ps_malloc(MAP_PNG_BUF_SIZE);
@@ -217,6 +230,20 @@ public:
}
}
// Allocate scanline decode buffer in PSRAM (512 bytes — avoids stack
// allocation inside the PNGdec callback which is called 256× per tile)
if (!_lineBuf) {
_lineBuf = (uint16_t*)ps_malloc(MAP_TILE_SIZE * sizeof(uint16_t));
if (!_lineBuf) {
_lineBuf = (uint16_t*)malloc(MAP_TILE_SIZE * sizeof(uint16_t));
}
if (_lineBuf) {
Serial.println("MapScreen: lineBuf allocated");
} else {
Serial.println("MapScreen: lineBuf alloc FAILED");
}
}
// Detect available zoom levels from SD card directories
detectZoomRange();
}
@@ -356,6 +383,7 @@ private:
// PNG decode buffer (PSRAM)
uint8_t* _pngBuf;
uint16_t* _lineBuf; // Scanline RGB565 buffer for PNG decode (PSRAM)
bool _tileFound; // Did last tile load succeed?
// PNGdec instance
@@ -381,6 +409,7 @@ private:
int offsetY; // Screen Y offset for this tile
int viewportY; // Top of viewport (MAP_VIEWPORT_Y)
int viewportH; // Height of viewport (MAP_VIEWPORT_H)
uint16_t* lineBuf; // Scanline decode buffer (PSRAM-allocated, avoids 512B stack usage per callback)
};
DrawContext _drawCtx;
@@ -487,7 +516,7 @@ private:
// Load a PNG tile from SD and decode it directly to the display
// screenX, screenY = top-left corner on display where this tile goes
bool loadAndRenderTile(int tileX, int tileY, int screenX, int screenY) {
if (!_pngBuf || !_einkDisplay) return false;
if (!_pngBuf || !_lineBuf || !_einkDisplay) return false;
char path[64];
buildTilePath(path, sizeof(path), _zoom, tileX, tileY);
@@ -521,6 +550,7 @@ private:
_drawCtx.offsetY = screenY;
_drawCtx.viewportY = MAP_VIEWPORT_Y;
_drawCtx.viewportH = MAP_VIEWPORT_H;
_drawCtx.lineBuf = _lineBuf;
// Open PNG from memory buffer
int rc = _png.openRAM(_pngBuf, fileSize, pngDrawCallback);
@@ -547,7 +577,7 @@ private:
// Uses getLineAsRGB565 with correct (little) endianness for ESP32.
static int pngDrawCallback(PNGDRAW* pDraw) {
DrawContext* ctx = (DrawContext*)pDraw->pUser;
if (!ctx || !ctx->display || !ctx->png) return 0;
if (!ctx || !ctx->display || !ctx->png || !ctx->lineBuf) return 0;
int screenY = ctx->offsetY + pDraw->y;
@@ -564,9 +594,8 @@ private:
}
uint16_t lineWidth = pDraw->iWidth;
uint16_t lineBuf[MAP_TILE_SIZE];
if (lineWidth > MAP_TILE_SIZE) lineWidth = MAP_TILE_SIZE;
ctx->png->getLineAsRGB565(pDraw, lineBuf, PNG_RGB565_LITTLE_ENDIAN, 0xFFFFFFFF);
ctx->png->getLineAsRGB565(pDraw, ctx->lineBuf, PNG_RGB565_LITTLE_ENDIAN, 0xFFFFFFFF);
for (int x = 0; x < lineWidth; x++) {
int screenX = ctx->offsetX + x;
@@ -574,7 +603,7 @@ private:
// RGB565 little-endian on ESP32: standard bit layout
// R[15:11] G[10:5] B[4:0]
uint16_t pixel = lineBuf[x];
uint16_t pixel = ctx->lineBuf[x];
// For B&W tiles this is 0x0000 (black) or 0xFFFF (white)
// Simple threshold on full 16-bit value handles both cleanly
@@ -639,6 +668,7 @@ private:
} else {
missing++;
}
yield(); // Feed WDT between tiles — each tile can take 1-2s at 80MHz
}
}

View File

@@ -102,6 +102,10 @@ private:
uint32_t _rtcTime; // Unix timestamp (0 = unavailable)
int8_t _utcOffset; // UTC offset in hours
// Callback to get fresh RTC time (set by UITask at init)
typedef uint32_t (*TimeGetterFn)();
TimeGetterFn _getTimeFn = nullptr;
// ---- Helpers ----
String getFullPath(const String& filename) {
@@ -570,8 +574,8 @@ private:
display.print("Swipe:Nav");
const char* right = "Tap:Open";
#else
display.print("Q:Back W/S:Nav");
const char* right = "Ent:Open";
display.print("Q:Bk");
const char* right = "Tap/Ent:Open";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
@@ -592,8 +596,8 @@ private:
display.print("Tap:Edit");
const char* right = "Hold:Delete";
#else
display.print("Q:Bck Ent:Edit");
const char* right = "Sh+Del:Del";
display.print("Q:Bk Ent:Edit");
const char* right = "X:Delete";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
@@ -684,9 +688,9 @@ private:
const char* right = "Tap:Edit";
#else
display.print("Q:Bck Ent:Edit");
display.print("Q:Bk Ent:Edit");
const char* right = "Sh+Del:Del";
const char* right = "X:Delete";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
@@ -1077,6 +1081,10 @@ private:
// ---- Note Creation ----
void createNewNote() {
// Refresh timestamp at creation time for accurate filenames
if (_getTimeFn) {
_rtcTime = _getTimeFn();
}
_currentFile = generateFilename();
_buf[0] = '\0';
_bufLen = 0;
@@ -1176,6 +1184,8 @@ public:
_utcOffset = utcOffset;
}
void setTimeGetter(TimeGetterFn fn) { _getTimeFn = fn; }
void enter(DisplayDriver& display) {
initLayout(display);
scanFiles();

View File

@@ -51,6 +51,43 @@ extern MyMesh the_mesh;
// ---------------------------------------------------------------------------
#include "RadioPresets.h"
// ---------------------------------------------------------------------------
// GPS baud rate options (shared with UITask GPS home page overlay)
// ---------------------------------------------------------------------------
static const uint32_t GPS_BAUD_OPTIONS[] = { 0, 4800, 9600, 19200, 38400, 57600, 115200 };
#define GPS_BAUD_OPTION_COUNT 7
static inline const char* gpsBaudLabel(uint32_t baud, char* buf, int bufLen) {
if (baud == 0) return "Default (38400)";
snprintf(buf, bufLen, "%lu", (unsigned long)baud);
return buf;
}
static inline int findGpsBaudIndex(uint32_t baud) {
for (int i = 0; i < GPS_BAUD_OPTION_COUNT; i++) {
if (GPS_BAUD_OPTIONS[i] == baud) return i;
}
return 0;
}
// Auto-lock timeout options (minutes, 0=disabled)
static const uint8_t AUTO_LOCK_OPTIONS[] = { 0, 2, 5, 10, 15, 30 };
#define AUTO_LOCK_OPTION_COUNT 6
static inline const char* autoLockLabel(uint8_t minutes) {
if (minutes == 0) return "None";
static char buf[8];
snprintf(buf, sizeof(buf), "%d min", minutes);
return buf;
}
static inline int findAutoLockIndex(uint8_t minutes) {
for (int i = 0; i < AUTO_LOCK_OPTION_COUNT; i++) {
if (AUTO_LOCK_OPTIONS[i] == minutes) return i;
}
return 0;
}
// ---------------------------------------------------------------------------
// Settings row types
// ---------------------------------------------------------------------------
@@ -68,6 +105,10 @@ enum SettingsRowType : uint8_t {
#if defined(LilyGo_T5S3_EPaper_Pro)
ROW_PORTRAIT_MODE, // Portrait orientation toggle
#endif
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
ROW_AUTO_LOCK, // Auto-lock timeout picker (None/2/5/10/15/30 min)
#endif
ROW_GPS_BAUD, // GPS baud rate picker (requires reboot)
ROW_PATH_HASH_SIZE, // Path hash size (1, 2, or 3 bytes per hop)
#ifdef MECK_WIFI_COMPANION
ROW_WIFI_SETUP, // WiFi SSID/password configuration
@@ -84,6 +125,8 @@ enum SettingsRowType : uint8_t {
ROW_AUTOADD_ROOM, // Toggle: auto-add Room Servers
ROW_AUTOADD_SENSOR, // Toggle: auto-add Sensors
ROW_AUTOADD_OVERWRITE, // Toggle: overwrite oldest non-favourite when full
ROW_CONTACTS_SUBMENU, // Folder row → enters Contacts sub-screen
ROW_CHANNELS_SUBMENU, // Folder row → enters Channels sub-screen
ROW_CH_HEADER, // "--- Channels ---" separator
ROW_CHANNEL, // A channel entry (dynamic, index stored separately)
ROW_ADD_CHANNEL, // "+ Add Hashtag Channel"
@@ -111,6 +154,15 @@ enum EditMode : uint8_t {
#endif
};
// ---------------------------------------------------------------------------
// Settings sub-screens (collapsible sections)
// ---------------------------------------------------------------------------
enum SubScreen : uint8_t {
SUB_NONE, // Top-level settings list
SUB_CONTACTS, // Contacts settings sub-screen
SUB_CHANNELS, // Channels management sub-screen
};
// Max rows in the settings list (increased for contact sub-toggles + WiFi)
#if defined(HAS_4G_MODEM) && defined(MECK_WIFI_COMPANION)
#define SETTINGS_MAX_ROWS 56 // Extra rows for IMEI, Carrier, APN, contacts, WiFi
@@ -153,6 +205,10 @@ private:
// Onboarding mode
bool _onboarding;
// Sub-screen navigation
SubScreen _subScreen;
int _savedTopCursor; // cursor position to restore when leaving sub-screen
// Dirty flag for radio params — prompt to apply
bool _radioChanged;
@@ -240,66 +296,71 @@ private:
void rebuildRows() {
_numRows = 0;
addRow(ROW_NAME);
addRow(ROW_RADIO_PRESET);
addRow(ROW_FREQ);
addRow(ROW_BW);
addRow(ROW_SF);
addRow(ROW_CR);
addRow(ROW_TX_POWER);
addRow(ROW_UTC_OFFSET);
addRow(ROW_MSG_NOTIFY);
addRow(ROW_PATH_HASH_SIZE);
addRow(ROW_DARK_MODE);
#if defined(LilyGo_T5S3_EPaper_Pro)
addRow(ROW_PORTRAIT_MODE);
#endif
#ifdef MECK_WIFI_COMPANION
addRow(ROW_WIFI_SETUP);
addRow(ROW_WIFI_TOGGLE);
#endif
#ifdef HAS_4G_MODEM
addRow(ROW_MODEM_TOGGLE);
// addRow(ROW_RINGTONE);
#endif
// --- Contacts section ---
addRow(ROW_CONTACT_HEADER);
addRow(ROW_CONTACT_MODE);
// Show per-type sub-toggles only in Custom mode
if (getContactMode() == CONTACT_MODE_CUSTOM) {
addRow(ROW_AUTOADD_CHAT);
addRow(ROW_AUTOADD_REPEATER);
addRow(ROW_AUTOADD_ROOM);
addRow(ROW_AUTOADD_SENSOR);
addRow(ROW_AUTOADD_OVERWRITE);
}
// --- Channels section ---
addRow(ROW_CH_HEADER);
// Enumerate current channels
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
addRow(ROW_CHANNEL, i);
} else {
break; // channels are contiguous
if (_subScreen == SUB_CONTACTS) {
// --- Contacts sub-screen: only contact-related rows ---
addRow(ROW_CONTACT_MODE);
if (getContactMode() == CONTACT_MODE_CUSTOM) {
addRow(ROW_AUTOADD_CHAT);
addRow(ROW_AUTOADD_REPEATER);
addRow(ROW_AUTOADD_ROOM);
addRow(ROW_AUTOADD_SENSOR);
addRow(ROW_AUTOADD_OVERWRITE);
}
} else if (_subScreen == SUB_CHANNELS) {
// --- Channels sub-screen: only channel-related rows ---
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
addRow(ROW_CHANNEL, i);
} else {
break;
}
}
addRow(ROW_ADD_CHANNEL);
} else {
// --- Top-level settings list ---
addRow(ROW_NAME);
addRow(ROW_RADIO_PRESET);
addRow(ROW_FREQ);
addRow(ROW_BW);
addRow(ROW_SF);
addRow(ROW_CR);
addRow(ROW_TX_POWER);
addRow(ROW_UTC_OFFSET);
addRow(ROW_MSG_NOTIFY);
addRow(ROW_GPS_BAUD);
addRow(ROW_PATH_HASH_SIZE);
addRow(ROW_DARK_MODE);
#if defined(LilyGo_T5S3_EPaper_Pro)
addRow(ROW_PORTRAIT_MODE);
#endif
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
addRow(ROW_AUTO_LOCK);
#endif
#ifdef MECK_WIFI_COMPANION
addRow(ROW_WIFI_SETUP);
addRow(ROW_WIFI_TOGGLE);
#endif
#ifdef HAS_4G_MODEM
addRow(ROW_MODEM_TOGGLE);
#endif
// Folder rows for sub-screens
addRow(ROW_CONTACTS_SUBMENU);
addRow(ROW_CHANNELS_SUBMENU);
// Info section (stays at top level)
addRow(ROW_INFO_HEADER);
addRow(ROW_PUB_KEY);
addRow(ROW_FIRMWARE);
#ifdef HAS_4G_MODEM
addRow(ROW_IMEI);
addRow(ROW_OPERATOR_INFO);
addRow(ROW_APN);
#endif
}
addRow(ROW_ADD_CHANNEL);
addRow(ROW_INFO_HEADER);
addRow(ROW_PUB_KEY);
addRow(ROW_FIRMWARE);
#ifdef HAS_4G_MODEM
addRow(ROW_IMEI);
addRow(ROW_OPERATOR_INFO);
addRow(ROW_APN);
#endif
// Clamp cursor
if (_cursor >= _numRows) _cursor = _numRows - 1;
if (_cursor < 0) _cursor = 0;
@@ -321,7 +382,7 @@ private:
#ifdef HAS_4G_MODEM
&& t != ROW_IMEI && t != ROW_OPERATOR_INFO
#endif
;
; // ROW_CONTACTS_SUBMENU and ROW_CHANNELS_SUBMENU ARE selectable
}
void skipNonSelectable(int dir) {
@@ -439,12 +500,15 @@ public:
_numRows(0), _cursor(0), _scrollTop(0),
_editMode(EDIT_NONE), _editPos(0), _editPickerIdx(0),
_editFloat(0), _editInt(0), _confirmAction(0),
_onboarding(false), _radioChanged(false) {
_onboarding(false), _subScreen(SUB_NONE), _savedTopCursor(0),
_radioChanged(false) {
memset(_editBuf, 0, sizeof(_editBuf));
}
void enter() {
_editMode = EDIT_NONE;
_subScreen = SUB_NONE;
_savedTopCursor = 0;
_cursor = 0;
_scrollTop = 0;
_radioChanged = false;
@@ -477,6 +541,35 @@ public:
bool isEditing() const { return _editMode != EDIT_NONE; }
bool hasRadioChanges() const { return _radioChanged; }
// Tap-to-select: given a virtual Y coordinate, compute which row was tapped
// and move cursor there. Returns: 0=miss, 1=moved to new row, 2=tapped current row.
int selectRowAtVY(int vy) {
if (_editMode != EDIT_NONE) return 0; // Don't change cursor while editing
const int headerH = 14, footerH = 14, lineH = 9;
// T-Deck Pro render offsets fillRect by +5 (GxEPD baseline compensation),
// so visual rows start 5 units below headerH. T5S3 renders at y directly.
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = headerH;
#else
const int bodyTop = headerH + 5;
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0; // Outside body area
int maxVisible = (128 - headerH - footerH) / lineH;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_cursor - maxVisible / 2, _numRows - maxVisible));
int tappedRow = startIdx + (vy - bodyTop) / lineH;
if (tappedRow < 0 || tappedRow >= _numRows) return 0;
// Skip non-selectable rows (headers/separators)
if (!isSelectable(tappedRow)) return 0;
if (tappedRow == _cursor) return 2; // Same row — activate
_cursor = tappedRow;
return 1; // Moved to new row
}
// ---------------------------------------------------------------------------
// WiFi scan helpers
// ---------------------------------------------------------------------------
@@ -628,6 +721,10 @@ public:
display.setCursor(0, 0);
if (_onboarding) {
display.print("Welcome! Setup");
} else if (_subScreen == SUB_CONTACTS) {
display.print("Settings > Contacts");
} else if (_subScreen == SUB_CHANNELS) {
display.print("Settings > Channels");
} else {
display.print("Settings");
}
@@ -773,6 +870,19 @@ public:
display.print(tmp);
break;
case ROW_GPS_BAUD: {
char baudStr[16];
if (editing && _editMode == EDIT_PICKER) {
snprintf(tmp, sizeof(tmp), "< GPS Baud: %s > *",
gpsBaudLabel(GPS_BAUD_OPTIONS[_editPickerIdx], baudStr, sizeof(baudStr)));
} else {
snprintf(tmp, sizeof(tmp), "GPS Baud: %s *",
gpsBaudLabel(_prefs->gps_baudrate, baudStr, sizeof(baudStr)));
}
display.print(tmp);
break;
}
case ROW_DARK_MODE:
snprintf(tmp, sizeof(tmp), "Dark Mode: %s",
_prefs->dark_mode ? "ON" : "OFF");
@@ -787,6 +897,19 @@ public:
break;
#endif
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
case ROW_AUTO_LOCK:
if (editing && _editMode == EDIT_PICKER) {
snprintf(tmp, sizeof(tmp), "< Auto Lock: %s >",
autoLockLabel(AUTO_LOCK_OPTIONS[_editPickerIdx]));
} else {
snprintf(tmp, sizeof(tmp), "Auto Lock: %s",
autoLockLabel(_prefs->auto_lock_minutes));
}
display.print(tmp);
break;
#endif
#ifdef MECK_WIFI_COMPANION
case ROW_WIFI_SETUP:
if (WiFi.status() == WL_CONNECTED) {
@@ -817,6 +940,17 @@ public:
// break;
#endif
// --- Submenu folder rows ---
case ROW_CONTACTS_SUBMENU:
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
display.print("Contacts >>");
break;
case ROW_CHANNELS_SUBMENU:
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
display.print("Channels >>");
break;
// --- Contacts section ---
case ROW_CONTACT_HEADER:
display.setColor(DisplayDriver::YELLOW);
@@ -1093,10 +1227,17 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
if (_editMode == EDIT_NONE) {
display.print("Swipe:Scroll");
const char* r = "Tap:Toggle Hold:Edit";
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
display.print(r);
if (_subScreen != SUB_NONE) {
display.print("Boot:Back");
const char* r = "Tap:Toggle Hold:Edit";
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
display.print(r);
} else {
display.print("Swipe:Scroll");
const char* r = "Tap:Toggle Hold:Edit";
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
display.print(r);
}
} else if (_editMode == EDIT_NUMBER) {
display.print("Swipe:Adjust");
const char* r = "Tap:OK Boot:Cancel";
@@ -1155,8 +1296,12 @@ public:
} else if (_editMode == EDIT_CONFIRM) {
// Footer already covered by overlay
} else {
display.print("Q:Bck");
const char* r = "W/S:Up/Dwn Entr:Chng";
if (_subScreen != SUB_NONE) {
display.print("Q:Back");
} else {
display.print("Q:Bk");
}
const char* r = "Tap/Ent:Edit";
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
display.print(r);
}
@@ -1382,6 +1527,12 @@ public:
if (type == ROW_CONTACT_MODE) {
_editPickerIdx--;
if (_editPickerIdx < 0) _editPickerIdx = CONTACT_MODE_COUNT - 1;
} else if (type == ROW_GPS_BAUD) {
_editPickerIdx--;
if (_editPickerIdx < 0) _editPickerIdx = GPS_BAUD_OPTION_COUNT - 1;
} else if (type == ROW_AUTO_LOCK) {
_editPickerIdx--;
if (_editPickerIdx < 0) _editPickerIdx = AUTO_LOCK_OPTION_COUNT - 1;
} else {
// Radio preset
_editPickerIdx--;
@@ -1393,6 +1544,12 @@ public:
if (type == ROW_CONTACT_MODE) {
_editPickerIdx++;
if (_editPickerIdx >= CONTACT_MODE_COUNT) _editPickerIdx = 0;
} else if (type == ROW_GPS_BAUD) {
_editPickerIdx++;
if (_editPickerIdx >= GPS_BAUD_OPTION_COUNT) _editPickerIdx = 0;
} else if (type == ROW_AUTO_LOCK) {
_editPickerIdx++;
if (_editPickerIdx >= AUTO_LOCK_OPTION_COUNT) _editPickerIdx = 0;
} else {
// Radio preset
_editPickerIdx++;
@@ -1404,6 +1561,18 @@ public:
if (type == ROW_CONTACT_MODE) {
applyContactMode(_editPickerIdx);
_editMode = EDIT_NONE;
} else if (type == ROW_GPS_BAUD) {
_prefs->gps_baudrate = GPS_BAUD_OPTIONS[_editPickerIdx];
the_mesh.savePrefs();
_editMode = EDIT_NONE;
Serial.printf("Settings: GPS baud set to %lu (reboot to apply)\n",
(unsigned long)_prefs->gps_baudrate);
} else if (type == ROW_AUTO_LOCK) {
_prefs->auto_lock_minutes = AUTO_LOCK_OPTIONS[_editPickerIdx];
the_mesh.savePrefs();
_editMode = EDIT_NONE;
Serial.printf("Settings: Auto lock = %s\n",
autoLockLabel(_prefs->auto_lock_minutes));
} else {
// Apply radio preset
if (_editPickerIdx >= 0 && _editPickerIdx < (int)NUM_RADIO_PRESETS) {
@@ -1583,6 +1752,9 @@ public:
case ROW_PATH_HASH_SIZE:
startEditInt(_prefs->path_hash_mode + 1); // display as 1-3
break;
case ROW_GPS_BAUD:
startEditPicker(findGpsBaudIndex(_prefs->gps_baudrate));
break;
case ROW_DARK_MODE:
_prefs->dark_mode = _prefs->dark_mode ? 0 : 1;
the_mesh.savePrefs();
@@ -1596,6 +1768,11 @@ public:
Serial.printf("Settings: Portrait mode = %s\n",
_prefs->portrait_mode ? "ON" : "OFF");
break;
#endif
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
case ROW_AUTO_LOCK:
startEditPicker(findAutoLockIndex(_prefs->auto_lock_minutes));
break;
#endif
#ifdef MECK_WIFI_COMPANION
case ROW_WIFI_SETUP: {
@@ -1704,6 +1881,24 @@ public:
(_prefs->autoadd_config & AUTO_ADD_OVERWRITE_OLDEST) ? "ON" : "OFF");
break;
// --- Submenu folder rows ---
case ROW_CONTACTS_SUBMENU:
_savedTopCursor = _cursor;
_subScreen = SUB_CONTACTS;
_cursor = 0;
_scrollTop = 0;
rebuildRows();
Serial.println("Settings: entered Contacts sub-screen");
break;
case ROW_CHANNELS_SUBMENU:
_savedTopCursor = _cursor;
_subScreen = SUB_CHANNELS;
_cursor = 0;
_scrollTop = 0;
rebuildRows();
Serial.println("Settings: entered Channels sub-screen");
break;
case ROW_ADD_CHANNEL:
startEditText("");
break;
@@ -1727,8 +1922,18 @@ public:
}
}
// Q: back — if radio changed, prompt to apply first
// Q: back -- if in sub-screen, return to top level; else exit settings
if (c == 'q' || c == 'Q') {
if (_subScreen != SUB_NONE) {
// Return to top-level settings list
_subScreen = SUB_NONE;
rebuildRows();
_cursor = _savedTopCursor;
if (_cursor >= _numRows) _cursor = _numRows - 1;
skipNonSelectable(1);
Serial.println("Settings: back to top level");
return true;
}
if (_radioChanged) {
_editMode = EDIT_CONFIRM;
_confirmAction = 2;

View File

@@ -115,10 +115,16 @@ inline WrapResult findLineBreakPixel(const char* buffer, int bufLen, int lineSta
result.nextStart = lineStart;
if (lineStart >= bufLen || !display) return result;
int displayW = display->width() - 3; // 3-unit right margin (rounding safety for proportional fonts)
#if defined(LilyGo_T5S3_EPaper_Pro)
int rightMargin = 5; // Wider margin for T5S3 (portrait mode especially tight)
#else
int rightMargin = 3;
#endif
int displayW = display->width() - rightMargin;
char measBuf[300]; // temp buffer for pixel measurement
int measLen = 0;
int lastBreakPoint = -1;
int lastBreakMeasLen = 0; // measLen at lastBreakPoint (for mid-word fallback)
bool inWord = false;
int charCount = 0;
@@ -145,6 +151,7 @@ inline WrapResult findLineBreakPixel(const char* buffer, int bufLen, int lineSta
// UTF-8 handling: decode multi-byte sequences to CP437 for accurate
// width measurement. The renderer (renderPage) does this same conversion,
// so the measurement must match or it underestimates line width.
int charStartIdx = i; // buffer index where this character started
if ((uint8_t)c >= 0xC0) {
// UTF-8 lead byte — decode full sequence to CP437
int decPos = i;
@@ -156,65 +163,54 @@ inline WrapResult findLineBreakPixel(const char* buffer, int bufLen, int lineSta
}
i = decPos - 1; // -1 because the for loop will i++
inWord = true;
continue;
}
if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) {
} else if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) {
// Orphan continuation byte — treat as CP437 pass-through (same as renderer)
if (measLen < 298) measBuf[measLen++] = c;
charCount++;
inWord = true;
continue;
} else {
// Plain ASCII
charCount++;
if (measLen < 298) measBuf[measLen++] = c;
if (c == ' ' || c == '\t') {
if (inWord) {
lastBreakPoint = i;
lastBreakMeasLen = measLen;
inWord = false;
}
} else if (c == '-') {
if (inWord) {
lastBreakPoint = i + 1;
lastBreakMeasLen = measLen;
}
inWord = true;
} else {
inWord = true;
}
}
// Plain ASCII
charCount++;
if (measLen < 298) measBuf[measLen++] = c;
if (c == ' ' || c == '\t') {
if (inWord) {
// Measure pixel width at this word boundary
measBuf[measLen] = '\0';
int pw = display->getTextWidth(measBuf);
if (pw >= displayW) {
// Current word pushes past edge — break at previous word boundary
if (lastBreakPoint > lineStart) {
result.lineEnd = lastBreakPoint;
result.nextStart = lastBreakPoint;
while (result.nextStart < bufLen &&
(buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t'))
result.nextStart++;
} else {
result.lineEnd = i;
result.nextStart = i;
}
return result;
// Per-character pixel width check — catches long words that exceed
// displayW without ever hitting a space/hyphen break point.
// Only measure every 3 chars to avoid excessive getTextWidth() calls.
if ((charCount & 3) == 0 || c == ' ' || c == '-') {
measBuf[measLen] = '\0';
int pw = display->getTextWidth(measBuf);
if (pw >= displayW) {
if (lastBreakPoint > lineStart) {
// Break at last word boundary
result.lineEnd = lastBreakPoint;
result.nextStart = lastBreakPoint;
while (result.nextStart < bufLen &&
(buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t'))
result.nextStart++;
} else {
// No word boundary found — break mid-word before this character
result.lineEnd = charStartIdx;
result.nextStart = charStartIdx;
}
lastBreakPoint = i;
inWord = false;
return result;
}
} else if (c == '-') {
if (inWord) {
// Measure at hyphen break point
measBuf[measLen] = '\0';
int pw = display->getTextWidth(measBuf);
if (pw >= displayW) {
if (lastBreakPoint > lineStart) {
result.lineEnd = lastBreakPoint;
result.nextStart = lastBreakPoint;
while (result.nextStart < bufLen &&
(buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t'))
result.nextStart++;
} else {
result.lineEnd = i;
result.nextStart = i;
}
return result;
}
lastBreakPoint = i + 1;
}
inWord = true;
} else {
inWord = true;
}
// Safety: hard char limit (handles spaceless lines, URLs, etc.)
@@ -400,6 +396,11 @@ private:
int _pageBufLen;
bool _contentDirty; // Need to re-read from SD
// Go-to-page input mode (Enter in reading view)
bool _gotoMode = false;
char _gotoBuf[6]; // Up to 5 digits + null
int _gotoBufLen = 0;
// ---- Splash Screen Drawing ----
// Draw directly to display outside the normal render cycle.
// Matches the style of the standalone text reader firmware splash.
@@ -1144,9 +1145,9 @@ private:
display.drawTextCentered(display.width() / 2, footerY, "Swipe: Scroll Tap: Open Boot: home");
#else
display.setCursor(0, footerY);
display.print("Q:Back W/S:Nav");
display.print("Q:Bk");
const char* right = "Ent:Open";
const char* right = "Tap/Ent:Open";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#endif
@@ -1242,20 +1243,26 @@ private:
char status[30];
int pct = _totalPages > 1 ? (_currentPage * 100) / (_totalPages - 1) : 100;
sprintf(status, "%d/%d %d%%", _currentPage + 1, _totalPages, pct);
if (_gotoMode) {
// Go-to-page input mode — show typed digits in footer
snprintf(status, sizeof(status), "Go to: %.*s_", _gotoBufLen, _gotoBuf);
} else {
sprintf(status, "%d/%d %d%%", _currentPage + 1, _totalPages, pct);
}
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setTextSize(0);
display.setCursor(0, footerY);
display.print(status);
const char* right = "Swipe: Page Tap: Next Hold: Close";
const char* right = "Swipe:Page Tap:GoTo Hold:Close";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#else
display.setCursor(0, footerY);
display.print(status);
const char* right = "W/S:Nav Q:Back";
const char* right = _gotoMode ? "Ent:Go Q:Cancel" : "Entr:Pg# Q:Bk";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#endif
@@ -1529,6 +1536,48 @@ public:
bool isReading() const { return _mode == READING; }
bool isInFileList() const { return _mode == FILE_LIST; }
// Jump to a specific page number (1-based for user-facing, converted to 0-based)
void gotoPage(int pageNum) {
if (!_fileOpen || _totalPages == 0) return;
int target = pageNum - 1; // Convert 1-based input to 0-based
if (target < 0) target = 0;
if (target >= _totalPages) target = _totalPages - 1;
_currentPage = target;
loadPageContent();
Serial.printf("TextReader: Go to page %d/%d\n", _currentPage + 1, _totalPages);
}
int getTotalPages() const { return _totalPages; }
int getCurrentPage() const { return _currentPage; }
// Tap-to-select: given virtual Y, select file list row.
// Returns: 0=miss, 1=moved, 2=tapped current row.
int selectRowAtVY(int vy) {
if (_mode != FILE_LIST) return 0;
const int startY = 14, footerH = 14, listLineH = 8;
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = startY;
#else
const int bodyTop = startY + 5; // GxEPD baseline offset
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
int totalItems = totalListItems();
if (totalItems == 0) return 0;
int maxVisible = (128 - startY - footerH) / listLineH;
if (maxVisible < 3) maxVisible = 3;
if (maxVisible > 15) maxVisible = 15;
int startIdx = max(0, min(_selectedFile - maxVisible / 2,
totalItems - maxVisible));
int tappedRow = startIdx + (vy - bodyTop) / listLineH;
if (tappedRow < 0 || tappedRow >= totalItems) return 0;
if (tappedRow == _selectedFile) return 2;
_selectedFile = tappedRow;
return 1;
}
int render(DisplayDriver& display) override {
if (!_sdReady) {
display.setCursor(0, 20);
@@ -1553,6 +1602,7 @@ public:
if (_mode == FILE_LIST) {
return handleFileListInput(c);
} else if (_mode == READING) {
if (_gotoMode) return handleGotoInput(c);
return handleReadingInput(c);
}
return false;
@@ -1673,9 +1723,9 @@ public:
return false;
}
// S/D/Space/Enter - next page
// S/D/Space - next page
if (c == 's' || c == 'S' || c == 'd' || c == 'D' ||
c == ' ' || c == '\r' || c == 13 || c == 0xF1) {
c == ' ' || c == 0xF1) {
if (_currentPage < _totalPages - 1) {
_currentPage++;
loadPageContent();
@@ -1684,6 +1734,14 @@ public:
return false;
}
// Enter - go-to-page input mode
if (c == '\r' || c == 13) {
_gotoMode = true;
_gotoBufLen = 0;
_gotoBuf[0] = '\0';
return true;
}
// Q - close book, back to file list
if (c == 'q' || c == 'Q') {
closeBook();
@@ -1694,6 +1752,42 @@ public:
return false;
}
bool handleGotoInput(char c) {
// Enter — commit page number
if (c == '\r' || c == 13) {
if (_gotoBufLen > 0) {
int pageNum = atoi(_gotoBuf);
gotoPage(pageNum);
}
_gotoMode = false;
return true;
}
// Q or Escape — cancel
if (c == 'q' || c == 'Q' || c == 0x1B) {
_gotoMode = false;
return true;
}
// Backspace — delete last digit
if (c == '\b' || c == 0x7F) {
if (_gotoBufLen > 0) {
_gotoBufLen--;
_gotoBuf[_gotoBufLen] = '\0';
}
return true;
}
// Digit — append (max 5 digits)
if (c >= '0' && c <= '9' && _gotoBufLen < 5) {
_gotoBuf[_gotoBufLen++] = c;
_gotoBuf[_gotoBufLen] = '\0';
return true;
}
return true; // Consume all other keys while in goto mode
}
// External close (called when leaving reader screen entirely)
void exitReader() {
if (_fileOpen) closeBook();

View File

@@ -4,6 +4,7 @@
#include "NotesScreen.h"
#include "RepeaterAdminScreen.h"
#include "DiscoveryScreen.h"
#include "LastHeardScreen.h"
#ifdef MECK_WEB_READER
#include "WebReaderScreen.h"
#endif
@@ -138,6 +139,7 @@ class HomeScreen : public UIScreen {
unsigned long _shutdown_at; // earliest time to proceed with shutdown (after e-ink refresh)
bool _editing_utc;
int8_t _saved_utc_offset; // for cancel/undo
AdvertPath recent[UI_RECENT_LIST_SIZE];
@@ -254,7 +256,8 @@ public:
_shutdown_init(false), _shutdown_at(0), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
bool isEditingUTC() const { return _editing_utc; }
void cancelEditUTC() {
bool isOnRecentPage() const { return _page == HomePage::RECENT; }
void cancelEditing() {
if (_editing_utc) {
_node_prefs->utc_offset_hours = _saved_utc_offset;
_editing_utc = false;
@@ -496,6 +499,16 @@ public:
display.setCursor(display.width() - timestamp_width - 1, y);
display.print(tmp);
}
// Hint for full Last Heard screen
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(display.width() / 2, display.height() - 24,
"Tap here for full Last Heard list");
#else
display.drawTextCentered(display.width() / 2, display.height() - 24,
"H: Full Last Heard list");
#endif
} else if (_page == HomePage::RADIO) {
display.setColor(DisplayDriver::YELLOW);
display.setTextSize(1);
@@ -666,7 +679,9 @@ public:
display.drawTextLeftAlign(0, y, "time(U)");
display.drawTextRightAlign(display.width()-1, y, "no sync");
}
y = y + 12;
}
// UTC offset editor overlay
if (_editing_utc) {
// Draw background box
@@ -686,6 +701,7 @@ public:
display.drawTextCentered(display.width() / 2, by + bh - 10, "W/S:adj Enter:ok Q:cancel");
display.setTextSize(1);
}
#endif
#if UI_SENSORS_PAGE == 1
} else if (_page == HomePage::SENSORS) {
@@ -842,7 +858,7 @@ public:
#endif
}
}
return _editing_utc ? 700 : 5000; // match e-ink refresh cycle while editing UTC
return _editing_utc ? 700 : 5000;
}
bool handleInput(char c) override {
@@ -1031,11 +1047,12 @@ public:
};
// ==========================================================================
// Lock Screen — T5S3 only
// Big clock, battery %, unread message count. Touch disabled while shown.
// Long press boot button to lock/unlock. Touch disabled while locked.
// Lock Screen — T5S3 and T-Deck Pro
// Big clock, battery %, unread message count.
// T5S3: Long press boot button to lock/unlock. Touch disabled while locked.
// T-Deck Pro: Double-press boot button to lock/unlock. Touch+keyboard disabled.
// ==========================================================================
#if defined(LilyGo_T5S3_EPaper_Pro)
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
class LockScreen : public UIScreen {
UITask* _task;
mesh::RTCClock* _rtc;
@@ -1059,7 +1076,11 @@ public:
}
// ---- Huge clock: HH:MM on one line ----
display.setTextSize(5); // Clock face size (FreeSansBold24pt × 5)
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setTextSize(5); // T5S3: FreeSansBold24pt × 5
#else
display.setTextSize(5); // T-Deck Pro: FreeSansBold12pt at GxEPD 2× scale
#endif
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 55, timeBuf);
@@ -1088,7 +1109,11 @@ public:
// ---- Unlock hint ----
display.setTextSize(0);
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(display.width() / 2, 120, "Hold button to unlock");
#else
display.drawTextCentered(display.width() / 2, 120, "Dbl-press to unlock");
#endif
return 30000;
}
@@ -1097,7 +1122,7 @@ public:
return false;
}
};
#endif // LilyGo_T5S3_EPaper_Pro
#endif
void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) {
_display = display;
@@ -1151,6 +1176,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
ui_started_at = millis();
_alert_expiry = 0;
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
_lastInputMillis = millis();
#endif
splash = new SplashScreen(this);
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
@@ -1162,7 +1190,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
discovery_screen = new DiscoveryScreen(this, &rtc_clock);
#if defined(LilyGo_T5S3_EPaper_Pro)
last_heard_screen = new LastHeardScreen(&rtc_clock);
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
lock_screen = new LockScreen(this, &rtc_clock, node_prefs);
#endif
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
@@ -1175,16 +1204,18 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
map_screen = nullptr;
#endif
// Apply saved dark mode preference before first render
if (_node_prefs->dark_mode) {
::display.setDarkMode(true);
}
#if defined(LilyGo_T5S3_EPaper_Pro)
// Apply saved display preferences before first render
if (_node_prefs->portrait_mode) {
::display.setPortraitMode(true);
}
#endif
// Apply saved dark mode preference (both T-Deck Pro and T5S3)
if (_node_prefs->dark_mode) {
::display.setDarkMode(true);
}
setCurrScreen(splash);
}
@@ -1315,6 +1346,8 @@ void UITask::userLedHandler() {
void UITask::setCurrScreen(UIScreen* c) {
curr = c;
_alert_expiry = 0; // Dismiss any active toast — prevents stale overlay from
// triggering extra 644ms e-ink refreshes on the new screen
_next_refresh = 100;
}
@@ -1345,11 +1378,16 @@ void UITask::shutdown(bool restart){
}
// Disable WiFi if active
#ifdef WIFI_SSID
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
#endif
// Disable 4G modem if active
#ifdef HAS_4G_MODEM
modemManager.shutdown();
#endif
// Disable GPS if active
#if ENV_INCLUDE_GPS == 1
{
@@ -1425,11 +1463,27 @@ void UITask::loop() {
gotoHomeScreen(); // file list: go home
c = 0;
}
} else if (isOnNotesScreen()) {
NotesScreen* notes = (NotesScreen*)notes_screen;
if (notes && notes->isEditing()) {
notes->triggerSaveAndExit(); // save and return to file list
} else {
notes->exitNotes();
gotoHomeScreen();
}
c = 0;
} else {
gotoHomeScreen();
c = 0; // consumed
}
}
#elif defined(LilyGo_TDeck_Pro)
// T-Deck Pro: single click ignored while locked — double-press to unlock
if (_locked) {
c = 0;
} else {
c = checkDisplayOn(KEY_NEXT);
}
#else
c = checkDisplayOn(KEY_NEXT);
#endif
@@ -1472,6 +1526,9 @@ void UITask::loop() {
curr->handleInput(c);
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
_next_refresh = 100; // trigger refresh
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
_lastInputMillis = millis(); // Reset auto-lock idle timer
#endif
}
userLedHandler();
@@ -1546,6 +1603,7 @@ if (curr) curr->poll();
display.setDarkMode(_node_prefs->dark_mode != 0);
}
#if defined(LilyGo_T5S3_EPaper_Pro)
// Sync portrait mode with prefs (T5S3 only)
if (_node_prefs && display.isPortraitMode() != (_node_prefs->portrait_mode != 0)) {
display.setPortraitMode(_node_prefs->portrait_mode != 0);
// Text reader layout depends on orientation — force recalculation
@@ -1567,6 +1625,11 @@ if (curr) curr->poll();
onVKBCancel();
}
} else {
// Default: allow full refresh. Override for notes editing (no flash while typing).
display.setForcePartial(false);
if (isOnNotesScreen() && ((NotesScreen*)notes_screen)->isEditing()) {
display.setForcePartial(true);
}
int delay_millis = curr->render(*_display);
// Check if settings screen needs VKB for WiFi password entry
@@ -1611,6 +1674,13 @@ if (curr) curr->poll();
}
#endif
_display->endFrame();
// E-ink render throttle: enforce minimum 800ms between renders.
// Each partial update blocks for ~644ms. Without this floor, incoming
// mesh notifications can trigger back-to-back renders that starve the
// keyboard polling loop, causing TCA8418 FIFO overflow and lost keys.
unsigned long minNext = millis() + 800;
if (_next_refresh < minNext) _next_refresh = minNext;
}
#if AUTO_OFF_MILLIS > 0
if (millis() > _auto_off) {
@@ -1619,6 +1689,35 @@ if (curr) curr->poll();
#endif
}
// Auto-lock idle timer — runs regardless of display on/off state
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
if (_node_prefs && _node_prefs->auto_lock_minutes > 0 && !_locked) {
uint8_t alm = _node_prefs->auto_lock_minutes;
// Only act on valid option values (guards against garbage from uninitialised prefs)
if (alm == 2 || alm == 5 || alm == 10 || alm == 15 || alm == 30) {
unsigned long lock_timeout = (unsigned long)alm * 60000UL;
if (millis() - _lastInputMillis >= lock_timeout) {
lockScreen();
}
}
}
// Lock screen clock refresh — update time display every 15 minutes.
// Runs outside the _display->isOn() gate so it works even after auto-off.
// Wakes the display briefly to render, then lets auto-off turn it back off.
if (_locked && _display != NULL) {
const unsigned long LOCK_REFRESH_INTERVAL = 15UL * 60UL * 1000UL; // 15 minutes
if (millis() - _lastLockRefresh >= LOCK_REFRESH_INTERVAL) {
_lastLockRefresh = millis();
if (!_display->isOn()) {
_display->turnOn();
_auto_off = millis() + 5000; // Stay on just long enough to render + settle
}
_next_refresh = 0; // Trigger immediate render
}
}
#endif
#ifdef PIN_VIBRATION
vibration.loop();
#endif
@@ -1660,6 +1759,9 @@ char UITask::checkDisplayOn(char c) {
}
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
_next_refresh = 0; // trigger refresh
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
_lastInputMillis = millis(); // Reset auto-lock idle timer
#endif
}
return c;
}
@@ -1695,6 +1797,14 @@ char UITask::handleDoubleClick(char c) {
board.setBacklight(true);
}
c = 0; // consume event — don't pass through as navigation
#elif defined(LilyGo_TDeck_Pro)
// Double-click boot button → lock/unlock screen
if (_locked) {
unlockScreen();
} else {
lockScreen();
}
c = 0;
#endif
checkDisplayOn(c);
return c;
@@ -1718,16 +1828,23 @@ char UITask::handleTripleClick(char c) {
return c;
}
#if defined(LilyGo_T5S3_EPaper_Pro)
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
void UITask::lockScreen() {
if (_locked) return;
_locked = true;
_screenBeforeLock = curr;
setCurrScreen(lock_screen);
board.setBacklight(false); // Save power
// Ensure display is on so lock screen renders (auto-off may have turned it off)
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
#if defined(LilyGo_T5S3_EPaper_Pro)
board.setBacklight(false); // Save power (T5S3 backlight)
#endif
_next_refresh = 0; // Draw lock screen immediately
_auto_off = millis() + 60000; // 60s before display off while locked
Serial.println("[UI] Screen locked");
_lastLockRefresh = millis(); // Start 15-min clock refresh cycle
Serial.println("[UI] Screen locked — entering low-power mode");
}
void UITask::unlockScreen() {
@@ -1739,11 +1856,18 @@ void UITask::unlockScreen() {
gotoHomeScreen();
}
_screenBeforeLock = nullptr;
// Ensure display is on so unlocked screen renders
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_lastInputMillis = millis(); // Reset auto-lock idle timer
_next_refresh = 0;
Serial.println("[UI] Screen unlocked");
Serial.println("[UI] Screen unlocked — exiting low-power mode");
}
#endif // LilyGo_T5S3_EPaper_Pro || LilyGo_TDeck_Pro
#if defined(LilyGo_T5S3_EPaper_Pro)
void UITask::showVirtualKeyboard(VKBPurpose purpose, const char* label, const char* initial, int maxLen, int contextIdx) {
_vkb.open(purpose, label, initial, maxLen, contextIdx);
_vkbActive = true;
@@ -1895,6 +2019,17 @@ void UITask::onVKBSubmit() {
break;
}
#endif
case VKB_TEXT_PAGE: {
if (strlen(text) > 0) {
int pageNum = atoi(text);
TextReaderScreen* reader = (TextReaderScreen*)getTextReaderScreen();
if (reader && pageNum > 0) {
reader->gotoPage(pageNum);
}
}
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
break;
}
}
_screenBeforeVKB = nullptr;
_next_refresh = 0;
@@ -1911,6 +2046,30 @@ void UITask::onVKBCancel() {
display.invalidateFrameCRC();
Serial.println("[UI] VKB cancelled");
}
#ifdef MECK_CARDKB
void UITask::feedCardKBChar(char c) {
if (_vkbActive) {
// VKB is open — feed character into its text buffer
if (_vkb.feedChar(c)) {
_next_refresh = 0; // Redraw VKB immediately
_auto_off = millis() + 120000; // Extend timeout while typing
// Check if feedChar triggered submit or cancel
if (_vkb.status() == VKB_SUBMITTED) {
onVKBSubmit();
} else if (_vkb.status() == VKB_CANCELLED) {
onVKBCancel();
}
} else {
// feedChar returned false — nav keys (arrows) while VKB is active
// Not consumed; could be used for cursor movement in future
}
} else {
// No VKB active — route as normal navigation key
injectKey(c);
}
}
#endif
#endif
bool UITask::getGPSState() {
@@ -1972,6 +2131,9 @@ void UITask::injectKey(char c) {
}
curr->handleInput(c);
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
_lastInputMillis = millis(); // Reset auto-lock idle timer
#endif
// Debounce refresh when editing UTC offset - e-ink takes 644ms per refresh
// so don't queue another render until the current one could have finished
if (isEditingHomeScreen()) {
@@ -1987,7 +2149,7 @@ void UITask::injectKey(char c) {
void UITask::gotoHomeScreen() {
// Cancel any active editing state when navigating to home
((HomeScreen *) home)->cancelEditUTC();
((HomeScreen *) home)->cancelEditing();
setCurrScreen(home);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
@@ -2000,6 +2162,10 @@ bool UITask::isEditingHomeScreen() const {
return curr == home && ((HomeScreen *) home)->isEditingUTC();
}
bool UITask::isHomeOnRecentPage() const {
return curr == home && ((HomeScreen *) home)->isOnRecentPage();
}
void UITask::gotoChannelScreen() {
((ChannelScreen *) channel_screen)->resetScroll();
// Mark the currently viewed channel as read
@@ -2042,6 +2208,10 @@ void UITask::gotoNotesScreen() {
if (_display != NULL) {
notes->enter(*_display);
}
// Set fresh timestamp and wire up time getter for note creation
notes->setTimestamp(rtc_clock.getCurrentTime(),
_node_prefs ? _node_prefs->utc_offset_hours : 0);
notes->setTimeGetter([]() -> uint32_t { return rtc_clock.getCurrentTime(); });
setCurrScreen(notes_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
@@ -2157,6 +2327,16 @@ void UITask::gotoDiscoveryScreen() {
_next_refresh = 100;
}
void UITask::gotoLastHeardScreen() {
((LastHeardScreen*)last_heard_screen)->resetScroll();
setCurrScreen(last_heard_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#ifdef MECK_WEB_READER
void UITask::gotoWebReader() {
// Lazy-initialize on first use (same pattern as audiobook player)

View File

@@ -84,6 +84,7 @@ class UITask : public AbstractUITask {
#endif
UIScreen* repeater_admin; // Repeater admin screen
UIScreen* discovery_screen; // Node discovery scan screen
UIScreen* last_heard_screen; // Last heard passive advert list
#ifdef MECK_WEB_READER
UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required)
#endif
@@ -95,11 +96,22 @@ class UITask : public AbstractUITask {
UIScreen* lock_screen; // Lock screen (big clock + battery + unread)
UIScreen* _screenBeforeLock = nullptr;
bool _locked = false;
unsigned long _lastInputMillis = 0; // Auto-lock idle tracking
unsigned long _lastLockRefresh = 0; // Periodic lock screen clock update
VirtualKeyboard _vkb;
bool _vkbActive = false;
UIScreen* _screenBeforeVKB = nullptr;
unsigned long _vkbOpenedAt = 0;
#ifdef MECK_CARDKB
bool _cardkbDetected = false;
#endif
#elif defined(LilyGo_TDeck_Pro)
UIScreen* lock_screen; // Lock screen (big clock + battery + unread)
UIScreen* _screenBeforeLock = nullptr;
bool _locked = false;
unsigned long _lastInputMillis = 0; // Auto-lock idle tracking
unsigned long _lastLockRefresh = 0; // Periodic lock screen clock update
#endif
void userLedHandler();
@@ -137,6 +149,7 @@ public:
void gotoAudiobookPlayer(); // Navigate to audiobook player
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
void gotoDiscoveryScreen(); // Navigate to node discovery scan
void gotoLastHeardScreen(); // Navigate to last heard passive list
#if HAS_GPS
void gotoMapScreen(); // Navigate to map tile screen
#endif
@@ -173,17 +186,25 @@ public:
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
bool isOnDiscoveryScreen() const { return curr == discovery_screen; }
bool isOnLastHeardScreen() const { return curr == last_heard_screen; }
bool isOnMapScreen() const { return curr == map_screen; }
#if defined(LilyGo_T5S3_EPaper_Pro)
#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)
bool isVKBActive() const { return _vkbActive; }
unsigned long vkbOpenedAt() const { return _vkbOpenedAt; }
VirtualKeyboard& getVKB() { return _vkb; }
void showVirtualKeyboard(VKBPurpose purpose, const char* label, const char* initial, int maxLen, int contextIdx = 0);
void onVKBSubmit();
void onVKBCancel();
#ifdef MECK_CARDKB
void setCardKBDetected(bool v) { _cardkbDetected = v; }
bool hasCardKB() const { return _cardkbDetected; }
void feedCardKBChar(char c);
#endif
#endif
#ifdef MECK_WEB_READER
bool isOnWebReader() const { return curr == web_reader; }
@@ -202,6 +223,8 @@ public:
// Check if home screen is in an editing mode (e.g. UTC offset editor)
bool isEditingHomeScreen() const;
// Check if home screen is showing the Recent Adverts page
bool isHomeOnRecentPage() const;
// Inject a key press from external source (e.g., keyboard)
void injectKey(char c);
@@ -229,6 +252,7 @@ public:
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
UIScreen* getDiscoveryScreen() const { return discovery_screen; }
UIScreen* getLastHeardScreen() const { return last_heard_screen; }
UIScreen* getMapScreen() const { return map_screen; }
#ifdef MECK_WEB_READER
UIScreen* getWebReaderScreen() const { return web_reader; }

View File

@@ -29,6 +29,7 @@ enum VKBPurpose {
VKB_ADMIN_CLI, // Repeater admin CLI command
VKB_NOTES, // Insert text into notes
VKB_SETTINGS_NAME, // Edit node name
VKB_SETTINGS_TEXT, // Generic settings text edit (channel name, freq, APN)
VKB_WIFI_PASSWORD, // WiFi password entry (settings screen)
#ifdef MECK_WEB_READER
VKB_WEB_URL, // Web reader URL entry
@@ -36,6 +37,7 @@ enum VKBPurpose {
VKB_WEB_WIFI_PASS, // Web reader WiFi password
VKB_WEB_LINK, // Web reader link number entry
#endif
VKB_TEXT_PAGE, // Text reader: go to page number
};
class VirtualKeyboard {
@@ -215,6 +217,32 @@ public:
// Swipe up on keyboard = cancel
void cancel() { _status = VKB_CANCELLED; }
// --- Feed a raw ASCII character from an external physical keyboard ---
// Maps standard ASCII control chars to internal VKB actions.
// Returns true if the character was consumed.
#ifdef MECK_CARDKB
bool feedChar(char c) {
if (_status != VKB_EDITING) return false;
switch (c) {
case '\r': processKey('>'); return true; // Enter → submit
case '\b': processKey('<'); return true; // Backspace
case 0x7F: processKey('<'); return true; // Delete → backspace
case 0x1B: _status = VKB_CANCELLED; return true; // ESC → cancel
case ' ': processKey('~'); return true; // Space
default:
// Printable ASCII → insert directly
if (c >= 0x20 && c <= 0x7E) {
if (_textLen < _maxLen) {
_text[_textLen++] = c;
_text[_textLen] = '\0';
}
return true;
}
return false; // Non-printable / nav keys — not consumed
}
}
#endif
private:
VKBStatus _status;
VKBPurpose _purpose;

View File

@@ -21,7 +21,7 @@ def merge_bin(source, target, env):
bootloader = os.path.join(build_dir, "bootloader.bin")
partitions = os.path.join(build_dir, "partitions.bin")
firmware = os.path.join(build_dir, "firmware.bin")
output = os.path.join(build_dir, "firmware_merged.bin")
output = os.path.join(build_dir, "firmware-merged.bin")
# Verify all inputs exist
for f in [bootloader, partitions, firmware]:

BIN
readback.bin Normal file

Binary file not shown.

View File

@@ -52,7 +52,7 @@ bool GxEPDDisplay::begin() {
void GxEPDDisplay::turnOn() {
if (!_init) begin();
#if defined(DISP_BACKLIGHT) && !defined(BACKLIGHT_BTN)
#if defined(DISP_BACKLIGHT) && !defined(BACKLIGHT_BTN) && !defined(LilyGo_TDeck_Pro)
digitalWrite(DISP_BACKLIGHT, HIGH);
#elif defined(EXP_PIN_BACKLIGHT) && !defined(BACKLIGHT_BTN)
expander.digitalWrite(EXP_PIN_BACKLIGHT, HIGH);
@@ -61,12 +61,17 @@ void GxEPDDisplay::turnOn() {
}
void GxEPDDisplay::turnOff() {
#if defined(DISP_BACKLIGHT) && !defined(BACKLIGHT_BTN)
#if defined(DISP_BACKLIGHT) && !defined(BACKLIGHT_BTN) && !defined(LilyGo_TDeck_Pro)
// Only toggle backlight on boards that actually have one.
// T-Deck Pro defines DISP_BACKLIGHT (GPIO 45) but has no physical backlight —
// setting _isOn=false would stop the render loop, making the device appear frozen.
digitalWrite(DISP_BACKLIGHT, LOW);
_isOn = false;
#elif defined(EXP_PIN_BACKLIGHT) && !defined(BACKLIGHT_BTN)
expander.digitalWrite(EXP_PIN_BACKLIGHT, LOW);
#endif
_isOn = false;
#endif
// T-Deck Pro: _isOn stays true — e-ink has no backlight, render loop must keep running
}
void GxEPDDisplay::clear() {
@@ -100,15 +105,23 @@ void GxEPDDisplay::setTextSize(int sz) {
break;
case 1: // Small - use 9pt (was 9pt)
display.setFont(&FreeSans9pt7b);
display.setTextSize(1);
break;
case 2: // Medium Bold - use 9pt bold instead of 12pt
display.setFont(&FreeSans9pt7b);
display.setTextSize(1);
break;
case 3: // Large - use 12pt instead of 18pt
display.setFont(&FreeSansBold12pt7b);
display.setTextSize(1);
break;
case 5: // Extra Large - lock screen clock face
display.setFont(&FreeSansBold12pt7b);
display.setTextSize(2); // GxEPD2 native 2× scaling on 12pt bold
break;
default:
display.setFont(&FreeSans9pt7b);
display.setTextSize(1);
break;
}
}

View File

@@ -8,9 +8,10 @@
// 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.
// so LoRa, e-ink, and GPS serial all work fine at 80MHz and 40MHz.
#ifdef ESP32
@@ -22,23 +23,36 @@
#define CPU_FREQ_BOOST 240 // MHz — heavy processing
#endif
#ifndef CPU_FREQ_LOW_POWER
#define CPU_FREQ_LOW_POWER 40 // MHz — lock screen / idle standby
#endif
#ifndef CPU_BOOST_TIMEOUT_MS
#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds
#endif
class CPUPowerManager {
public:
CPUPowerManager() : _boosted(false), _boost_started(0) {}
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)) {
setIdle();
// 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;
}
}
@@ -57,13 +71,42 @@ public:
_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;
};

View File

@@ -79,7 +79,7 @@ build_flags =
-D CHANNEL_MSG_HISTORY_SIZE=800
-D DISPLAY_CLASS=FastEPDDisplay
-D USE_EINK
; Font family: comment/uncomment to toggle (delete .indexes on SD after switching)
-D MECK_CARDKB
; -D MECK_SERIF_FONT ; FreeSerif (Times New Roman-like)
; ; Default (no flag): FreeSans (Arial-like)
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
@@ -111,6 +111,7 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=256
-D DISPLAY_CLASS=FastEPDDisplay
-D USE_EINK
-D MECK_CARDKB
; -D MECK_SERIF_FONT
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
@@ -144,6 +145,7 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=256
-D DISPLAY_CLASS=FastEPDDisplay
-D USE_EINK
-D MECK_CARDKB
; -D MECK_SERIF_FONT
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
@@ -156,5 +158,4 @@ lib_deps =
densaugeo/base64 @ ~1.4.0
adafruit/Adafruit GFX Library@^1.11.0
https://github.com/mverch67/FastEPD/archive/0df1bff329b6fc782e062f611758880762340647.zip
https://github.com/lewisxhe/SensorLib/archive/refs/tags/v0.3.4.zip
https://github.com/lewisxhe/SensorLib/archive/refs/tags/v0.3.4.zip

View File

@@ -29,6 +29,7 @@
#define I2C_ADDR_BQ27220 0x55 // Fuel gauge
#define I2C_ADDR_BQ25896 0x6B // Battery charger
#define I2C_ADDR_TPS65185 0x68 // E-ink power driver
#define CARDKB_I2C_ADDR 0x5F // M5Stack CardKB (external, via QWIIC)
// -----------------------------------------------------------------------------
// SPI Bus — shared by LoRa and SD card

View File

@@ -8,9 +8,10 @@
// 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.
// so LoRa, e-ink, and GPS serial all work fine at 80MHz and 40MHz.
#ifdef ESP32
@@ -22,23 +23,36 @@
#define CPU_FREQ_BOOST 240 // MHz — heavy processing
#endif
#ifndef CPU_FREQ_LOW_POWER
#define CPU_FREQ_LOW_POWER 40 // MHz — lock screen / idle standby
#endif
#ifndef CPU_BOOST_TIMEOUT_MS
#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds
#endif
class CPUPowerManager {
public:
CPUPowerManager() : _boosted(false), _boost_started(0) {}
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)) {
setIdle();
// 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;
}
}
@@ -57,13 +71,42 @@ public:
_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;
};

View File

@@ -83,6 +83,8 @@ build_flags =
-D PIN_DISPLAY_MOSI=33
-D PIN_DISPLAY_BL=45
-D PIN_USER_BTN=0
-D HAS_TOUCHSCREEN=1
-D CST328_PIN_INT=12
-D CST328_PIN_RST=38
-D ARDUINO_LOOP_STACK_SIZE=32768
build_src_filter = ${esp32_base.build_src_filter}
@@ -144,7 +146,7 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=256
-D MECK_AUDIO_VARIANT
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.0.WiFi"'
-D FIRMWARE_VERSION='"Meck v1.2.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -194,7 +196,7 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.0.4G"'
-D FIRMWARE_VERSION='"Meck v1.2.4G"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -224,7 +226,7 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.0.4G.WiFi"'
-D FIRMWARE_VERSION='"Meck v1.2.4G.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -250,7 +252,7 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=1
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.0.4G.SA"'
-D FIRMWARE_VERSION='"Meck v1.2.4G.SA"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>