134 Commits

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
pelgraine
509411630b update readme 2026-03-13 23:20:22 +11:00
pelgraine
a1ce8ca4d4 Has gps flag for tdpro to fix audio standalone compile bug 2026-03-13 22:50:24 +11:00
pelgraine
b77059706b tdpro dark mode 2026-03-13 22:50:24 +11:00
pelgraine
a6f0052b89 t5s3 standalone clock sync over serial 2026-03-13 22:50:24 +11:00
pelgraine
120c0a739b update firmware build date; t5s3 home ui fix for standalone build 2026-03-13 22:50:24 +11:00
pelgraine
816e41d63a tdpro - reduced redundant offline queue size to free up kb in standalone builds only 2026-03-13 22:50:24 +11:00
pelgraine
68d10f088f t5s3 standalone env, no wifi, no ble, no gps 2026-03-13 22:50:24 +11:00
pelgraine
2f0c8909b9 t5s3 webreader screen ui fixes 2026-03-13 22:50:24 +11:00
pelgraine
c60255a44d fix repeater admin screen timeout buffer t5s3; minor ui fixes web reader screen t5s3 2026-03-13 22:50:24 +11:00
pelgraine
9040873526 script to create merged firmware automatically 2026-03-13 22:50:24 +11:00
pelgraine
a564957a82 t5s3 portrait mode text fix 2026-03-13 22:50:24 +11:00
pelgraine
b55892431d t5s3 portrait mode and dark mode 2026-03-13 22:50:24 +11:00
pelgraine
dc5331702d 55s3 ghosting improvement 2026-03-13 22:50:24 +11:00
pelgraine
88a887eba2 t5s3 text reader screen 2026-03-13 22:50:24 +11:00
pelgraine
b1218223e6 reader screen word wrap fixes t5s3 2026-03-13 22:50:24 +11:00
pelgraine
0971cd6015 t5s3 removed battery icon and replaced with text because it's ugly 2026-03-13 22:50:24 +11:00
pelgraine
81eb558868 t5s3 web reader ui fixes 2026-03-13 22:50:24 +11:00
pelgraine
74b24f1222 t5s3 wifi companion 2026-03-13 22:50:24 +11:00
pelgraine
182231deeb home ui icons t5s3; repeater path view tap method 2026-03-13 22:50:24 +11:00
pelgraine
3372c4aa1d t5s3 ui, screen refresh and ghosting fixes 2026-03-13 22:50:24 +11:00
pelgraine
467773366b t5s3 keyboard bug fix 2026-03-13 22:50:23 +11:00
pelgraine
753d125384 t5s3 touch mapping fix; ui fixed for repeateradminscreen; highlighting fixed for notes and discovery screen; t5s3 initial virtual keyboard implementation 2026-03-13 22:50:23 +11:00
pelgraine
8b78eac17f lock screen and lock screen clock t5s3 2026-03-13 22:50:23 +11:00
pelgraine
565c2a4c9b ui fixes including discover screen; clock fix 2026-03-13 22:50:23 +11:00
pelgraine
7ae9c47006 t5s3 ui fixes; t5s3 initial ble and wifi companion build envs 2026-03-13 22:50:23 +11:00
pelgraine
2a0497e5ba lower brightness to 4 for best darkroom reading; first prelim touch implementation; ui improvements 2026-03-13 22:50:23 +11:00
pelgraine
479673e90f LilyGo T5 S3 Epaper Pro No GPS version implementation stage 1 - H752-B; set backlight double click boot to 153 full brightness on, triple click to 40 brightness, double click off 2026-03-13 22:50:23 +11:00
pelgraine
9b15458927 Update README.md 2026-03-08 00:41:51 +11:00
pelgraine
85ccdf526e Update README.md 2026-03-08 00:40:47 +11:00
pelgraine
c0dd59834c updated firmware version and date 2026-03-07 15:56:16 +11:00
pelgraine
90a4f5f881 multi.acks 1 set default for new firmware installs; can set rxdelay, int.thresh, gps.baud and multi.acks prefs over serial; adjust preamble length dependant on SF setting; updated serial settings guide and Meck Readme accordingly 2026-03-07 15:53:50 +11:00
pelgraine
b27acb3252 multi-byte path implementation to bring Meck up to speed with Meshcore v1.14; fix regression of ui display in last msg rcd repeater hop count view and also update it for 2 byte nodes 2026-03-07 05:02:22 +11:00
pelgraine
580484e0ad update firmware version and date to v0.9.9 in prep for multibyte testing 2026-03-07 03:21:10 +11:00
pelgraine
9d7fbc3134 updated photo sizes in Launcher flash guide 2026-03-06 22:15:17 +11:00
pelgraine
b859f8f168 Added guide to flashing Meck via Launcher 2026-03-06 22:10:38 +11:00
pelgraine
190b40c2ce fixed regression with previously committed missing code from webreaderscreen h 2026-03-06 18:26:32 +11:00
pelgraine
859919348d fix serial settings loop regression argh 2026-03-05 19:13:29 +11:00
pelgraine
e91ad4bac4 repeater admin login regression bugfix and timing response improvements 2026-03-05 18:04:28 +11:00
pelgraine
db58f8cf87 add additional firmware version update to v0.9.8 for impending bugfixes 2026-03-05 17:51:27 +11:00
pelgraine
a74b1c3f7a fix accidental leftover preseed discovery code 2026-03-05 17:44:05 +11:00
pelgraine
12477af8c7 fix serial monitor loop issue 2026-03-04 19:47:19 +11:00
pelgraine
49d399c4d6 updated firmware build date 2026-03-04 19:25:01 +11:00
pelgraine
33f2e0fc6e proper discover node function working 2026-03-04 18:55:13 +11:00
pelgraine
7685de4be6 fix wifi connection path; enable phone hotspot connection pairing 2026-03-04 18:36:39 +11:00
pelgraine
3f4da4bc2b Enable settings setup over serial - see guide for details 2026-03-04 07:56:35 +11:00
pelgraine
fe949235d9 fixed stupid persistent contacts saved bug in datastore; prelim contacts discovery function 2026-03-04 07:16:07 +11:00
pelgraine
d92fdc9ffe updated accidental regression in settings - readded autoadd contacts settings; added wifi on off option to settings 2026-03-03 23:17:41 +11:00
pelgraine
3a6673edea attempt to fix contacts persistency error bug 2026-03-03 22:38:40 +11:00
pelgraine
e2a04892f4 updated uitask for wifi companions 2026-03-03 22:07:39 +11:00
pelgraine
31db349305 new wifi companions with wifi setup in onboarding; update firmware version and date accordingly 2026-03-03 21:59:30 +11:00
pelgraine
b444a664c5 removed gpsaiding references 2026-03-03 20:57:26 +11:00
pelgraine
4e4c6cba80 Removed GPSaiding as was causing device to lose fix 2026-03-03 17:26:12 +11:00
pelgraine
a178d43046 refined gpsaiding integration now that gpsdutycycle is deleted 2026-03-02 20:01:10 +11:00
pelgraine
36c5fafec6 removed gps cycle due to slow or no fix from cold start frequency 2026-03-02 19:49:03 +11:00
pelgraine
5260f0ccea commented out setting for ringtone as appears to be impossible to silence 2026-03-02 07:42:56 +11:00
pelgraine
edf3fb7fff commented out setting for ringtone as appears to be impossible to silence 2026-03-02 07:38:28 +11:00
pelgraine
129a75ed4e update firmware version and date; 2026-03-02 07:33:46 +11:00
pelgraine
1ecda1a8f5 fix qmax entries so fcc is limited to 2000mah and not 3000mah 2026-03-01 23:49:02 +11:00
pelgraine
4bb721e060 implementing low battery brownout protection to prevent contacts file corruption caused by low voltage reboot loop; board goes to sleep at 2800mv 2026-03-01 23:28:49 +11:00
pelgraine
4646fd6bd9 ringtone 2026-03-01 23:16:54 +11:00
pelgraine
d1104d0b9c Hopefully faster gps fix after cold boot 2026-03-01 22:54:26 +11:00
pelgraine
513715e472 add contacts settings in settings 2026-03-01 14:11:39 +11:00
pelgraine
1dfab7d9a6 Add image and update README for T-Deck Pro 2026-03-01 13:39:26 +11:00
pelgraine
4724cded26 changed map labels so non ascii characters aren't displayed to make it more readable; implemented fix to prevent contacts rewrite if crash occurs during boot 2026-03-01 12:46:52 +11:00
pelgraine
74d5bfef70 fix labels and map icon rendering 2026-03-01 12:36:35 +11:00
pelgraine
e9540bcf23 maps! version 1 - g key to access 2026-03-01 11:38:20 +11:00
pelgraine
2163a4c56c sync new message message read or unread notification display better between device ui and ble app; keep in channel after message sent, moved sent toaster popup to in channel 2026-03-01 08:01:47 +11:00
pelgraine
a536196fd7 Added telemetry print to repeater admin including battery and temperature status 2026-02-27 23:09:51 +11:00
pelgraine
01a7ab80eb reoeater admin menu functions overhaul and expansion 2026-02-27 22:36:40 +11:00
pelgraine
44fe5da876 updated web app guide to note new search function and IRC details 2026-02-27 20:13:59 +11:00
pelgraine
652d853b0c updated sms and phone app guide with new call changes 2026-02-27 20:11:12 +11:00
pelgraine
fdfac73427 updated readme to accommodate the several new changes in v0.9.5 2026-02-27 20:04:25 +11:00
pelgraine
351c23cc44 new emojis 2026-02-27 18:36:19 +11:00
pelgraine
6cad4f8610 press R in message channel screen view to select a message to reply directly to; update firmware version and date 2026-02-27 08:32:20 +11:00
pelgraine
6d8a01b593 fixed repeater path view regression so it's now back to being able to see 20 hops 2026-02-26 19:45:08 +11:00
pelgraine
d5bc958621 Able to copy repeater path bytes into new message 2026-02-26 16:17:00 +11:00
pelgraine
14e29eb600 fix contacts screen nav bar regression 2026-02-26 14:33:29 +11:00
pelgraine
7915e5ef0b Changed max contacts handling to psram so Audio BLE is 400 → 500 contacts, 20 channels (Near BLE protocol max (510)). Audio Standalone350 → 1500 (40 channels → 20) PSRAM-backed. 4G BLE env is 400 → 500 with 20 channels (Near BLE protocol max (510)). 4G Standalone is 600 → 1500 contacts with 20 channels - PSRAM-backed 2026-02-26 14:14:39 +11:00
pelgraine
623f3eaec4 fix screen refresh when modem ready indicated 2026-02-26 02:35:55 +11:00
pelgraine
0b2b7e61b4 uncommented meck web reader in audio ble env option 2026-02-26 02:24:29 +11:00
pelgraine
d159318b00 Fixed in-call screen and call ended notifications; fixed dial number screen print responsiveness; fixed firmware version 4G text issue caused by - instead of . 2026-02-26 02:19:10 +11:00
pelgraine
197b6de4a6 added Favourites filter to mesh Contacts scren; fixed regression with dropped in-call screen; fixed 0 key recognition 2026-02-25 23:50:52 +11:00
pelgraine
db7c5778a1 removed redundant duplicate firmware version 2026-02-25 22:49:23 +11:00
pelgraine
db0fb1d4c6 implemented search functionality with DuckDuckGo Lite 2026-02-25 22:39:53 +11:00
pelgraine
90b9045a90 contacts export function - save to SD card from contacts screen with toaster pop up confimation once completed 2026-02-25 22:19:44 +11:00
pelgraine
fd33aa8d28 phone touchscreen dialpad now available, initial iteration for alterative to keyboard number text entry; contacts export from Contacts screen to save to sd card 2026-02-25 21:57:46 +11:00
pelgraine
3652970969 fix last message overflow rendering 2026-02-25 20:42:56 +11:00
pelgraine
7f03d6fbea added extra phone screen to allow phone or sms inbox selection to enabling dialing of non-contact numbers 2026-02-25 20:26:05 +11:00
pelgraine
049017cd2d Add apn database to enable modem to connect to network without wifi, same with updates to modem manager; adustments to settings screen to show imei, carrier, apn information; updated new no-ble 4G standalone env 2026-02-25 19:59:32 +11:00
pelgraine
2a72723eff update firmware version and build date 2026-02-25 19:34:59 +11:00
pelgraine
ccb4280ae2 updated bq27220 function for better fcc battery readings; updates to webreader to enable epub downloads to sd 2026-02-25 19:14:56 +11:00
pelgraine
668aff8105 fixed stupid ui spacing 2026-02-24 15:06:17 +11:00
pelgraine
47a6dbc74b updated sms and phone app readme to match main 2026-02-24 14:49:01 +11:00
pelgraine
99c686acf2 updated home screen ui to read Phone instead of SMS 2026-02-24 14:47:49 +11:00
pelgraine
5de518d5f4 increased last seen msg rcd hop path view count limit and added scroll bar to path view 2026-02-24 14:07:57 +11:00
pelgraine
a9b37ab697 updated firmware version and date now that we've got phone calls as well 2026-02-24 14:02:53 +11:00
pelgraine
28337c41c9 phone calls! woo 2026-02-24 14:01:58 +11:00
pelgraine
c5e10ad8ea updated readme to include license info 2026-02-24 10:03:50 +11:00
pelgraine
ad196b7674 initial download epub functionality; add scroll and screen refresh to review longer bookmarks and history list web app home screen 2026-02-24 09:39:01 +11:00
pelgraine
d7bb0b2024 ui updates; updated firmware date 2026-02-24 09:10:52 +11:00
pelgraine
d5b79cf0b4 fix ble error loop crash in serialbleinterface and main; same ble crash fix in webreaderscreen 2026-02-24 02:17:42 +11:00
pelgraine
ea04d515ea html spacing display cleanup 2026-02-24 01:11:51 +11:00
pelgraine
7d9ac3a827 added toaster popup confirmation for when a bookmark is saved 2026-02-24 00:54:24 +11:00
pelgraine
241854a707 limited url retries and added url referrer on all requests to try to address CF 525 error when browsing 2026-02-24 00:35:54 +11:00
pelgraine
f289788242 increase web display max links; fix to encode spaces as %20 in web url entry so you just type a space and it will translate it for you 2026-02-24 00:00:29 +11:00
pelgraine
17347a1e9d revised approach to nav bar view 2026-02-23 19:08:43 +11:00
pelgraine
da3bf06004 ui fixes for web reader - primarily nav bar 2026-02-23 18:44:11 +11:00
pelgraine
0d750fbb19 updated firmware version; added basic irc functionality to web reader app with irc.eastmesh.au as the default suggestion 2026-02-23 18:25:46 +11:00
91 changed files with 17459 additions and 1619 deletions

64
Filter_clock_sync.py Normal file
View File

@@ -0,0 +1,64 @@
# PlatformIO monitor filter: automatic clock sync for Meck devices
#
# When a Meck device boots with no valid RTC time, it prints "MECK_CLOCK_REQ"
# over serial. This filter watches for that line and responds immediately
# with "clock sync <epoch>\r\n", setting the device's real-time clock to
# the host computer's current time.
#
# The sync is completely transparent — the user just sees it happen in the
# boot log. If the RTC already has valid time, the device never sends the
# request and this filter does nothing.
#
# Install: place this file in <project>/monitor/filter_clock_sync.py
# Enable: add "clock_sync" to monitor_filters in platformio.ini
#
# Works with: PlatformIO Core >= 6.0
import time
from platformio.device.monitor.filters.base import DeviceFilter
class ClockSync(DeviceFilter):
NAME = "clock_sync"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._buf = bytearray()
self._synced = False
def rx(self, text):
"""Called with each chunk of data received from the device."""
if self._synced:
return text
# Accumulate into a line buffer to detect MECK_CLOCK_REQ
if isinstance(text, str):
self._buf.extend(text.encode("utf-8", errors="replace"))
else:
self._buf.extend(text)
if b"MECK_CLOCK_REQ" in self._buf:
epoch = int(time.time())
response = "clock sync {}\r\n".format(epoch)
try:
# Write directly to the serial port
self.miniterm.serial.write(response.encode("utf-8"))
except Exception as e:
# Fallback: shouldn't happen, but don't crash the monitor
import sys
print(
"\n[clock_sync] Failed to auto-sync: {}".format(e),
file=sys.stderr,
)
self._synced = True
self._buf = bytearray()
elif len(self._buf) > 2048:
# Prevent unbounded growth — keep tail only
self._buf = self._buf[-256:]
return text
def tx(self, text):
"""Called with each chunk of data sent from terminal to device."""
return text

520
README.md
View File

@@ -1,10 +1,24 @@
## Meshcore + Fork = Meck
This fork was created specifically to focus on enabling BLE companion firmware for the LilyGo T-Deck Pro. Created with the assistance of Claude AI using Meshcore v1.11 code.
## Meshcore + Fork = Meck
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
- [T-Deck Pro Keyboard Controls](#t-deck-pro-keyboard-controls)
- [Supported Devices](#supported-devices)
- [Flashing Firmware](#flashing-firmware)
- [First-Time Flash (Merged Firmware)](#first-time-flash-merged-firmware)
- [Upgrading Firmware](#upgrading-firmware)
- [SD Card Launcher](#sd-card-launcher)
- [Path Hash Mode (v0.9.9+)](#path-hash-mode-v099)
- [T-Deck Pro](#t-deck-pro)
- [Build Variants](#t-deck-pro-build-variants)
- [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)
@@ -14,12 +28,29 @@ This fork was created specifically to focus on enabling BLE companion firmware f
- [Compose Mode](#compose-mode)
- [Symbol Entry (Sym Key)](#symbol-entry-sym-key)
- [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)
- [Home Screen](#t5s3-home-screen)
- [Boot Button Controls](#boot-button-controls)
- [Backlight](#backlight)
- [Lock Screen](#lock-screen)
- [Virtual Keyboard](#virtual-keyboard)
- [Display Settings](#display-settings)
- [Clock & RTC](#clock--rtc)
- [Touch Gestures by Screen](#touch-gestures-by-screen)
- [Serial Settings (USB)](Serial_Settings_Guide.md)
- [Text & EPUB Reader](TXT___EPUB_Reader_Guide.md)
- [Web Browser & IRC Guide](Web_App_Guide.md)
- [SMS & Phone App Guide](SMS___Phone_App_Guide.md)
- [About MeshCore](#about-meshcore)
- [What is MeshCore?](#what-is-meshcore)
- [Key Features](#key-features)
- [What Can You Use MeshCore For?](#what-can-you-use-meshcore-for)
- [How to Get Started](#how-to-get-started)
- [MeshCore Flasher](#meshcore-flasher)
- [MeshCore Clients](#meshcore-clients)
- [Hardware Compatibility](#-hardware-compatibility)
- [Contributing](#contributing)
@@ -28,9 +59,111 @@ This fork was created specifically to focus on enabling BLE companion firmware f
- [License](#-license)
- [Third-Party Libraries](#third-party-libraries)
## T-Deck Pro Keyboard Controls
---
The T-Deck Pro BLE companion firmware includes full keyboard support for standalone messaging without a phone.
## Supported Devices
Meck currently targets two LilyGo devices:
| Device | Display | Input | LoRa | Battery | GPS | RTC |
|--------|---------|-------|------|---------|-----|-----|
| **T-Deck Pro** | 240×320 e-ink (GxEPD2) | TCA8418 keyboard + optional touch | SX1262 | BQ27220 fuel gauge, 1400 mAh | Yes | No (uses GPS time) |
| **T5S3 E-Paper Pro** (V2, H752-B) | 960×540 e-ink (FastEPD, parallel) | GT911 capacitive touch (no keyboard) | SX1262 | BQ27220 fuel gauge, 1500 mAh | No (non-GPS variant) | Yes (PCF8563 hardware RTC) |
Both devices use the ESP32-S3 with 16 MB flash and 8 MB PSRAM.
---
## Flashing Firmware
Download the latest firmware from the [Releases](https://github.com/pelgraine/Meck/releases) page. Each release includes two types of `.bin` files per build variant:
| File Type | When to Use |
|-----------|-------------|
| `*-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)
If the device has never had Meck firmware (or you want a clean start), use the **merged** `.bin` file. This contains the bootloader, partition table, and application firmware combined into a single image.
**Using esptool.py:**
```
esptool.py --chip esp32s3 --port /dev/ttyACM0 --baud 921600 \
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`.
**Using the MeshCore Flasher (web-based, T-Deck Pro only):**
1. Go to https://flasher.meshcore.co.uk
2. Select **Custom Firmware**
3. Select the **merged** `.bin` file you downloaded
4. Click **Flash**, select your device in the popup, and click **Connect**
> **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
If the device is already running Meck (or any MeshCore-based firmware with a valid bootloader), use the **non-merged** `.bin` file. This is smaller and faster to flash since it only contains the application firmware.
**Using esptool.py:**
```
esptool.py --chip esp32s3 --port /dev/ttyACM0 --baud 921600 \
write_flash 0x10000 meck_t5s3_standalone.bin
```
> **Tip:** If you're unsure whether the device already has a bootloader, it's always safe to use the merged file and flash at `0x0` — it will overwrite everything cleanly.
### SD Card Launcher
If you're loading firmware from an SD card via the LilyGo Launcher firmware, use the **non-merged** `.bin` file. The Launcher provides its own bootloader and only needs the application image.
---
## Path Hash Mode (v0.9.9+)
Meck supports multibyte path hash, bringing it in line with MeshCore firmware v1.14. The path hash controls how many bytes each repeater uses to identify itself in forwarded flood packets. Larger hashes reduce the chance of identity collisions at the cost of fewer maximum hops per packet.
You can configure the path hash size in the device settings (press **S** from the home screen on T-Deck Pro, or open Settings via the tile on T5S3) or set it via USB serial:
```
set path.hash.mode 1
```
| Mode | Bytes per hop | Max hops | Notes |
|------|--------------|----------|-------|
| 0 | 1 | 64 | Legacy — prone to hash collisions in larger networks |
| 1 | 2 | 32 | Recommended — effectively eliminates collisions |
| 2 | 3 | 21 | Maximum precision, rarely needed |
Nodes with different path hash modes can coexist on the same network. The mode only affects packets your node originates — the hash size is encoded in each packet's header, so receiving nodes adapt automatically.
For a detailed explanation of what multibyte path hash means and why it matters, see the [Path Diagnostics & Improvements write-up](https://buymeacoffee.com/ripplebiz/path-diagnostics-improvements).
---
## T-Deck Pro
### T-Deck Pro Build Variants
| 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.
### T-Deck Pro Keyboard Controls
The T-Deck Pro firmware includes full keyboard support for standalone messaging without a phone.
### Navigation (Home Screen)
@@ -42,8 +175,16 @@ The T-Deck Pro BLE companion firmware includes full keyboard support for standal
| M | Open channel messages |
| C | Open contacts list |
| E | Open e-book reader |
| N | Open notes |
| S | Open settings |
| B | Open web browser (BLE and 4G variants only) |
| 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)
@@ -51,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:**
@@ -80,6 +238,8 @@ The GPS page also shows the current time, satellite count, position, altitude, a
| W / S | Scroll messages up/down |
| A / D | Switch between channels |
| Enter | Compose new message |
| R | Reply to a message — enter reply select mode, scroll to a message with W/S, then press Enter to compose a reply with an @mention |
| V | View relay path of the last received message (scrollable, up to 20 hops) |
| Q | Back to home screen |
### Contacts Screen
@@ -89,10 +249,14 @@ Press **C** from the home screen to open the contacts list. All known mesh conta
| Key | Action |
|-----|--------|
| W / S | Scroll up / down through contacts |
| A / D | Cycle filter: All → Chat → Repeater → Room → Sensor |
| A / D | Cycle filter: All → Chat → Repeater → Room → Sensor → Favourites |
| Enter | Open DM compose (Chat contact) or repeater admin (Repeater contact) |
| X | Export contacts to SD card (wait 510 seconds for confirmation popup) |
| R | Import contacts from SD card (wait 510 seconds for confirmation popup) |
| Q | Back to home screen |
**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
Select a **Chat** contact in the contacts list and press **Enter** to start composing a direct message. The compose screen will show `DM: ContactName` in the header. Type your message and press **Enter** to send. The DM is sent encrypted directly to that contact (or flooded if no direct path is known). After sending or cancelling, you're returned to the contacts list.
@@ -127,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:**
@@ -136,19 +300,33 @@ 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 |
| 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) |
**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.
If you've changed radio parameters, pressing Q will prompt you to apply changes before exiting.
> **Tip:** All device settings (plus mesh tuning parameters not available on-screen) can also be configured via USB serial. See the [Serial Settings Guide](Serial_Settings_Guide.md) for complete documentation.
### Compose Mode
| Key | Action |
@@ -194,14 +372,266 @@ While in compose mode, press the **$** key to open the emoji picker. A scrollabl
| Enter | Insert selected emoji |
| $ / Q / Backspace | Cancel and return to compose |
### SMS & Phone App (4G only)
Press **T** from the home screen to open the SMS & Phone app. The app opens to a menu screen where you can choose between the **Phone** dialer (for calling any number) or the **SMS Inbox** (for messaging and calling saved contacts).
For full documentation including key mappings, dialpad usage, contacts management, and troubleshooting, see the [SMS & Phone App Guide](SMS___Phone_App_Guide.md).
### Web Browser & IRC
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.
The browser is a text-centric reader best suited to text-heavy websites. It also includes basic web search via DuckDuckGo Lite, and can download EPUB files — follow a link to an `.epub` and it will be saved to the books folder on your SD card for reading later in the e-book reader.
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
The LilyGo T5S3 E-Paper Pro (V2, H752-B) is a 4.7-inch e-ink device with capacitive touch and no physical keyboard. All navigation is done via touch gestures and the Boot button (GPIO0). The larger 960×540 display provides significantly more screen real estate than the T-Deck Pro's 240×320 panel.
### T5S3 Build Variants
| Variant | Environment | BLE | WiFi | Web Reader | Max Contacts |
|---------|------------|-----|------|------------|-------------|
| Standalone | `meck_t5s3_standalone` | — | — | No | 1,500 |
| BLE Companion | `meck_t5s3_ble` | Yes | — | No | 500 |
| WiFi Companion | `meck_t5s3_wifi` | — | Yes (TCP:5000) | Yes | 1,500 |
The WiFi variant connects to the MeshCore web app or meshcore.js over your local network. The web reader shares the same WiFi connection — no extra setup needed.
### Touch Navigation
The T5S3 uses a combination of touch gestures and the Boot button for all interaction. There is no physical keyboard — text entry uses an on-screen virtual keyboard that appears when needed.
**Core gesture types:**
| Gesture | Description |
|---------|-------------|
| **Tap** | Touch and release quickly. Context-dependent: opens tiles on home screen, selects items in lists, advances pages in readers. |
| **Swipe** | Touch, drag at least 60 pixels, and release. Direction determines action (scroll, page turn, switch channel/filter). |
| **Long press (touch)** | Touch and hold for 500ms+. Context-dependent: compose messages, open DMs, delete bookmarks. |
### T5S3 Home Screen
The home screen displays a 3×2 grid of tappable tiles:
| | Column 1 | Column 2 | Column 3 |
|---|----------|----------|----------|
| **Row 1** | Messages | Contacts | Settings |
| **Row 2** | Reader | Notes | Browser (WiFi) / Discover (other) |
Tap a tile to open that screen. Tap outside the tile grid (or swipe left/right) to cycle between home pages. The additional home pages show BLE status, battery info, GPS status, and a hibernate option — same as the T-Deck Pro but navigated by swiping or tapping the left/right halves of the screen instead of pressing keys.
### Boot Button Controls
The Boot button (GPIO0, bottom of device) provides essential navigation and utility functions:
| Action | Effect |
|--------|--------|
| **Single click** | On home screen: cycle to next page. On other screens: go back (same as pressing Q on T-Deck Pro). In text reader reading mode: close book and return to file list. |
| **Double-click** | Toggle backlight at full brightness (comfortable for indoor reading). |
| **Triple-click** | Toggle backlight at low brightness (dim nighttime reading). |
| **Long press** | Lock or unlock the screen. While locked, touch is disabled and a lock screen shows the time, battery percentage, and unread message count. |
| **Long press during first 8 seconds after boot** | Enter CLI rescue mode (serial settings interface). |
### Backlight
The T5S3 has a warm-tone front-light controlled by PWM on GPIO11. Brightness ranges from 0 (off) to 255 (maximum).
- **Double-click Boot button** — toggle backlight on at 153/255 brightness (comfortable reading level)
- **Triple-click Boot button** — toggle backlight on at low brightness (4/255, nighttime reading)
- The backlight turns off automatically when the screen locks
### Lock Screen
Long press the Boot button to lock the device. The lock screen shows:
- Current time in large text (HH:MM)
- Battery percentage
- Unread message count (if any)
- "Hold button to unlock" hint
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.).
The virtual keyboard supports:
- QWERTY letter layout with a symbol/number layer (tap the **123** key to switch)
- Shift toggle for uppercase
- Backspace and Enter keys
- Phantom keystroke prevention (a brief cooldown after the keyboard opens prevents accidental taps)
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 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. 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.
### Clock & RTC
Unlike the T-Deck Pro (which relies on GPS for time), the T5S3 has a hardware RTC (PCF8563/BM8563) that maintains time across reboots as long as the battery has charge. On first use (or after a full battery drain), the clock needs to be set via USB serial:
```
clock sync 1773554535
```
Where the number is a Unix epoch timestamp. Quick one-liner from a macOS/Linux terminal:
```
echo "clock sync $(date +%s)" > /dev/ttyACM0
```
Once set, the RTC retains the time across reboots. See the [Serial Settings Guide](Serial_Settings_Guide.md) for full clock sync documentation including the PlatformIO auto-sync feature.
The UTC offset is configured in the Settings screen (same as T-Deck Pro) and is persisted to flash.
### Touch Gestures by Screen
#### Home Screen
| Gesture | Action |
|---------|--------|
| Tap tile | Open that screen (Messages, Contacts, Settings, Reader, Notes, Browser/Discover) |
| Tap outside tiles (left half) | Previous home page |
| Tap outside tiles (right half) | Next home page |
| Swipe left / right | Next / previous home page |
| Long press (touch) | Activate current page action (toggle BLE, hibernate, etc.) |
#### Channel Messages
| Gesture | Action |
|---------|--------|
| Swipe up / down | Scroll messages |
| Swipe left / right | Switch between channels |
| Tap footer area | View relay path of last received message |
| Tap path overlay | Dismiss overlay |
| Long press (touch) | Open virtual keyboard to compose message to current channel |
#### Contacts
| Gesture | Action |
|---------|--------|
| Swipe up / down | Scroll through contacts |
| Swipe left / right | Cycle contact filter (All → Chat → Repeater → Room → Sensor → Favourites) |
| Tap | Select contact |
| Long press on Chat contact | Open virtual keyboard to compose DM |
| Long press on Repeater contact | Open repeater admin login |
#### Text Reader (File List)
| Gesture | Action |
|---------|--------|
| Swipe up / down | Scroll file list |
| Tap | Open selected book |
#### Text Reader (Reading)
| 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 |
| Long press (touch) | Close book, return to file list |
| Tap status bar | Go to home screen |
#### Web Reader (WiFi variant)
| Gesture | Action |
|---------|--------|
| Tap URL bar | Open virtual keyboard for URL entry |
| Tap Search | Open virtual keyboard for DuckDuckGo search |
| Tap reading area | Next page |
| Tap footer (if links exist) | Open virtual keyboard to enter link number |
| Swipe left / right (reading) | Next / previous page |
| Swipe up / down (home/lists) | Scroll list |
| Long press (reading) | Navigate back |
| Long press on bookmark | Delete bookmark |
| Long press on home | Exit web reader |
#### Settings
| Gesture | Action |
|---------|--------|
| Swipe up / down | Scroll through settings |
| Swipe left / right | Adjust value (same as A/D keys on T-Deck Pro) |
| Tap | Toggle or edit selected setting |
#### Notes
| Gesture | Action |
|---------|--------|
| Tap (while editing) | Open virtual keyboard for text entry |
| Long press (while editing) | Save note and exit editor |
#### Discovery
| 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 |
|---------|--------|
| Swipe up / down | Scroll menu / response |
| Long press (password entry) | Open virtual keyboard for admin password |
#### All Screens
| Gesture | Action |
|---------|--------|
| Tap status bar (top of screen) | Return to home screen (except in text reader reading mode, where it advances the page) |
---
## About MeshCore
MeshCore is a lightweight, portable C++ library that enables multi-hop packet routing for embedded projects using LoRa and other packet radios. It is designed for developers who want to create resilient, decentralized communication networks that work without the internet.
## What is MeshCore?
MeshCore now supports a range of LoRa devices, allowing for easy flashing without the need to compile firmware manually. Users can flash a pre-built binary using tools like Adafruit ESPTool and interact with the network through a serial console.
MeshCore provides the ability to create wireless mesh networks, similar to Meshtastic and Reticulum but with a focus on lightweight multi-hop packet routing for embedded projects. Unlike Meshtastic, which is tailored for casual LoRa communication, or Reticulum, which offers advanced networking, MeshCore balances simplicity with scalability, making it ideal for custom embedded solutions., where devices (nodes) can communicate over long distances by relaying messages through intermediate nodes. This is especially useful in off-grid, emergency, or tactical situations where traditional communication infrastructure is unavailable.
MeshCore now supports a range of LoRa devices, allowing for easy flashing without the need to compile firmware manually. Users can flash a pre-built binary using tools like esptool.py and interact with the network through a serial console.
MeshCore provides the ability to create wireless mesh networks, similar to Meshtastic and Reticulum but with a focus on lightweight multi-hop packet routing for embedded projects. Unlike Meshtastic, which is tailored for casual LoRa communication, or Reticulum, which offers advanced networking, MeshCore balances simplicity with scalability, making it ideal for custom embedded solutions, where devices (nodes) can communicate over long distances by relaying messages through intermediate nodes. This is especially useful in off-grid, emergency, or tactical situations where traditional communication infrastructure is unavailable.
## Key Features
@@ -226,33 +656,22 @@ MeshCore provides the ability to create wireless mesh networks, similar to Mesht
- Watch the [MeshCore Intro Video](https://www.youtube.com/watch?v=t1qne8uJBAc) by Andy Kirby.
- Read through our [Frequently Asked Questions](./docs/faq.md) section.
- Flash the MeshCore firmware on a supported device.
- Download firmware from the [Releases](https://github.com/pelgraine/Meck/releases) page and flash it using the instructions above.
- Connect with a supported client.
For developers;
For developers:
- Install [PlatformIO](https://docs.platformio.org) in [Visual Studio Code](https://code.visualstudio.com).
- Clone and open the MeshCore repository in Visual Studio Code.
- See the example applications you can modify and run:
- [Companion Radio](./examples/companion_radio) - For use with an external chat app, over BLE, USB or WiFi.
## MeshCore Flasher
Download a copy of the Meck firmware bin from https://github.com/pelgraine/Meck/releases, then:
- Launch https://flasher.meshcore.co.uk
- Select Custom Firmware
- Select the .bin file you just downloaded, and click Open or press Enter.
- Click Flash, then select your device in the popup window (eg. USB JTAG/serial debug unit cu.usbmodem101 as an example), then click Connect.
- Once flashing is complete, you can connect with one of the MeshCore clients below.
- Clone and open the Meck repository in Visual Studio Code.
- Build for your target device using the environment names listed in the build variant tables above.
## MeshCore Clients
**Companion Firmware**
The companion firmware can be connected to via BLE.
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 the T-Deck Pro, BLE is disabled by default at boot. Navigate to the Bluetooth home page and press Enter to enable BLE before connecting with a companion app.
> **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
@@ -262,7 +681,7 @@ The companion firmware can be connected to via BLE.
## 🛠 Hardware Compatibility
MeshCore is designed for devices listed in the [MeshCore Flasher](https://flasher.meshcore.co.uk)
MeshCore is designed for devices listed in the [MeshCore Flasher](https://flasher.meshcore.co.uk). Meck specifically targets the LilyGo T-Deck Pro and LilyGo T5S3 E-Paper Pro.
## Contributing
@@ -277,6 +696,8 @@ Here are some general principals you should try to adhere to:
## Road-Map / To-Do
There are a number of fairly major features in the pipeline, with no particular time-frames attached yet. In partly chronological order:
**T-Deck Pro:**
- [X] Companion radio: BLE
- [X] Text entry for Public channel messages Companion BLE firmware
- [X] View and compose all channel messages Companion BLE firmware
@@ -285,12 +706,36 @@ There are a number of fairly major features in the pipeline, with no particular
- [X] Standalone repeater admin access for Companion BLE firmware
- [X] GPS time sync with on-device timezone setting
- [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
- [ ] Expand SMS app to enable phone calls
- [ ] Better JPEG and PNG decoding
- [ ] Improve EPUB rendering and EPUB format handling
- [ ] Map support with GPS
- [ ] Basic web reader app for text-centric websites
- [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, 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
- [X] Contacts with filter cycling and long-press DM/admin
- [X] Text reader with swipe page turns
- [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
## 📞 Get Support
@@ -308,10 +753,13 @@ However, this firmware links against libraries with different license terms. Bec
|---------|---------|-----------------|
| [MeshCore](https://github.com/meshcore-dev/MeshCore) | MIT | Scott Powell / rippleradios.com |
| [GxEPD2](https://github.com/ZinggJM/GxEPD2) | GPL-3.0 | Jean-Marc Zingg |
| [FastEPD](https://github.com/bitbank2/FastEPD) | Apache-2.0 | Larry Bank (bitbank2) |
| [ESP32-audioI2S](https://github.com/schreibfaul1/ESP32-audioI2S) | GPL-3.0 | schreibfaul1 (Wolle) |
| [Adafruit GFX Library](https://github.com/adafruit/Adafruit-GFX-Library) | BSD | Adafruit |
| [RadioLib](https://github.com/jgromes/RadioLib) | MIT | Jan Gromeš |
| [SensorLib](https://github.com/lewisxhe/SensorLib) | MIT | Lewis He |
| [JPEGDEC](https://github.com/bitbank2/JPEGDEC) | Apache-2.0 | Larry Bank (bitbank2) |
| [PNGdec](https://github.com/bitbank2/PNGdec) | Apache-2.0 | Larry Bank (bitbank2) |
| [CRC32](https://github.com/bakercp/CRC32) | MIT | Christopher Baker |
| [base64](https://github.com/Densaugeo/base64_arduino) | MIT | densaugeo |
| [Arduino Crypto](https://github.com/rweather/arduinolibs) | MIT | Rhys Weatherley |

View File

@@ -1,4 +1,4 @@
## SMS & Phone App (4G variant only) - Meck v0.9.3 (Alpha)
## SMS & Phone App (4G variant only) - Meck v0.9.5
Press **T** from the home screen to open the SMS & Phone app.
Requires a nano SIM card inserted in the T-Deck Pro V1.1 4G modem slot and an
@@ -8,16 +8,31 @@ powered. The modem (and its red LED) can be switched off and on from the
settings screen. After each modem startup, the system clock syncs from the
cellular network, which takes roughly 15 seconds.
### App Menu
The SMS & Phone app opens to a landing screen with two options:
| Option | Description |
|--------|-------------|
| **Phone** | Open the phone dialer to call any number |
| **SMS Inbox** | Open the SMS inbox for messaging and calling saved contacts |
Use **W / S** to select an option and **Enter** to confirm. Press **Q** to
return to the home screen.
### Key Mapping
| Context | Key | Action |
|---------|-----|--------|
| Home screen | T | Open SMS & Phone app |
| App menu | W / S | Select Phone or SMS Inbox |
| App menu | Enter | Open selected option |
| App menu | Q | Back to home screen |
| Inbox | W / S | Scroll conversations |
| Inbox | Enter | Open conversation |
| Inbox | C | Compose new SMS (enter phone number) |
| Inbox | D | Open contacts directory |
| Inbox | Q | Back to home screen |
| Inbox | Q | Back to app menu |
| Conversation | W / S | Scroll messages |
| Conversation | C | Reply to this conversation |
| Conversation | F | Call this number |
@@ -31,6 +46,10 @@ cellular network, which takes roughly 15 seconds.
| Contacts | Q | Back to inbox |
| Edit Contact | Enter | Save contact name |
| Edit Contact | Shift+Del | Cancel without saving |
| Phone Dialer | 09, *, +, # | Enter phone number (see input methods below) |
| Phone Dialer | Enter | Place call |
| Phone Dialer | Backspace | Delete last digit |
| Phone Dialer | Q | Back to app menu |
| Dialing | Enter or Q | Cancel / hang up |
| Incoming Call | Enter | Answer call |
| Incoming Call | Q | Reject call |
@@ -55,23 +74,20 @@ shown in the footer while composing.
### Making a Phone Call
Press **F** to call from either the conversation view or the contacts
directory. The display switches to a dialing screen showing the contact name
(or phone number) and an animated progress indicator. Once the remote party
answers, the screen transitions to the in-call view with a live call timer.
There are three ways to start a call:
There are two ways to start a call:
1. **From a conversation** — open a conversation and press **F**. You can call
1. **From the phone dialer** — select **Phone** from the app menu to open the
dialer. Enter a phone number and press **Enter** to call. This is the
easiest way to call a number you haven't messaged before.
2. **From a conversation** — open a conversation and press **F**. You can call
any number you have previously exchanged messages with, whether or not it is
saved as a named contact.
2. **From the contacts directory** — press **D** from the inbox, scroll to a
3. **From the contacts directory** — press **D** from the inbox, scroll to a
contact, and press **F**.
> **Note:** There is currently no way to dial an arbitrary phone number without
> first creating a conversation. To call a new number, press **C** from the
> inbox to compose a new SMS, enter the phone number, send a short message,
> then open the resulting conversation and press **F** to call.
The display switches to a dialing screen showing the contact name (or phone
number) and an animated progress indicator. Once the remote party answers, the
screen transitions to the in-call view with a live call timer.
During an active call, **W** and **S** adjust the speaker volume (05). The
number keys **09**, **\***, and **#** send DTMF tones for navigating phone
@@ -80,6 +96,26 @@ menus and voicemail systems. Press **Enter** or **Q** to hang up.
Audio is routed through the A7682E modem's internal codec to the board speaker
and microphone — no headphones or external audio hardware are required.
### Phone Dialer Input Methods
The phone dialer supports three ways to enter digits:
1. **Direct key press** — press the keyboard letter that corresponds to each
number using the silk-screened labels on the T-Deck Pro keys:
| Key | Digit | | Key | Digit | | Key | Digit |
|-----|-------|-|-----|-------|-|-----|-------|
| W | 1 | | S | 4 | | Z | 7 |
| E | 2 | | D | 5 | | X | 8 |
| R | 3 | | F | 6 | | C | 9 |
| A | * | | O | + | | Mic | 0 |
2. **Touchscreen tap** — tap the on-screen number buttons directly. Note: this
currently requires fairly precise taps on the numbers themselves.
3. **Sym+key** — the standard symbol entry method (e.g. Sym+W for 1, Sym+S for
4, etc.)
### Receiving a Phone Call
When an incoming call arrives, the app automatically switches to the incoming
@@ -134,6 +170,12 @@ call screens. Bars are derived from the modem's CSQ (signal quality) reading,
updated every 30 seconds. The modem state (REG, READY, OFF, etc.) is shown
when not yet connected. During a call, the signal indicator remains visible.
### IMEI, Carrier & APN
The 4G modem's IMEI, current carrier name, and APN are displayed at the bottom
of the settings screen (press **S** from the home screen), alongside your node
ID and firmware version.
### SD Card Structure
```
@@ -158,7 +200,6 @@ SD Card
| SMS sends but no delivery | Check signal strength; below 5 bars is marginal. Move to better coverage |
| Call drops immediately after dialing | Check signal strength and ensure the SIM plan supports voice calls |
| No audio during call | The A7682E routes audio through its own codec; ensure the board speaker is not obstructed. Try adjusting volume with W/S |
| Cannot dial a number | You must first have a conversation or saved contact for that number. Send a short SMS to create a conversation, then press F |
> **Note:** The SMS & Phone app is only available on the 4G modem variant of
> the T-Deck Pro. It is not present on the audio or standalone BLE builds due

456
Serial Settings Guide.md Normal file
View File

@@ -0,0 +1,456 @@
# Meck Serial Settings Guide
Configure your T-Deck Pro's Meck firmware over USB serial — no companion app needed. Plug in a USB-C cable, open a serial terminal, and you have full access to every setting on the device.
## Getting Started
### What You Need
- T-Deck Pro running Meck firmware
- USB-C cable
- A serial terminal application:
- **Windows:** PuTTY, TeraTerm, or the Arduino IDE Serial Monitor
- **macOS:** `screen`, CoolTerm, or the Arduino IDE Serial Monitor
- **Linux:** `screen`, `minicom`, `picocom`, or the Arduino IDE Serial Monitor
### Connection Settings
| Parameter | Value |
|-----------|-------|
| Baud rate | 115200 |
| Data bits | 8 |
| Parity | None |
| Stop bits | 1 |
| Line ending | CR (carriage return) or CR+LF |
### Quick Start (macOS / Linux)
```
screen /dev/ttyACM0 115200
```
On macOS the port is typically `/dev/cu.usbmodem*`. On Linux it is usually `/dev/ttyACM0` or `/dev/ttyUSB0`.
### Quick Start (Arduino IDE)
Open **Tools → Serial Monitor**, set baud to **115200** and line ending to **Carriage Return** or **Both NL & CR**.
Once connected, type `help` and press Enter to confirm everything is working.
---
## Command Reference
All commands follow a simple pattern: `get` to read, `set` to write.
### Viewing Settings
| Command | Description |
|---------|-------------|
| `get all` | Dump every setting at once |
| `get name` | Device name |
| `get freq` | Radio frequency (MHz) |
| `get bw` | Bandwidth (kHz) |
| `get sf` | Spreading factor |
| `get cr` | Coding rate |
| `get tx` | TX power (dBm) |
| `get radio` | All radio params in one line |
| `get utc` | UTC offset (hours) |
| `get notify` | Keyboard flash notification (on/off) |
| `get gps` | GPS status and interval |
| `get pin` | BLE pairing PIN |
| `get path.hash.mode` | Path hash size (0=1-byte, 1=2-byte, 2=3-byte) |
| `get rxdelay` | Rx delay base (0=disabled) |
| `get af` | Airtime factor |
| `get multi.acks` | Redundant ACKs (0 or 1) |
| `get int.thresh` | Interference threshold (0=disabled) |
| `get gps.baud` | GPS baud rate (0=compile-time default) |
| `get channels` | List all channels with index numbers |
| `get presets` | List all radio presets with parameters |
| `get pubkey` | Device public key (hex) |
| `get firmware` | Firmware version string |
| `clock` | Current RTC time (UTC + epoch) |
**4G variant only:**
| Command | Description |
|---------|-------------|
| `get modem` | Modem enabled/disabled |
| `get apn` | Current APN |
| `get imei` | Device IMEI |
### Changing Settings
#### Device Name
```
set name MyNode
```
Names cannot contain these characters: `[ ] / \ : , ? *`
#### Radio Parameters (Individual)
Each of these applies immediately — no reboot required.
```
set freq 910.525
set bw 62.5
set sf 7
set cr 5
set tx 22
```
Valid ranges:
| Parameter | Min | Max |
|-----------|-----|-----|
| freq | 400.0 | 928.0 |
| bw | 7.8 | 500.0 |
| sf | 5 | 12 |
| cr | 5 | 8 |
| tx | 1 | Board max (typically 22) |
#### Radio Parameters (All at Once)
Set frequency, bandwidth, spreading factor, and coding rate in a single command:
```
set radio 910.525 62.5 7 5
```
#### Radio Presets
The easiest way to configure your radio. First, list the available presets:
```
get presets
```
This prints a numbered list like:
```
Available radio presets:
0 Australia 915.800 MHz BW250.0 SF10 CR5 TX22
1 Australia (Narrow) 916.575 MHz BW62.5 SF7 CR8 TX22
...
14 USA/Canada (Recommended) 910.525 MHz BW62.5 SF7 CR5 TX22
15 Vietnam 920.250 MHz BW250.0 SF11 CR5 TX22
```
Apply a preset by name or number:
```
set preset USA/Canada (Recommended)
set preset 14
```
Preset names are case-insensitive, so `set preset australia` works too. The preset applies all five radio parameters (freq, bw, sf, cr, tx) and takes effect immediately.
#### UTC Offset
```
set utc 10
```
Range: -12 to +14.
#### Keyboard Notification Flash
Toggle whether the keyboard backlight flashes when a new message arrives:
```
set notify on
set notify off
```
#### BLE PIN
```
set pin 123456
```
#### Path Hash Mode
Controls the byte size of each repeater's identity stamp in forwarded flood packets. Larger hashes reduce collisions at the cost of fewer maximum hops.
```
set path.hash.mode 1
```
| Mode | Bytes/hop | Max hops | Notes |
|------|-----------|----------|-------|
| 0 | 1 | 64 | Legacy — prone to hash collisions in larger networks |
| 1 | 2 | 32 | Recommended — effectively eliminates collisions |
| 2 | 3 | 21 | Maximum precision, rarely needed |
Nodes with different modes can coexist — the mode only affects packets your node originates. The hash size is encoded in each packet's header, so receiving nodes adapt automatically.
### Mesh Tuning
These settings control how the device participates in the mesh network. They take effect immediately — no reboot required (except `gps.baud`).
#### Rx Delay (rxdelay)
Delays processing of flood packets based on signal quality. Stronger signals are processed first; weaker copies wait longer and are typically discarded as duplicates. Direct messages are always processed immediately.
```
set rxdelay 3
```
Range: 020 (0 = disabled, default). Higher values create larger timing differences between strong and weak signals. Values below 1.0 have no practical effect. See the [MeshSydney wiki](https://meshsydney.com/wiki) for detailed tuning profiles.
#### Airtime Factor (af)
Adjusts how long certain internal timing windows remain open. Does not change the LoRa radio parameters (SF, BW, CR) — those remain as configured.
```
set af 1.0
```
Range: 09 (default: 1.0). Keep this value consistent across nodes in your mesh for best coherence.
#### Multiple Acknowledgments (multi.acks)
Sends redundant ACK packets for direct messages. When enabled, two ACKs are sent (a multi-ack first, then the standard ACK), improving delivery confirmation reliability.
```
set multi.acks 1
```
Values: 0 (single ACK) or 1 (redundant ACKs, default).
#### Interference Threshold (int.thresh)
Enables channel activity scanning before transmitting. Not recommended unless your device is in a high RF interference environment — specifically where the noise floor is low but shows significant fluctuations indicating interference. Enabling this adds approximately 4 seconds of receive delay per packet.
```
set int.thresh 14
set int.thresh 0
```
Values: 0 (disabled, default) or 14+ (14 is the typical setting). Values between 113 are not functional and will be rejected.
#### GPS Baud Rate (gps.baud)
Override the GPS serial baud rate. The default (0) uses the compile-time value of 38400. **Requires a reboot to take effect** — the GPS serial port is only configured at startup.
```
set gps.baud 9600
set gps.baud 0
```
Valid rates: 0 (default), 4800, 9600, 19200, 38400, 57600, 115200.
### Channel Management
#### List Channels
```
get channels
```
Output:
```
[0] #public
[1] #meck-test
[2] #local-group
```
#### Add a Hashtag Channel
```
set channel.add meck-test
```
The `#` prefix is added automatically if you omit it. The channel's encryption key is derived from the name (SHA-256), matching the same method used by the on-device Settings screen and companion apps.
#### Delete a Channel
```
set channel.del 2
```
Channels are referenced by their index number (shown in `get channels`). Channel 0 (public) cannot be deleted. Remaining channels are automatically compacted after deletion.
### 4G Modem (4G Variant Only)
#### Enable / Disable Modem
```
set modem on
set modem off
```
#### Set APN
```
set apn telstra.internet
```
To clear a custom APN and revert to auto-detection on next boot:
```
set apn
```
### Clock Sync
Set the device's real-time clock from a Unix timestamp. This is especially important for the T5S3 E-Paper Pro which has no GPS to auto-set the clock. These are standalone commands (not `get`/`set` prefixed) — matching the same `clock sync` command used on MeshCore repeaters.
#### View Current Time
```
clock
```
Output:
```
> 2026-03-13 04:22:15 UTC (epoch: 1773554535)
```
If the clock has never been set:
```
> not set (epoch: 0)
```
#### Sync Clock from Serial
```
clock sync 1773554535
```
The value must be a Unix epoch timestamp in the 20242036 range.
**Quick one-liner from your terminal (macOS / Linux / WSL):**
```
echo "clock sync $(date +%s)" > /dev/ttyACM0
```
Or paste directly into the Arduino IDE Serial Monitor:
```
clock sync 1773554535
```
**Tip:** On macOS/Linux, run `date +%s` to get the current epoch. On Windows PowerShell: `[int](Get-Date -UFormat %s)`.
#### Boot-Time Auto-Sync (T5S3)
When the T5S3 boots with no valid RTC time and detects a USB serial host is connected, it sends a `MECK_CLOCK_REQ` handshake over serial. If you're using PlatformIO's serial monitor (`pio device monitor`), the built-in `clock_sync` monitor filter responds automatically with the host computer's current time — no user action required. The sync appears transparently in the boot log:
```
MECK_CLOCK_REQ
(Waiting 3s for clock sync from host...)
> Clock synced to 1773554535
```
If no USB host is connected (e.g. running on battery), the sync window is skipped entirely with no boot delay.
**Manual fallback:** If you're using a serial terminal that doesn't have the filter (e.g. `screen`, PuTTY), you can paste a `clock sync` command during the 3-second window, or any time after boot:
```
clock sync $(date +%s)
```
### System Commands
| Command | Description |
|---------|-------------|
| `reboot` | Restart the device |
| `rebuild` | Erase filesystem, re-save identity + prefs + contacts + channels |
| `erase` | Format the filesystem (caution: loses everything) |
| `ls UserData/` | List files on internal filesystem |
| `ls ExtraFS/` | List files on secondary filesystem |
| `cat UserData/<path>` | Dump file contents as hex |
| `rm UserData/<path>` | Delete a file |
| `help` | Show command summary |
---
## Common Workflows
### First-Time Setup
Plug in your new T-Deck Pro and run through these commands to get on the air:
```
set name YourCallsign
set preset Australia
set utc 10
set channel.add local-group
get all
```
### Switching to a New Region
Moving from Australia to the US? One command:
```
set preset USA/Canada (Recommended)
```
Verify with:
```
get radio
```
### Custom Radio Configuration
If none of the presets match your local group or you need specific parameters, set them directly. You can do it all in one command:
```
set radio 916.575 62.5 8 8
set tx 20
```
Or one parameter at a time if you're only adjusting part of your config:
```
set freq 916.575
set bw 62.5
set sf 8
set cr 8
set tx 20
```
Both approaches apply immediately. Confirm with `get radio` to double-check everything took:
```
get radio
> freq=916.575 bw=62.5 sf=8 cr=8 tx=20
```
### Troubleshooting Radio Settings
If you're not sure what went wrong, dump everything:
```
get all
```
Compare the radio section against what others in your area are using. If you need to match exact parameters from another node:
```
set radio 916.575 62.5 7 8
set tx 22
```
### Backing Up Your Settings
Use `get all` to capture a snapshot of your configuration. Copy the serial output and save it — you can manually re-enter the settings after a firmware update or device reset if your SD card backup isn't available.
---
## Tips
- **All radio changes apply live.** There is no need to reboot after changing frequency, bandwidth, spreading factor, coding rate, or TX power. The radio reconfigures on the fly.
- **Preset selection by number is faster.** Once you've seen `get presets`, use the index number instead of typing the full name.
- **Settings are persisted immediately.** Every `set` command writes to flash. If power is lost, your settings are safe.
- **SD card backup is automatic.** If your T-Deck Pro has an SD card inserted, settings are backed up after every change. On a fresh flash, settings restore automatically from the SD card.
- **The `get all` command is your friend.** When in doubt, dump everything and check.

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

@@ -1,15 +1,97 @@
# Web Reader - Integration Summary
# Web Reader & IRC - Meck v0.9.5
### Conditional Compilation
All web reader code is wrapped in `#ifdef MECK_WEB_READER` guards. The flag is set:
- **meck_audio_ble**: Yes (`-D MECK_WEB_READER=1`) — WiFi available via BLE radio stack
- **meck_4g_ble**: Yes (`-D MECK_WEB_READER=1`) — WiFi now, PPP via A7682E in future
- **meck_audio_standalone**: No — excluded to preserve zero-radio-power design
Press **B** from the home screen to open the web reader. The web reader is
available on the BLE and 4G variants. It is excluded from the standalone audio
variant to preserve zero-radio-power design.
### 4G Modem / PPP Support
The web reader uses `isNetworkAvailable()` which checks both WiFi and (future) PPP connectivity. The `fetchPage()` method uses ESP32's standard `HTTPClient` which routes through whatever network interface is active — WiFi or PPP.
The web reader home screen provides access to the **IRC client**, the **URL
bar**, your **bookmarks**, and browsing **history**. Use **W / S** to navigate
the list and **Enter** to select an item.
When PPP support is added to the 4G modem driver, the web reader will work over cellular automatically without code changes. The `isNetworkAvailable()` method has a `TODO` placeholder for the PPP status check.
## Web Browser
A text-centric web browser ("reader mode") that fetches pages over WiFi,
strips HTML to readable text, extracts links as numbered references, and
paginates content for the e-ink display. Still very much in development, but
already useful for text-heavy websites.
Includes basic web search via **DuckDuckGo Lite** — type a search query into
the URL bar and it will be sent to DuckDuckGo.
### EPUB Downloads
If you follow a link to an `.epub` file, it will be saved directly to the
`/books/` folder on your SD card. You can then read it in the e-book reader
(press **E** from the home screen).
### Bookmarks
Press **K** while on a page to save a bookmark. Bookmarks appear on the web
reader home screen below the URL bar. To delete a bookmark, open the browser
home screen, scroll down to the bookmark, and press **Delete**.
### Cookies & History
Press **X** to clear cookies and browsing history.
---
## IRC Client
The IRC client lets you connect to IRC networks directly from the device. It
is accessed from the web reader home screen — select **IRC Chat** (the first
item) and press **Enter**.
If you are not currently connected, the IRC setup screen opens where you can
configure the server, port, nickname, and channel. If you are already
connected, you go straight to the chat view.
### IRC Setup
The setup screen has five fields. Use **W / S** to navigate between them and
press **Enter** to edit a field (type the value, then **Enter** to confirm).
| Field | Description | Default |
|-------|-------------|---------|
| Host | IRC server hostname (e.g. `irc.libera.chat`) | — |
| Port | Server port. Use `6697` for TLS or `6667` for plain | 6697 |
| Nick | Your IRC nickname (max 16 characters) | — |
| Channel | Channel to join, including the `#` (e.g. `#meshcore`) | — |
| Connect | Select and press Enter to connect | — |
TLS is used automatically when the port is 6697. Other ports connect without
encryption.
Configuration is saved to the SD card at `/web/irc.cfg` and restored on next
launch, so you only need to enter server details once.
If WiFi is not connected when you press Connect, you'll be taken to the WiFi
setup screen first.
### IRC Chat View
Once connected and joined to the channel, you'll see messages in a scrollable
chat view. The channel name and connection status are shown at the top.
| Key | Action |
|-----|--------|
| Enter | Start composing a message (type, then Enter to send) |
| Backspace | Delete last character while composing; exit compose if empty |
| W / S | Scroll up (older) / down (newer) through messages |
| X | Disconnect from IRC and return to web reader home |
| Q | Return to web reader home (connection stays alive in background) |
The IRC connection remains active when you press **Q** to go back to the web
reader home screen. You'll see the connection status and channel name displayed
on the IRC Chat line. Select it and press Enter to return to the chat. Press
**X** from the chat view to disconnect.
The client automatically reconnects if the connection drops (10-second delay
between attempts) and detects dead connections after 5 minutes of inactivity
via ping timeout.
Messages are stored in a circular buffer of 64 messages. Older messages are
discarded as new ones arrive.
---
@@ -23,8 +105,8 @@ When PPP support is added to the 4G modem driver, the web reader will work over
### Web Reader - Home View
| Key | Action |
|-----|--------|
| `w` / `s` | Navigate up/down in bookmarks/history |
| `Enter` | Select URL bar or bookmark/history item |
| `w` / `s` | Navigate up/down in IRC / URL bar / bookmarks / history |
| `Enter` | Select IRC Chat, activate URL bar, or open bookmark/history item |
| Type | Enter URL (when URL bar is active) |
| `q` | Exit to firmware home |
@@ -36,6 +118,7 @@ When PPP support is added to the 4G modem driver, the web reader will work over
| `l` or `Enter` | Enter link selection (type link number) |
| `g` | Go to new URL (return to web reader home) |
| `k` | Bookmark current page |
| `x` | Clear cookies and history |
| `q` | Back to web reader home |
### Web Reader - WiFi Setup
@@ -46,6 +129,37 @@ When PPP support is added to the 4G modem driver, the web reader will work over
| Type | Enter WiFi password |
| `q` | Back |
### IRC - Setup View
| Key | Action |
|-----|--------|
| `w` / `s` | Navigate fields (Host / Port / Nick / Channel / Connect) |
| `Enter` | Edit selected field, or connect (when on Connect button) |
| Type | Enter field value (when editing) |
| `Backspace` | Delete last character (when editing) |
| `q` | Back to web reader home |
### IRC - Chat View
| Key | Action |
|-----|--------|
| `Enter` | Start composing / send message |
| `Backspace` | Delete character / exit compose if empty |
| `w` / `s` | Scroll older / newer messages |
| `x` | Disconnect and return to web reader home |
| `q` | Back to web reader home (stays connected) |
---
## WiFi
The web reader and IRC client both use WiFi for network access. On first use,
you'll be taken to the WiFi setup screen to scan for networks and enter a
password. Credentials are saved to `/web/wifi.cfg` on the SD card and used for
auto-reconnect on subsequent launches.
On the 4G variant, the web reader currently uses WiFi. A future update will add
PPP support via the A7682E cellular modem, allowing the browser and IRC to work
over cellular data without WiFi.
---
## SD Card Structure
@@ -54,4 +168,14 @@ When PPP support is added to the 4G modem driver, the web reader will work over
wifi.cfg - Saved WiFi credentials (auto-reconnect)
bookmarks.txt - One URL per line
history.txt - Recent URLs, newest first
```
irc.cfg - IRC server/port/nick/channel config
```
---
## Conditional Compilation
All web reader code is wrapped in `#ifdef MECK_WEB_READER` guards. The flag is set:
- **meck_audio_ble**: Yes (`-D MECK_WEB_READER=1`) — WiFi available via BLE radio stack
- **meck_4g_ble**: Yes (`-D MECK_WEB_READER=1`) — WiFi now, PPP via A7682E in future
- **meck_4g_standalone**: Yes (`-D MECK_WEB_READER=1`) — WiFi works better without BLE (no teardown needed, more free heap)
- **meck_audio_standalone**: No — excluded to preserve zero-radio-power design

View File

@@ -0,0 +1,38 @@
{
"build": {
"arduino": {
"ldscript": "esp32s3_out.ld",
"memory_type": "qio_opi",
"partitions": "default_16MB.csv"
},
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=0",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=1"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"hwids": [["0x303A", "0x1001"]],
"mcu": "esp32s3",
"variant": "esp32s3"
},
"connectivity": ["wifi", "bluetooth", "lora"],
"debug": {
"openocd_target": "esp32s3.cfg"
},
"frameworks": ["arduino", "espidf"],
"name": "LilyGo T5S3 E-Paper Pro",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,
"maximum_size": 16777216,
"require_upload_port": true,
"speed": 921600
},
"url": "https://lilygo.cc/products/t5-e-paper-s3-pro",
"vendor": "LILYGO"
}

View File

@@ -0,0 +1,101 @@
# How to Flash Meck Firmware Using Launcher over WiFi
## How to Install Launcher on Your T-Deck Pro
First, ensure your SD card is inserted into your T-Deck Pro. Your SD card should already have been formatted as FAT32.
1. Plug your T-Deck Pro into your computer via USB-C.
2. Go to [https://bmorcelli.github.io/Launcher/webflasher.html](https://bmorcelli.github.io/Launcher/webflasher.html) in Chrome browser.
3. Click on **LilyGo** under Choose a Vendor.
4. Click on **T-Deck Pro**.
5. Click on **Connect**.
6. In the serial connect popup, click on your device in the list (likely starts with "USB JTAG/serial debug unit"), and click **Connect**. Wait a few seconds for it to connect.
7. Click the **Install T-Deck Pro** popup.
8. Click **Next**. (Don't worry about ticking the Erase Device checkbox.)
9. Click **Install**.
### If You Don't Already Have a Meck Firmware File
Download one from [https://github.com/pelgraine/Meck/releases](https://github.com/pelgraine/Meck/releases).
## How to Install a New Meck Firmware .bin File via Launcher
After flashing using [https://bmorcelli.github.io/Launcher/webflasher.html](https://bmorcelli.github.io/Launcher/webflasher.html), your Pro will reboot itself automatically and display the main Launcher home screen, with the SD card option highlighted.
<img src="images/01_launcher_home.jpg" alt="Launcher home screen" width="200">
Either tap **NEXT** on the device screen twice or tap on the WUI button, and tap **SEL**.
<img src="images/02_wui_selected.jpg" alt="WUI selected" width="200">
Tap on **My Network** on the pop-up menu. Press **NEXT/SEL** as needed to highlight and select your WiFi SSID.
Enter your WiFi SSID details.
Once connected, your device will display the WebUI connection screen with the T-Deck Pro IP address.
Open a browser on your computer — Chrome, Firefox, or Safari will do, but Firefox tends to be easiest — and type in the IP address displayed on your T-Deck Pro into your computer browser address bar, and press enter.
<img src="images/03_webui_ip.jpg" alt="WebUI IP address screen" width="200">
In this instance, for example, I would type `192.168.1.118`, and once I've pressed enter, the address bar now displays `http://192.168.1.118/` (as per the photo). If you're having trouble loading the IP address page, double check your browser hasn't automatically changed it to `https`. If it has, delete the `s` out of the URL and hit enter to load the page.
Login to the browser page with the username **admin** and password **launcher**, and click **Login**. The browser will refresh and display your SD card file list.
<img src="images/04_browser_login.jpg" alt="Browser login" width="450">
<img src="images/05_send_files.png" alt="SD card file list with Send Files button" width="450">
Scroll down to the bottom of the browser page, and click the **Send Files** button.
Your computer/device will load the file browser. Navigate to wherever you've previously saved your new Meck firmware `.bin` file, select the bin file, and click **Open**.
Wait for the blue loading bar on the bottom of the browser page to finish, and then check you can see the file name in the list in green. Also worth checking the file is at least 1.2MB — if it is under 1MB, the file hasn't uploaded properly and you will need to go through the **Send Files** button to try uploading it again.
<img src="images/06_check_file_uploaded.png" alt="Check file uploaded" width="450">
You can then either close the browser window or just leave it. Go back to your T-Deck Pro and press **SEL** to disconnect the WUI mode.
<img src="images/07_disconnect_wui.png" alt="Disconnect WUI" width="200">
Either press **PREV** twice to navigate to it and then press **SEL** again to open, or tap right on the **SD** button to open the SD card menu.
<img src="images/08_sd_button.jpg" alt="SD button on Launcher home" width="200">
The Launcher SD file browser will open. You will most likely have to tap **Page Down** at least twice to scroll to where the name of your new file is.
<img src="images/09_sd_file_list.png" alt="SD file list page 1" width="200">
<img src="images/10_page_down.png" alt="Page Down to find file" width="200">
Either press **NEXT** to navigate until the new file is highlighted with the `>`, or just tap right on the file name, and press **SEL** to bring up the file menu.
<img src="images/11_select_file.png" alt="Select the firmware file" width="200">
The first option on the file menu list will be **>Install**. You can either tap right on **Install** or tap **SEL**.
<img src="images/12_install_option.png" alt="Install option" width="200">
**Wait for the firmware to finish installing.** It will reboot itself automatically.
<img src="images/13_installing_fw.jpg" alt="Installing firmware" width="200">
> **Note:** On first flash of a new firmware version, the "Loading…" screen will most likely display for about 70 seconds. This is a known bug. **Please be patient** if this is the first time loading your new Meck firmware.
<img src="images/14_loading_screen.png" alt="Loading screen" width="200">
On every boot, the firmware will scan your SD card and `/books` folder for any new `.txt` or `.epub` files that haven't yet been cached. It's usually very quick even if you have a lot of ebook files, and even faster after the first boot.
<img src="images/15_indexing_pages.jpg" alt="Indexing pages" width="200">
You'll then see the firmware version splash screen for a split second.
<img src="images/16_version_splash.jpg" alt="Version splash screen" width="200">
Then the Meck home screen will display, and you're good to go. Here's an example of what the Meck 4G WiFi companion firmware home screen looks like:
<img src="images/17_meck_home.jpg" alt="Meck home screen" width="200">
> **Tip:** Every time you reset the device, the Launcher splash screen will display. Wait about six seconds if you just want the Meck firmware to boot by default. Otherwise, tap the **LAUNCHER** text at the bottom to boot back into the Launcher home screen, to get access to the SD menu and WUI menu again.
<img src="images/18_launcher_boot.jpg" alt="Launcher boot screen" width="200">

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

BIN
docs/images/03_webui_ip.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

View File

@@ -41,14 +41,18 @@ public:
void disableSerial() { _serial->disable(); }
virtual void msgRead(int msgcount) = 0;
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
const uint8_t* path = nullptr) = 0;
const uint8_t* path = nullptr, int8_t snr = 0) = 0;
virtual void notify(UIEventType t = UIEventType::none) = 0;
virtual void loop() = 0;
virtual void showAlert(const char* text, int duration_millis) {}
virtual void forceRefresh() {}
virtual void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {}
// Mark a channel as read when BLE companion app syncs a message
virtual void markChannelReadFromBLE(uint8_t channel_idx) {}
// Repeater admin callbacks (from MyMesh)
virtual void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {}
virtual void onAdminCliResponse(const char* from_name, const char* text) {}
virtual void onAdminTelemetryResult(const uint8_t* data, uint8_t len) {}
};

View File

@@ -230,6 +230,28 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
file.read((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87
file.read((uint8_t *)&_prefs.utc_offset_hours, sizeof(_prefs.utc_offset_hours)); // 88
// Fields added later — may not exist in older prefs files
if (file.read((uint8_t *)&_prefs.kb_flash_notify, sizeof(_prefs.kb_flash_notify)) != sizeof(_prefs.kb_flash_notify)) {
_prefs.kb_flash_notify = 0; // default OFF for old files
}
if (file.read((uint8_t *)&_prefs.ringtone_enabled, sizeof(_prefs.ringtone_enabled)) != sizeof(_prefs.ringtone_enabled)) {
_prefs.ringtone_enabled = 0; // default OFF for old files
}
// Clamp booleans to 0/1 in case of garbage
if (_prefs.kb_flash_notify > 1) _prefs.kb_flash_notify = 0;
if (_prefs.ringtone_enabled > 1) _prefs.ringtone_enabled = 0;
// v1.14+ fields — may not exist in older prefs files
if (file.read((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)) != sizeof(_prefs.path_hash_mode)) {
_prefs.path_hash_mode = 0; // default: legacy 1-byte
}
if (file.read((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)) != sizeof(_prefs.autoadd_max_hops)) {
_prefs.autoadd_max_hops = 0; // default: no limit
}
if (_prefs.path_hash_mode > 2) _prefs.path_hash_mode = 0;
if (_prefs.autoadd_max_hops > 64) _prefs.autoadd_max_hops = 0;
file.close();
}
}
@@ -265,14 +287,66 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86
file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87
file.write((uint8_t *)&_prefs.utc_offset_hours, sizeof(_prefs.utc_offset_hours)); // 88
file.write((uint8_t *)&_prefs.kb_flash_notify, sizeof(_prefs.kb_flash_notify)); // 89
file.write((uint8_t *)&_prefs.ringtone_enabled, sizeof(_prefs.ringtone_enabled)); // 90
file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 91
file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 92
file.close();
}
}
void DataStore::loadContacts(DataStoreHost* host) {
File file = openRead(_getContactsChannelsFS(), "/contacts3");
FILESYSTEM* fs = _getContactsChannelsFS();
// --- Crash recovery ---
// If /contacts3 is missing but /contacts3.tmp exists, a crash occurred
// after removing the original but before the rename completed.
// The .tmp file has the valid data — promote it.
if (!fs->exists("/contacts3") && fs->exists("/contacts3.tmp")) {
Serial.println("DataStore: recovering contacts from .tmp file");
fs->rename("/contacts3.tmp", "/contacts3");
}
// If both exist, a crash occurred before the old file was removed.
// The original /contacts3 is still valid — just clean up the orphan.
if (fs->exists("/contacts3.tmp")) {
fs->remove("/contacts3.tmp");
}
File file = openRead(fs, "/contacts3");
if (file) {
// --- Truncation guard ---
// If the file is smaller than one full contact record (152 bytes),
// it was truncated by a crash/brown-out. Discard it and try the
// .tmp backup if available.
size_t fsize = file.size();
if (fsize > 0 && fsize < 152) {
Serial.printf("DataStore: contacts3 truncated (%d bytes < 152), discarding\n", (int)fsize);
file.close();
fs->remove("/contacts3");
if (fs->exists("/contacts3.tmp")) {
File tmp = openRead(fs, "/contacts3.tmp");
if (tmp && tmp.size() >= 152) {
Serial.println("DataStore: recovering from .tmp after truncation");
tmp.close();
fs->rename("/contacts3.tmp", "/contacts3");
file = openRead(fs, "/contacts3");
if (!file) return; // give up
} else {
if (tmp) tmp.close();
Serial.println("DataStore: no valid contacts backup — starting fresh");
return;
}
} else {
Serial.println("DataStore: no .tmp backup — starting fresh");
return;
}
} else if (fsize == 0) {
// Empty file — nothing to load
file.close();
return;
}
bool full = false;
while (!full) {
ContactInfo c;
@@ -302,36 +376,181 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3");
}
void DataStore::saveContacts(DataStoreHost* host) {
File file = openWrite(_getContactsChannelsFS(), "/contacts3");
if (file) {
uint32_t idx = 0;
ContactInfo c;
uint8_t unused = 0;
FILESYSTEM* fs = _getContactsChannelsFS();
const char* finalPath = "/contacts3";
const char* tmpPath = "/contacts3.tmp";
while (host->getContactForSave(idx, c)) {
bool success = (file.write(c.id.pub_key, 32) == 32);
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
success = success && (file.write(&c.type, 1) == 1);
success = success && (file.write(&c.flags, 1) == 1);
success = success && (file.write(&unused, 1) == 1);
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
success = success && (file.write(c.out_path, 64) == 64);
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
// --- Step 1: Write all contacts to a temporary file ---
File file = openWrite(fs, tmpPath);
if (!file) {
Serial.println("DataStore: saveContacts FAILED — cannot open tmp file");
return;
}
if (!success) break; // write failed
uint32_t idx = 0;
ContactInfo c;
uint8_t unused = 0;
uint32_t recordsWritten = 0;
bool writeOk = true;
idx++; // advance to next contact
while (host->getContactForSave(idx, c)) {
bool success = (file.write(c.id.pub_key, 32) == 32);
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
success = success && (file.write(&c.type, 1) == 1);
success = success && (file.write(&c.flags, 1) == 1);
success = success && (file.write(&unused, 1) == 1);
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
success = success && (file.write(c.out_path, 64) == 64);
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
if (!success) {
writeOk = false;
Serial.printf("DataStore: saveContacts write error at record %d\n", idx);
break;
}
file.close();
recordsWritten++;
idx++;
}
file.close();
// --- Step 2: Verify the write completed ---
// Reopen read-only to get true on-disk size (SPIFFS file.size() is unreliable before close)
size_t expectedBytes = recordsWritten * 152; // 152 bytes per contact record
File verify = openRead(fs, tmpPath);
size_t bytesWritten = verify ? verify.size() : 0;
if (verify) verify.close();
if (!writeOk || bytesWritten != expectedBytes) {
Serial.printf("DataStore: saveContacts ABORTED — wrote %d bytes, expected %d (%d records)\n",
(int)bytesWritten, (int)expectedBytes, recordsWritten);
fs->remove(tmpPath); // Clean up failed tmp file
return; // Original /contacts3 is untouched
}
// --- Step 3: Replace original with verified temp file ---
fs->remove(finalPath);
if (fs->rename(tmpPath, finalPath)) {
Serial.printf("DataStore: saved %d contacts (%d bytes)\n", recordsWritten, (int)bytesWritten);
} else {
// Rename failed — tmp file still has the good data
Serial.println("DataStore: rename failed, tmp file preserved");
}
}
// =========================================================================
// 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) {
File file = openRead(_getContactsChannelsFS(), "/channels2");
FILESYSTEM* fs = _getContactsChannelsFS();
// Crash recovery (same pattern as contacts)
if (!fs->exists("/channels2") && fs->exists("/channels2.tmp")) {
Serial.println("DataStore: recovering channels from .tmp file");
fs->rename("/channels2.tmp", "/channels2");
}
if (fs->exists("/channels2.tmp")) {
fs->remove("/channels2.tmp");
}
File file = openRead(fs, "/channels2");
if (file) {
bool full = false;
uint8_t channel_idx = 0;
@@ -356,22 +575,54 @@ void DataStore::loadChannels(DataStoreHost* host) {
}
void DataStore::saveChannels(DataStoreHost* host) {
File file = openWrite(_getContactsChannelsFS(), "/channels2");
if (file) {
uint8_t channel_idx = 0;
ChannelDetails ch;
uint8_t unused[4];
memset(unused, 0, 4);
FILESYSTEM* fs = _getContactsChannelsFS();
const char* finalPath = "/channels2";
const char* tmpPath = "/channels2.tmp";
while (host->getChannelForSave(channel_idx, ch)) {
bool success = (file.write(unused, 4) == 4);
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
File file = openWrite(fs, tmpPath);
if (!file) {
Serial.println("DataStore: saveChannels FAILED — cannot open tmp file");
return;
}
if (!success) break; // write failed
channel_idx++;
uint8_t channel_idx = 0;
ChannelDetails ch;
uint8_t unused[4];
memset(unused, 0, 4);
bool writeOk = true;
while (host->getChannelForSave(channel_idx, ch)) {
bool success = (file.write(unused, 4) == 4);
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
if (!success) {
writeOk = false;
Serial.printf("DataStore: saveChannels write error at channel %d\n", channel_idx);
break;
}
file.close();
channel_idx++;
}
file.close();
// Reopen read-only to get true on-disk size (SPIFFS file.size() is unreliable before close)
size_t expectedBytes = channel_idx * 68; // 4 + 32 + 32 = 68 bytes per channel
File verify = openRead(fs, tmpPath);
size_t bytesWritten = verify ? verify.size() : 0;
if (verify) verify.close();
if (!writeOk || bytesWritten != expectedBytes) {
Serial.printf("DataStore: saveChannels ABORTED — wrote %d bytes, expected %d\n",
(int)bytesWritten, (int)expectedBytes);
fs->remove(tmpPath);
return;
}
fs->remove(finalPath);
if (fs->rename(tmpPath, finalPath)) {
Serial.printf("DataStore: saved %d channels (%d bytes)\n", channel_idx, (int)bytesWritten);
} else {
Serial.println("DataStore: channels rename failed, tmp file preserved");
}
}

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

File diff suppressed because it is too large Load Diff

View File

@@ -5,14 +5,14 @@
#include "AbstractUITask.h"
/*------------ Frame Protocol --------------*/
#define FIRMWARE_VER_CODE 8
#define FIRMWARE_VER_CODE 10
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "24 Feb 2026"
#define FIRMWARE_BUILD_DATE "20 March 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.9.3"
#define FIRMWARE_VERSION "Meck v1.2"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -79,11 +79,22 @@
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];
};
// Discovery scan — transient buffer for on-device node discovery
#define MAX_DISCOVERED_NODES 20
struct DiscoveredNode {
ContactInfo contact;
uint8_t path_len;
int8_t snr; // SNR × 4 from active discovery response (0 if pre-seeded)
bool already_in_contacts; // true if contact was auto-added or already known
};
class MyMesh : public BaseChatMesh, public DataStoreHost {
public:
MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui=NULL);
@@ -101,6 +112,20 @@ public:
void enterCLIRescue();
int getRecentlyHeard(AdvertPath dest[], int max_num);
// Discovery scan — on-device node discovery
void startDiscovery(uint32_t duration_ms = 30000);
void stopDiscovery();
bool isDiscoveryActive() const { return _discoveryActive; }
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);
@@ -109,8 +134,9 @@ public:
bool uiSendDirectMessage(uint32_t contact_idx, const char* text);
// Repeater admin - UI-initiated operations
bool uiLoginToRepeater(uint32_t contact_idx, const char* password);
bool uiLoginToRepeater(uint32_t contact_idx, const char* password, uint32_t& est_timeout_ms);
bool uiSendCliCommand(uint32_t contact_idx, const char* command);
bool uiSendTelemetryRequest(uint32_t contact_idx);
int getAdminContactIdx() const { return _admin_contact_idx; }
@@ -118,7 +144,10 @@ protected:
float getAirtimeBudgetFactor() const override;
int getInterferenceThreshold() const override;
int calcRxDelay(float score, uint32_t air_time) const override;
uint32_t getRetransmitDelay(const mesh::Packet *packet) override;
uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override;
uint8_t getExtraAckTransmitCount() const override;
uint8_t getAutoAddMaxHops() const override;
bool filterRecvFloodPacket(mesh::Packet* packet) override;
void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
@@ -240,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
@@ -256,6 +285,13 @@ private:
SentMsgTrack _sent_track[SENT_TRACK_SIZE];
int _sent_track_idx; // next slot in circular buffer
int _admin_contact_idx; // contact index for active admin session (-1 if none)
// Discovery scan state
DiscoveredNode _discovered[MAX_DISCOVERED_NODES];
int _discoveredCount;
bool _discoveryActive;
unsigned long _discoveryTimeout;
uint32_t _discoveryTag; // random correlation tag for active discovery
};
extern MyMesh the_mesh;

View File

@@ -30,4 +30,12 @@ struct NodePrefs { // persisted to file
uint8_t autoadd_config; // bitmask for auto-add contacts config
int8_t utc_offset_hours; // UTC offset in hours (-12 to +14), default 0
uint8_t kb_flash_notify; // Keyboard backlight flash on new message (0=off, 1=on)
uint8_t ringtone_enabled; // Ringtone on incoming call (0=off, 1=on) — 4G only
uint8_t path_hash_mode; // 0=1-byte (legacy), 1=2-byte, 2=3-byte path hashes
uint8_t autoadd_max_hops; // 0=no limit, N=up to N-1 hops (max 64)
uint32_t gps_baudrate; // GPS baud rate (0 = use compile-time GPS_BAUDRATE default)
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,372 @@
#pragma once
// =============================================================================
// ApnDatabase.h - Embedded APN Lookup Table
//
// Maps MCC/MNC (Mobile Country Code / Mobile Network Code) to default APN
// settings for common carriers worldwide. Compiled directly into flash (~3KB)
// so users never need to manually install a lookup file.
//
// The modem queries IMSI via AT+CIMI to extract MCC (3 digits) + MNC (2-3
// digits), then looks up the APN here. If not found, falls back to the
// modem's existing PDP context (AT+CGDCONT?) or user-configured APN.
//
// To add a carrier: append to APN_DATABASE[] with the MCC+MNC as a single
// integer. MNC can be 2 or 3 digits:
// MCC=310, MNC=260 → mccmnc = 310260
// MCC=505, MNC=01 → mccmnc = 50501
//
// Guard: HAS_4G_MODEM
// =============================================================================
#ifdef HAS_4G_MODEM
#ifndef APN_DATABASE_H
#define APN_DATABASE_H
struct ApnEntry {
uint32_t mccmnc; // MCC+MNC as integer (e.g. 310260 for T-Mobile US)
const char* apn; // APN string
const char* carrier; // Human-readable carrier name (for debug/display)
};
// ---------------------------------------------------------------------------
// APN Database — sorted by MCC for binary search potential (not required)
//
// Sources: carrier documentation, GSMA databases, community wikis.
// This covers ~120 major carriers across key regions. Users with less
// common carriers can set APN manually in Settings.
// ---------------------------------------------------------------------------
static const ApnEntry APN_DATABASE[] = {
// =========================================================================
// Australia (MCC 505)
// =========================================================================
{ 50501, "telstra.internet", "Telstra" },
{ 50502, "yesinternet", "Optus" },
{ 50503, "vfinternet.au", "Vodafone AU" },
{ 50506, "3netaccess", "Three AU" },
{ 50507, "telstra.internet", "Vodafone AU (MVNO)" }, // Many MVNOs on Telstra
{ 50510, "telstra.internet", "Norfolk Tel" },
{ 50512, "3netaccess", "Amaysim" }, // Optus MVNO
{ 50514, "yesinternet", "Aussie Broadband" }, // Optus MVNO
{ 50590, "yesinternet", "Optus MVNO" },
// =========================================================================
// New Zealand (MCC 530)
// =========================================================================
{ 53001, "internet", "Vodafone NZ" },
{ 53005, "internet", "Spark NZ" },
{ 53024, "internet", "2degrees" },
// =========================================================================
// United States (MCC 310, 311, 312, 313, 316)
// =========================================================================
{ 310012, "fast.t-mobile.com", "Verizon (old)" },
{ 310026, "fast.t-mobile.com", "T-Mobile US" },
{ 310030, "fast.t-mobile.com", "T-Mobile US" },
{ 310032, "fast.t-mobile.com", "T-Mobile US" },
{ 310060, "fast.t-mobile.com", "T-Mobile US" },
{ 310160, "fast.t-mobile.com", "T-Mobile US" },
{ 310200, "fast.t-mobile.com", "T-Mobile US" },
{ 310210, "fast.t-mobile.com", "T-Mobile US" },
{ 310220, "fast.t-mobile.com", "T-Mobile US" },
{ 310230, "fast.t-mobile.com", "T-Mobile US" },
{ 310240, "fast.t-mobile.com", "T-Mobile US" },
{ 310250, "fast.t-mobile.com", "T-Mobile US" },
{ 310260, "fast.t-mobile.com", "T-Mobile US" },
{ 310270, "fast.t-mobile.com", "T-Mobile US" },
{ 310310, "fast.t-mobile.com", "T-Mobile US" },
{ 310490, "fast.t-mobile.com", "T-Mobile US" },
{ 310530, "fast.t-mobile.com", "T-Mobile US" },
{ 310580, "fast.t-mobile.com", "T-Mobile US" },
{ 310660, "fast.t-mobile.com", "T-Mobile US" },
{ 310800, "fast.t-mobile.com", "T-Mobile US" },
{ 311480, "vzwinternet", "Verizon" },
{ 311481, "vzwinternet", "Verizon" },
{ 311482, "vzwinternet", "Verizon" },
{ 311483, "vzwinternet", "Verizon" },
{ 311484, "vzwinternet", "Verizon" },
{ 311489, "vzwinternet", "Verizon" },
{ 310410, "fast.t-mobile.com", "AT&T (migrated)" },
{ 310120, "att.mvno", "AT&T (Sprint)" },
{ 312530, "iot.1nce.net", "1NCE IoT" },
{ 310120, "tfdata", "Tracfone" },
// =========================================================================
// Canada (MCC 302)
// =========================================================================
{ 30220, "internet.com", "Rogers" },
{ 30221, "internet.com", "Rogers" },
{ 30237, "internet.com", "Rogers" },
{ 30272, "internet.com", "Rogers" },
{ 30234, "sp.telus.com", "Telus" },
{ 30286, "sp.telus.com", "Telus" },
{ 30236, "sp.telus.com", "Telus" },
{ 30261, "sp.bell.ca", "Bell" },
{ 30263, "sp.bell.ca", "Bell" },
{ 30267, "sp.bell.ca", "Bell" },
{ 30268, "fido-core-appl1.apn", "Fido" },
{ 30278, "internet.com", "SaskTel" },
{ 30266, "sp.mb.com", "MTS" },
// =========================================================================
// United Kingdom (MCC 234, 235)
// =========================================================================
{ 23410, "o2-internet", "O2 UK" },
{ 23415, "three.co.uk", "Vodafone UK" },
{ 23420, "three.co.uk", "Three UK" },
{ 23430, "everywhere", "EE" },
{ 23431, "everywhere", "EE" },
{ 23432, "everywhere", "EE" },
{ 23433, "everywhere", "EE" },
{ 23450, "data.lycamobile.co.uk","Lycamobile UK" },
{ 23486, "three.co.uk", "Three UK" },
// =========================================================================
// Germany (MCC 262)
// =========================================================================
{ 26201, "internet.t-mobile", "Telekom DE" },
{ 26202, "web.vodafone.de", "Vodafone DE" },
{ 26203, "internet", "O2 DE" },
{ 26207, "internet", "O2 DE" },
// =========================================================================
// France (MCC 208)
// =========================================================================
{ 20801, "orange", "Orange FR" },
{ 20810, "sl2sfr", "SFR" },
{ 20815, "free", "Free Mobile" },
{ 20820, "ofnew.fr", "Bouygues" },
// =========================================================================
// Italy (MCC 222)
// =========================================================================
{ 22201, "mobile.vodafone.it", "TIM" },
{ 22210, "mobile.vodafone.it", "Vodafone IT" },
{ 22250, "internet.it", "Iliad IT" },
{ 22288, "internet.wind", "WindTre" },
{ 22299, "internet.wind", "WindTre" },
// =========================================================================
// Spain (MCC 214)
// =========================================================================
{ 21401, "internet", "Vodafone ES" },
{ 21403, "internet", "Orange ES" },
{ 21404, "internet", "Yoigo" },
{ 21407, "internet", "Movistar" },
// =========================================================================
// Netherlands (MCC 204)
// =========================================================================
{ 20404, "internet", "Vodafone NL" },
{ 20408, "internet", "KPN" },
{ 20412, "internet", "Telfort" },
{ 20416, "internet", "T-Mobile NL" },
{ 20420, "internet", "T-Mobile NL" },
// =========================================================================
// Sweden (MCC 240)
// =========================================================================
{ 24001, "internet.telia.se", "Telia SE" },
{ 24002, "tre.se", "Three SE" },
{ 24007, "internet.telenor.se", "Telenor SE" },
// =========================================================================
// Norway (MCC 242)
// =========================================================================
{ 24201, "internet.telenor.no", "Telenor NO" },
{ 24202, "internet.netcom.no", "Telia NO" },
// =========================================================================
// Denmark (MCC 238)
// =========================================================================
{ 23801, "internet", "TDC" },
{ 23802, "internet", "Telenor DK" },
{ 23806, "internet", "Three DK" },
{ 23820, "internet", "Telia DK" },
// =========================================================================
// Switzerland (MCC 228)
// =========================================================================
{ 22801, "gprs.swisscom.ch", "Swisscom" },
{ 22802, "internet", "Sunrise" },
{ 22803, "internet", "Salt" },
// =========================================================================
// Austria (MCC 232)
// =========================================================================
{ 23201, "a1.net", "A1" },
{ 23203, "web.one.at", "Three AT" },
{ 23205, "web", "T-Mobile AT" },
// =========================================================================
// Japan (MCC 440, 441)
// =========================================================================
{ 44010, "spmode.ne.jp", "NTT Docomo" },
{ 44020, "plus.4g", "SoftBank" },
{ 44051, "au.au-net.ne.jp", "KDDI au" },
// =========================================================================
// South Korea (MCC 450)
// =========================================================================
{ 45005, "lte.sktelecom.com", "SK Telecom" },
{ 45006, "lte.ktfwing.com", "KT" },
{ 45008, "lte.lguplus.co.kr", "LG U+" },
// =========================================================================
// India (MCC 404, 405)
// =========================================================================
{ 40445, "airtelgprs.com", "Airtel" },
{ 40410, "airtelgprs.com", "Airtel" },
{ 40411, "www", "Vodafone IN (Vi)" },
{ 40413, "www", "Vodafone IN (Vi)" },
{ 40486, "www", "Vodafone IN (Vi)" },
{ 40553, "jionet", "Jio" },
{ 40554, "jionet", "Jio" },
{ 40512, "bsnlnet", "BSNL" },
// =========================================================================
// Singapore (MCC 525)
// =========================================================================
{ 52501, "internet", "Singtel" },
{ 52503, "internet", "M1" },
{ 52505, "internet", "StarHub" },
// =========================================================================
// Hong Kong (MCC 454)
// =========================================================================
{ 45400, "internet", "CSL" },
{ 45406, "internet", "SmarTone" },
{ 45412, "internet", "CMHK" },
// =========================================================================
// Brazil (MCC 724)
// =========================================================================
{ 72405, "claro.com.br", "Claro BR" },
{ 72406, "wap.oi.com.br", "Vivo" },
{ 72410, "wap.oi.com.br", "Vivo" },
{ 72411, "wap.oi.com.br", "Vivo" },
{ 72415, "internet.tim.br", "TIM BR" },
{ 72431, "gprs.oi.com.br", "Oi" },
// =========================================================================
// Mexico (MCC 334)
// =========================================================================
{ 33402, "internet.itelcel.com","Telcel" },
{ 33403, "internet.movistar.mx","Movistar MX" },
{ 33404, "internet.att.net.mx", "AT&T MX" },
// =========================================================================
// South Africa (MCC 655)
// =========================================================================
{ 65501, "internet", "Vodacom" },
{ 65502, "internet", "Telkom ZA" },
{ 65507, "internet", "Cell C" },
{ 65510, "internet", "MTN ZA" },
// =========================================================================
// Philippines (MCC 515)
// =========================================================================
{ 51502, "internet.globe.com.ph","Globe" },
{ 51503, "internet", "Smart" },
{ 51505, "internet", "Sun Cellular" },
// =========================================================================
// Thailand (MCC 520)
// =========================================================================
{ 52001, "internet", "AIS" },
{ 52004, "internet", "TrueMove" },
{ 52005, "internet", "dtac" },
// =========================================================================
// Indonesia (MCC 510)
// =========================================================================
{ 51001, "internet", "Telkomsel" },
{ 51010, "internet", "Telkomsel" },
{ 51011, "3gprs", "XL Axiata" },
{ 51028, "3gprs", "XL Axiata (Axis)" },
// =========================================================================
// Malaysia (MCC 502)
// =========================================================================
{ 50212, "celcom3g", "Celcom" },
{ 50213, "celcom3g", "Celcom" },
{ 50216, "internet", "Digi" },
{ 50219, "celcom3g", "Celcom" },
// =========================================================================
// Czech Republic (MCC 230)
// =========================================================================
{ 23001, "internet.t-mobile.cz","T-Mobile CZ" },
{ 23002, "internet", "O2 CZ" },
{ 23003, "internet.vodafone.cz","Vodafone CZ" },
// =========================================================================
// Poland (MCC 260)
// =========================================================================
{ 26001, "internet", "Plus PL" },
{ 26002, "internet", "T-Mobile PL" },
{ 26003, "internet", "Orange PL" },
{ 26006, "internet", "Play" },
// =========================================================================
// Portugal (MCC 268)
// =========================================================================
{ 26801, "internet", "Vodafone PT" },
{ 26803, "internet", "NOS" },
{ 26806, "internet", "MEO" },
// =========================================================================
// Ireland (MCC 272)
// =========================================================================
{ 27201, "internet", "Vodafone IE" },
{ 27202, "open.internet", "Three IE" },
{ 27205, "three.ie", "Three IE" },
// =========================================================================
// IoT / Global SIMs
// =========================================================================
{ 901028, "iot.1nce.net", "1NCE (IoT)" },
{ 90143, "hologram", "Hologram" },
};
#define APN_DATABASE_SIZE (sizeof(APN_DATABASE) / sizeof(APN_DATABASE[0]))
// ---------------------------------------------------------------------------
// Lookup function — returns nullptr if not found
// ---------------------------------------------------------------------------
inline const ApnEntry* apnLookup(uint32_t mccmnc) {
for (int i = 0; i < (int)APN_DATABASE_SIZE; i++) {
if (APN_DATABASE[i].mccmnc == mccmnc) {
return &APN_DATABASE[i];
}
}
return nullptr;
}
// Parse IMSI string into MCC+MNC. Tries 3-digit MNC first (6-digit mccmnc),
// falls back to 2-digit MNC (5-digit mccmnc) if not found.
inline const ApnEntry* apnLookupFromIMSI(const char* imsi) {
if (!imsi || strlen(imsi) < 5) return nullptr;
// Extract MCC (always 3 digits)
uint32_t mcc = (imsi[0] - '0') * 100 + (imsi[1] - '0') * 10 + (imsi[2] - '0');
// Try 3-digit MNC first (more specific)
if (strlen(imsi) >= 6) {
uint32_t mnc3 = (imsi[3] - '0') * 100 + (imsi[4] - '0') * 10 + (imsi[5] - '0');
uint32_t mccmnc6 = mcc * 1000 + mnc3;
const ApnEntry* entry = apnLookup(mccmnc6);
if (entry) return entry;
}
// Fall back to 2-digit MNC
uint32_t mnc2 = (imsi[3] - '0') * 10 + (imsi[4] - '0');
uint32_t mccmnc5 = mcc * 100 + mnc2;
return apnLookup(mccmnc5);
}
#endif // APN_DATABASE_H
#endif // HAS_4G_MODEM

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

@@ -4,6 +4,7 @@
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ChannelDetails.h>
#include <MeshCore.h>
#include <Packet.h>
#include "EmojiSprites.h"
// SD card message persistence
@@ -24,7 +25,7 @@
// On-disk format for message persistence (SD card)
// ---------------------------------------------------------------------------
#define MSG_FILE_MAGIC 0x4D434853 // "MCHS" - MeshCore History Store
#define MSG_FILE_VERSION 3
#define MSG_FILE_VERSION 3 // v3: MSG_PATH_MAX=20, reserved→snr field
#define MSG_FILE_PATH "/meshcore/messages.bin"
struct __attribute__((packed)) MsgFileHeader {
@@ -41,7 +42,7 @@ struct __attribute__((packed)) MsgFileRecord {
uint8_t path_len;
uint8_t channel_idx;
uint8_t valid;
uint8_t reserved;
int8_t snr; // Receive SNR × 4 (was reserved; 0 = unknown)
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes (first byte of pub key)
char text[CHANNEL_MSG_TEXT_LEN];
// 188 bytes total
@@ -57,6 +58,7 @@ public:
uint32_t timestamp;
uint8_t path_len;
uint8_t channel_idx; // Which channel this message belongs to
int8_t snr; // Receive SNR × 4 (0 if locally sent or unknown)
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes
char text[CHANNEL_MSG_TEXT_LEN];
bool valid;
@@ -74,24 +76,38 @@ private:
uint8_t _viewChannelIdx; // Which channel we're currently viewing
bool _sdReady; // SD card is available for persistence
bool _showPathOverlay; // Show path detail overlay for last received msg
int _pathOverlayScroll; // Scroll offset for hop list in path overlay
int _pathScrollPos; // Scroll offset within path overlay hop list
int _pathHopsVisible; // Hops that fit on screen (set during render)
// Reply select mode — press R to pick a message and reply with @mention
bool _replySelectMode; // True when user is picking a message to reply to
int _replySelectPos; // Index into chronological channelMsgs[] (0=oldest)
int _replyChannelMsgCount; // Cached count from last render (for input bounds)
// Per-channel unread message counts (standalone mode)
// Index 0..MAX_GROUP_CHANNELS-1 for channel messages
// Index MAX_GROUP_CHANNELS for DMs (channel_idx == 0xFF)
int _unread[MAX_GROUP_CHANNELS + 1];
public:
ChannelScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0),
_msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false), _pathOverlayScroll(0) {
_msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false), _pathScrollPos(0), _pathHopsVisible(20),
_replySelectMode(false), _replySelectPos(-1), _replyChannelMsgCount(0) {
// Initialize all messages as invalid
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
_messages[i].valid = false;
memset(_messages[i].path, 0, MSG_PATH_MAX);
}
// Initialize unread counts
memset(_unread, 0, sizeof(_unread));
}
void setSDReady(bool ready) { _sdReady = ready; }
// Add a new message to the history
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text,
const uint8_t* path_bytes = nullptr) {
const uint8_t* path_bytes = nullptr, int8_t snr = 0) {
// Move to next slot in circular buffer
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
@@ -99,12 +115,14 @@ public:
msg->timestamp = _rtc->getCurrentTime();
msg->path_len = path_len;
msg->channel_idx = channel_idx;
msg->snr = snr;
msg->valid = true;
// Store path hop hashes
memset(msg->path, 0, MSG_PATH_MAX);
if (path_bytes && path_len > 0 && path_len != 0xFF) {
int n = path_len < MSG_PATH_MAX ? path_len : MSG_PATH_MAX;
int n = mesh::Packet::getPathByteLenFor(path_len);
if (n > MSG_PATH_MAX) n = MSG_PATH_MAX;
memcpy(msg->path, path_bytes, n);
}
@@ -119,7 +137,18 @@ public:
// Reset scroll to show newest message
_scrollPos = 0;
_showPathOverlay = false; // Dismiss overlay on new message
_pathOverlayScroll = 0;
_pathScrollPos = 0;
_replySelectMode = false; // Dismiss reply select on new message
_replySelectPos = -1;
// Track unread count for this channel (only for received messages, not sent)
// path_len == 0 means locally sent
if (path_len != 0) {
int unreadSlot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : channel_idx;
if (unreadSlot >= 0 && unreadSlot <= MAX_GROUP_CHANNELS) {
_unread[unreadSlot]++;
}
}
// Persist to SD card
saveToSD();
@@ -139,8 +168,107 @@ public:
int getMessageCount() const { return _msgCount; }
uint8_t getViewChannelIdx() const { return _viewChannelIdx; }
void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; _showPathOverlay = false; _pathOverlayScroll = 0; }
void setViewChannelIdx(uint8_t idx) {
_viewChannelIdx = idx;
_scrollPos = 0;
_showPathOverlay = false;
_pathScrollPos = 0;
markChannelRead(idx);
}
bool isShowingPathOverlay() const { return _showPathOverlay; }
void dismissPathOverlay() { _showPathOverlay = false; _pathScrollPos = 0; }
// --- Reply select mode (R key → pick a message → Enter to @mention reply) ---
bool isReplySelectMode() const { return _replySelectMode; }
void exitReplySelect() { _replySelectMode = false; _replySelectPos = -1; }
// Extract sender name from a "Sender: message" formatted text.
// Returns true if a sender was found, fills senderBuf (null-terminated).
static bool extractSenderName(const char* msgText, char* senderBuf, int bufLen) {
const char* colon = strstr(msgText, ": ");
if (!colon || colon == msgText) return false;
int nameLen = colon - msgText;
if (nameLen >= bufLen) nameLen = bufLen - 1;
memcpy(senderBuf, msgText, nameLen);
senderBuf[nameLen] = '\0';
return true;
}
// Get the sender name of the currently selected message in reply select mode.
// Returns true and fills senderBuf if valid selection exists.
bool getReplySelectSender(char* senderBuf, int bufLen) {
if (!_replySelectMode || _replySelectPos < 0) return false;
// Rebuild the channel message list (same logic as render)
static int rsMsgs[CHANNEL_MSG_HISTORY_SIZE];
int count = 0;
for (int i = 0; i < _msgCount && count < CHANNEL_MSG_HISTORY_SIZE; i++) {
int idx = _newestIdx - i;
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
rsMsgs[count++] = idx;
}
}
// Reverse to chronological (oldest first)
for (int l = 0, r = count - 1; l < r; l++, r--) {
int t = rsMsgs[l]; rsMsgs[l] = rsMsgs[r]; rsMsgs[r] = t;
}
if (_replySelectPos >= count) return false;
int idx = rsMsgs[_replySelectPos];
return extractSenderName(_messages[idx].text, senderBuf, bufLen);
}
// Get the ChannelMessage pointer for the currently selected reply message.
ChannelMessage* getReplySelectMsg() {
if (!_replySelectMode || _replySelectPos < 0) return nullptr;
static int rsMsgs[CHANNEL_MSG_HISTORY_SIZE];
int count = 0;
for (int i = 0; i < _msgCount && count < CHANNEL_MSG_HISTORY_SIZE; i++) {
int idx = _newestIdx - i;
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
rsMsgs[count++] = idx;
}
}
for (int l = 0, r = count - 1; l < r; l++, r--) {
int t = rsMsgs[l]; rsMsgs[l] = rsMsgs[r]; rsMsgs[r] = t;
}
if (_replySelectPos >= count) return nullptr;
return &_messages[rsMsgs[_replySelectPos]];
}
// --- Unread message tracking (standalone mode) ---
// Mark all messages for a channel as read
void markChannelRead(uint8_t channel_idx) {
int slot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : channel_idx;
if (slot >= 0 && slot <= MAX_GROUP_CHANNELS) {
_unread[slot] = 0;
}
}
// Get unread count for a specific channel
int getUnreadForChannel(uint8_t channel_idx) const {
int slot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : channel_idx;
if (slot >= 0 && slot <= MAX_GROUP_CHANNELS) {
return _unread[slot];
}
return 0;
}
// Get total unread across all channels
int getTotalUnread() const {
int total = 0;
for (int i = 0; i <= MAX_GROUP_CHANNELS; i++) {
total += _unread[i];
}
return total;
}
// Find the newest RECEIVED message for the current channel
// (path_len != 0 means received, path_len 0 = locally sent)
@@ -157,12 +285,34 @@ public:
return nullptr;
}
// Format the path of the newest received message as paste-ready text
// Output: comma-separated hex prefixes e.g. "30, 3b, 9b, 05, e8, 36"
// Returns length written (0 if no path available)
int formatPathAsText(char* buf, int bufLen) {
ChannelMessage* msg = getNewestReceivedMsg();
if (!msg || msg->path_len == 0 || msg->path_len == 0xFF) return 0;
int pos = 0;
uint8_t hopCount = msg->path_len & 63;
uint8_t bytesPerHop = (msg->path_len >> 6) + 1;
for (int h = 0; h < hopCount && pos < bufLen - 1; h++) {
if (h > 0) pos += snprintf(buf + pos, bufLen - pos, ", ");
int offset = h * bytesPerHop;
for (int b = 0; b < bytesPerHop && pos < bufLen - 1; b++) {
pos += snprintf(buf + pos, bufLen - pos, "%02x", msg->path[offset + b]);
}
}
return pos;
}
// -----------------------------------------------------------------------
// SD card persistence
// -----------------------------------------------------------------------
// Save the entire message buffer to SD card.
// File: /meshcore/messages.bin (~56 KB for 300 messages)
// File: /meshcore/messages.bin (~50 KB for 300 messages)
void saveToSD() {
#if defined(HAS_SDCARD) && defined(ESP32)
if (!_sdReady) return;
@@ -194,7 +344,7 @@ public:
rec.path_len = _messages[i].path_len;
rec.channel_idx = _messages[i].channel_idx;
rec.valid = _messages[i].valid ? 1 : 0;
rec.reserved = 0;
rec.snr = _messages[i].snr;
memcpy(rec.path, _messages[i].path, MSG_PATH_MAX);
memcpy(rec.text, _messages[i].text, CHANNEL_MSG_TEXT_LEN);
f.write((uint8_t*)&rec, sizeof(rec));
@@ -261,6 +411,7 @@ public:
_messages[i].path_len = rec.path_len;
_messages[i].channel_idx = rec.channel_idx;
_messages[i].valid = (rec.valid != 0);
_messages[i].snr = rec.snr;
memcpy(_messages[i].path, rec.path, MSG_PATH_MAX);
memcpy(_messages[i].text, rec.text, CHANNEL_MSG_TEXT_LEN);
if (_messages[i].valid) loaded++;
@@ -349,6 +500,8 @@ public:
// Route type
display.setCursor(0, y);
uint8_t plen = msg->path_len;
uint8_t hopCount = plen & 63; // extract hop count from encoded path_len
uint8_t bytesPerHop = (plen >> 6) + 1; // 1, 2, or 3 bytes per hop
if (plen == 0xFF) {
display.setColor(DisplayDriver::LIGHT);
display.print("Route: Direct");
@@ -357,47 +510,82 @@ public:
display.print("Route: Local/Sent");
} else {
display.setColor(DisplayDriver::GREEN);
sprintf(tmp, "Route: %d hop%s", plen, plen == 1 ? "" : "s");
sprintf(tmp, "Route: %d hop%s (%dB)", hopCount, hopCount == 1 ? "" : "s", bytesPerHop);
display.print(tmp);
}
y += lineH + 2;
y += lineH;
// SNR (if available — value is SNR×4)
if (msg->snr != 0) {
display.setCursor(0, y);
display.setColor(DisplayDriver::YELLOW);
int snr_whole = msg->snr / 4;
int snr_frac = ((abs(msg->snr) % 4) * 10) / 4;
sprintf(tmp, "SNR: %d.%ddB", snr_whole, snr_frac);
display.print(tmp);
y += lineH;
}
y += 2;
// Show each hop resolved against contacts (scrollable)
if (plen > 0 && plen != 0xFF) {
int displayHops = plen < MSG_PATH_MAX ? plen : MSG_PATH_MAX;
int footerHeight = 14;
if (hopCount > 0 && plen != 0xFF) {
int displayHops = hopCount;
int footerReserve = 26; // footer + divider
int scrollBarW = 4;
int maxY = display.height() - footerHeight;
int maxY = display.height() - footerReserve;
int hopAreaTop = y;
// Calculate how many hops fit in the visible area
int hopsAreaTop = y;
int visibleHops = (maxY - y) / lineH;
if (visibleHops < 1) visibleHops = 1;
int hopsVisible = (maxY - hopAreaTop) / lineH;
if (hopsVisible < 1) hopsVisible = 1;
_pathHopsVisible = hopsVisible; // Cache for input handler
bool needsScroll = displayHops > hopsVisible;
// Clamp scroll position
int maxScroll = displayHops > visibleHops ? displayHops - visibleHops : 0;
if (_pathOverlayScroll > maxScroll) _pathOverlayScroll = maxScroll;
int maxScroll = displayHops - hopsVisible;
if (maxScroll < 0) maxScroll = 0;
if (_pathScrollPos > maxScroll) _pathScrollPos = maxScroll;
int startHop = _pathOverlayScroll;
// Available text width (narrower if scroll bar present)
int textRight = needsScroll ? display.width() - scrollBarW - 2 : display.width();
(void)textRight; // reserved for future truncation
for (int h = startHop; h < displayHops && y + lineH <= maxY; h++) {
uint8_t hopHash = msg->path[h];
int startHop = _pathScrollPos;
int endHop = startHop + hopsVisible;
if (endHop > displayHops) endHop = displayHops;
for (int h = startHop; h < endHop && y + lineH <= maxY; h++) {
int hopOffset = h * bytesPerHop; // byte offset into path[]
display.setCursor(0, y);
display.setColor(DisplayDriver::LIGHT);
sprintf(tmp, " %d: ", h + 1);
display.print(tmp);
// Try to resolve: prefer repeaters, then any contact
// Show hex prefix (1, 2, or 3 bytes)
display.setColor(DisplayDriver::LIGHT);
if (bytesPerHop == 1) {
sprintf(tmp, "%02X ", msg->path[hopOffset]);
} else if (bytesPerHop == 2) {
sprintf(tmp, "%02X%02X ", msg->path[hopOffset], msg->path[hopOffset + 1]);
} else {
sprintf(tmp, "%02X%02X%02X ", msg->path[hopOffset], msg->path[hopOffset + 1], msg->path[hopOffset + 2]);
}
display.print(tmp);
// Try to resolve name: prefer repeaters, then any contact
bool resolved = false;
int numContacts = the_mesh.getNumContacts();
ContactInfo contact;
char filteredName[32];
// First pass: repeaters only
for (uint32_t ci = 0; ci < numContacts && !resolved; ci++) {
if (the_mesh.getContactByIdx(ci, contact)) {
if (contact.id.pub_key[0] == hopHash && contact.type == ADV_TYPE_REPEATER) {
if (memcmp(contact.id.pub_key, &msg->path[hopOffset], bytesPerHop) == 0
&& contact.type == ADV_TYPE_REPEATER) {
display.setColor(DisplayDriver::GREEN);
display.print(contact.name);
display.translateUTF8ToBlocks(filteredName, contact.name, sizeof(filteredName));
display.print(filteredName);
resolved = true;
}
}
@@ -406,38 +594,39 @@ public:
if (!resolved) {
for (uint32_t ci = 0; ci < numContacts; ci++) {
if (the_mesh.getContactByIdx(ci, contact)) {
if (contact.id.pub_key[0] == hopHash) {
if (memcmp(contact.id.pub_key, &msg->path[hopOffset], bytesPerHop) == 0) {
display.setColor(DisplayDriver::YELLOW);
display.print(contact.name);
display.translateUTF8ToBlocks(filteredName, contact.name, sizeof(filteredName));
display.print(filteredName);
resolved = true;
break;
}
}
}
}
// Fallback: show hex hash
// No name resolved - hex prefix already shown, add "?" marker
if (!resolved) {
display.setColor(DisplayDriver::LIGHT);
sprintf(tmp, "?%02X", hopHash);
display.print(tmp);
display.print("?");
}
y += lineH;
}
// --- Scroll bar for hop list ---
if (displayHops > visibleHops) {
// Scroll bar (right edge) when hops exceed visible area
if (needsScroll) {
int sbX = display.width() - scrollBarW;
int sbTop = hopsAreaTop;
int sbHeight = maxY - hopsAreaTop;
int sbTop = hopAreaTop;
int sbHeight = maxY - hopAreaTop;
// Draw track outline
// Outline
display.setColor(DisplayDriver::LIGHT);
display.drawRect(sbX, sbTop, scrollBarW, sbHeight);
// Draw proportional thumb
int thumbH = (visibleHops * sbHeight) / displayHops;
// Proportional thumb
int thumbH = (hopsVisible * sbHeight) / displayHops;
if (thumbH < 4) thumbH = 4;
int thumbY = sbTop + (_pathOverlayScroll * (sbHeight - thumbH)) / maxScroll;
int thumbY = sbTop + (_pathScrollPos * (sbHeight - thumbH)) / maxScroll;
display.setColor(DisplayDriver::GREEN);
for (int ty = thumbY + 1; ty < thumbY + thumbH - 1; ty++)
display.drawRect(sbX + 1, ty, scrollBarW - 2, 1);
}
@@ -450,9 +639,24 @@ public:
display.drawRect(0, footerY - 2, display.width(), 1);
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
display.print("Q:Back W/S:Scroll");
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Back");
const char* copyHint = "Tap:Dismiss";
#else
display.print("Q:Back");
// Show scroll hint if path is scrollable
if (msg && (msg->path_len & 63) > _pathHopsVisible && msg->path_len != 0xFF) {
const char* scrollHint = "W/S:Scrl";
int scrollW = display.getTextWidth(scrollHint);
display.setCursor((display.width() - scrollW) / 2, footerY);
display.print(scrollHint);
}
const char* copyHint = "Ent:Copy";
#endif
display.setCursor(display.width() - display.getTextWidth(copyHint) - 2, footerY);
display.print(copyHint);
#if AUTO_OFF_MILLIS == 0
#ifdef USE_EINK
return 5000;
#else
return 1000;
@@ -465,9 +669,15 @@ public:
display.setColor(DisplayDriver::LIGHT);
display.print("No messages yet");
display.setCursor(0, 30);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe: Switch channel");
display.setCursor(0, 40);
display.print("Long press: Compose");
#else
display.print("A/D: Switch channel");
display.setCursor(0, 40);
display.print("C: Compose message");
#endif
display.setTextSize(1); // Restore for footer
} else {
display.setTextSize(0); // Tiny font for message body
@@ -501,6 +711,9 @@ public:
int tmp = channelMsgs[l]; channelMsgs[l] = channelMsgs[r]; channelMsgs[r] = tmp;
}
// Cache for reply select input bounds
_replyChannelMsgCount = numChannelMsgs;
// Clamp scroll position to valid range
int maxScroll = numChannelMsgs > _msgsPerPage ? numChannelMsgs - _msgsPerPage : 0;
if (_scrollPos > maxScroll) _scrollPos = maxScroll;
@@ -513,35 +726,70 @@ public:
// Display messages oldest-to-newest (top to bottom)
int msgsDrawn = 0;
bool screenFull = false;
bool lastMsgTruncated = false; // Did the last message get clipped by footer?
for (int i = startIdx; i < numChannelMsgs && y + lineHeight <= maxY; i++) {
int idx = channelMsgs[i];
ChannelMessage* msg = &_messages[idx];
// Reply select: is this the currently selected message?
bool isSelected = (_replySelectMode && i == _replySelectPos);
// Highlight: single fillRect for the entire message area, then
// draw DARK text on top (same pattern as web reader bookmarks).
// Because message height depends on word-wrap, we fill a generous
// area up-front and erase the excess after rendering.
int yStart = y;
int contentW = display.width();
int maxLinesPerMsg = 8;
if (isSelected) {
int maxFillH = (maxLinesPerMsg + 1) * lineHeight + 2;
int availH = maxY - y;
if (maxFillH > availH) maxFillH = availH;
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, contentW, maxFillH);
#else
display.fillRect(0, y + 5, contentW, maxFillH);
#endif
}
// Time indicator with hop count - inline on same line as message start
display.setCursor(0, y);
display.setColor(DisplayDriver::YELLOW);
display.setColor(isSelected ? DisplayDriver::DARK : DisplayDriver::YELLOW);
uint32_t age = _rtc->getCurrentTime() - msg->timestamp;
if (age < 60) {
sprintf(tmp, "(%d) %ds ", msg->path_len == 0xFF ? 0 : msg->path_len, age);
} else if (age < 3600) {
sprintf(tmp, "(%d) %dm ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 60);
} else if (age < 86400) {
sprintf(tmp, "(%d) %dh ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 3600);
if (isSelected) {
// Show > marker for selected message, replacing the hop count
if (age < 60) {
sprintf(tmp, ">%ds ", age);
} else if (age < 3600) {
sprintf(tmp, ">%dm ", age / 60);
} else if (age < 86400) {
sprintf(tmp, ">%dh ", age / 3600);
} else {
sprintf(tmp, ">%dd ", age / 86400);
}
} else {
sprintf(tmp, "(%d) %dd ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 86400);
if (age < 60) {
sprintf(tmp, "(%d) %ds ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age);
} else if (age < 3600) {
sprintf(tmp, "(%d) %dm ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age / 60);
} else if (age < 86400) {
sprintf(tmp, "(%d) %dh ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age / 3600);
} else {
sprintf(tmp, "(%d) %dd ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age / 86400);
}
}
display.print(tmp);
// DO NOT advance y - message text continues on the same line
// Message text with character wrapping and inline emoji support
// (continues after timestamp on first line)
display.setColor(DisplayDriver::LIGHT);
display.setColor(isSelected ? DisplayDriver::DARK : DisplayDriver::LIGHT);
int textLen = strlen(msg->text);
int pos = 0;
int linesForThisMsg = 0;
int maxLinesPerMsg = 8;
char charStr[2] = {0, 0};
// Track position in pixels for emoji placement
@@ -574,7 +822,9 @@ public:
if (wb == ' ' || isEmojiEscape(wb)) break;
charStr[0] = (char)wb;
dblStr[0] = dblStr[1] = (char)wb;
wordW += display.getTextWidth(dblStr) - display.getTextWidth(charStr);
int charAdv = display.getTextWidth(dblStr) - display.getTextWidth(charStr);
if (charAdv < 1) charAdv = 1;
wordW += charAdv;
}
if (px + wordW > lineW) {
px = 0;
@@ -603,6 +853,7 @@ public:
charStr[0] = ' ';
dblStr[0] = dblStr[1] = ' ';
int adv = display.getTextWidth(dblStr) - display.getTextWidth(charStr);
if (adv < 1) adv = 1; // Minimum advance (rounding fix for proportional fonts)
if (px + adv > lineW) {
px = 0;
linesForThisMsg++;
@@ -620,6 +871,7 @@ public:
charStr[0] = (char)b;
dblStr[0] = dblStr[1] = (char)b;
int adv = display.getTextWidth(dblStr) - display.getTextWidth(charStr);
if (adv < 1) adv = 1; // Minimum advance (rounding fix for proportional fonts)
if (px + adv > lineW) {
px = 0;
linesForThisMsg++;
@@ -635,12 +887,34 @@ public:
}
}
// Check if this message was clipped (not all text rendered)
lastMsgTruncated = (pos < textLen);
// If we didn't end on a full line, still count it
if (px > 0) {
y += lineHeight;
}
y += 2; // Small gap between messages
// Erase excess highlight below the actual message.
// The upfront fillRect covered a max area; restore the unused
// portion back to background so subsequent messages render cleanly.
if (isSelected) {
int usedH = y - yStart;
int maxFillH = (maxLinesPerMsg + 1) * lineHeight + 2;
int availH = maxY - yStart;
if (maxFillH > availH) maxFillH = availH;
if (usedH < maxFillH) {
display.setColor(DisplayDriver::DARK);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, contentW, maxFillH - usedH);
#else
display.fillRect(0, y + 5, contentW, maxFillH - usedH);
#endif
}
}
msgsDrawn++;
if (y + lineHeight > maxY) screenFull = true;
}
@@ -650,7 +924,12 @@ public:
// prevents a feedback loop where variable-height messages cause
// msgsPerPage to oscillate, shifting startIdx every render (flicker).
if (screenFull && msgsDrawn > 0 && _scrollPos == 0) {
_msgsPerPage = msgsDrawn;
// If the last message was truncated (text clipped by footer), exclude it
// from the page count so next render starts one message later and the
// bottom message fits completely.
int effectiveDrawn = lastMsgTruncated ? msgsDrawn - 1 : msgsDrawn;
if (effectiveDrawn < 1) effectiveDrawn = 1;
_msgsPerPage = effectiveDrawn;
}
// --- Scroll bar (emoji picker style) ---
@@ -687,15 +966,31 @@ public:
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setCursor(0, footerY);
display.print("Swipe:Ch/Scroll");
const char* midCh = "Tap:Path";
display.setCursor((display.width() - display.getTextWidth(midCh)) / 2, footerY);
display.print(midCh);
const char* rtCh = "Hold:Compose";
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
display.print(rtCh);
#else
// Left side: abbreviated controls
display.print("Q:Bck A/D:Ch V:Pth");
// Right side: Ent:New
const char* rightText = "Ent:New";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
if (_replySelectMode) {
display.print("W/S:Sel V:Pth Q:X");
const char* rightText = "Ent:Reply";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
} else {
display.print("Q:Bck A/D:Ch R:Rply");
const char* rightText = "Ent:New";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
}
#endif
#if AUTO_OFF_MILLIS == 0 // e-ink
#ifdef USE_EINK
return 5000;
#else
return 1000;
@@ -703,34 +998,107 @@ public:
}
bool handleInput(char c) override {
// If overlay is showing, only handle dismiss
// If overlay is showing, handle scroll and dismiss
if (_showPathOverlay) {
if (c == 'q' || c == 'Q' || c == '\b' || c == 'v' || c == 'V') {
_showPathOverlay = false;
_pathScrollPos = 0;
return true;
}
if (c == '\r' || c == 13) {
return false; // Let main.cpp handle Enter for copy-to-compose
}
// W - scroll up in hop list
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_pathOverlayScroll > 0) {
_pathOverlayScroll--;
return true;
if (_pathScrollPos > 0) {
_pathScrollPos--;
}
return true;
}
// S - scroll down in hop list
if (c == 's' || c == 'S' || c == 0xF1) {
_pathOverlayScroll++; // Clamped during render
ChannelMessage* msg = getNewestReceivedMsg();
if (msg && msg->path_len > 0 && msg->path_len != 0xFF) {
int totalHops = msg->path_len & 63;
if (_pathScrollPos < totalHops - _pathHopsVisible) {
_pathScrollPos++;
}
}
return true;
}
return true; // Consume all keys while overlay is up
return true; // Consume all other keys while overlay is up
}
// --- Reply select mode ---
if (_replySelectMode) {
// Q - exit reply select
if (c == 'q' || c == 'Q' || c == '\b') {
_replySelectMode = false;
_replySelectPos = -1;
return true;
}
// W - select older message (lower index in chronological order)
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_replySelectPos > 0) {
_replySelectPos--;
// Auto-scroll to keep selection visible
int startIdx = _replyChannelMsgCount - _msgsPerPage - _scrollPos;
if (startIdx < 0) startIdx = 0;
if (_replySelectPos < startIdx) {
_scrollPos++;
}
}
return true;
}
// S - select newer message (higher index in chronological order)
if (c == 's' || c == 'S' || c == 0xF1) {
if (_replySelectPos < _replyChannelMsgCount - 1) {
_replySelectPos++;
// Auto-scroll to keep selection visible
int endIdx = _replyChannelMsgCount - _scrollPos;
if (_replySelectPos >= endIdx) {
if (_scrollPos > 0) _scrollPos--;
}
}
return true;
}
// V - view path for the SELECTED message (not just newest received)
if (c == 'v' || c == 'V') {
// Path overlay will use getNewestReceivedMsg() — for v1 this is fine.
// The user can see the selected message's hop count in the > marker.
ChannelMessage* selMsg = getReplySelectMsg();
if (selMsg && selMsg->path_len != 0) {
_showPathOverlay = true;
_pathScrollPos = 0;
}
return true;
}
// Enter - let main.cpp handle (enters compose with @mention)
if (c == '\r' || c == 13) {
return false;
}
return true; // Consume all other keys in reply select
}
int channelMsgCount = getMessageCountForChannel();
// R - enter reply select mode
if (c == 'r' || c == 'R') {
if (channelMsgCount > 0) {
_replySelectMode = true;
// Start with newest message selected
_replySelectPos = _replyChannelMsgCount > 0
? _replyChannelMsgCount - 1 : channelMsgCount - 1;
return true;
}
return false;
}
// V - show path detail for last received message
if (c == 'v' || c == 'V') {
if (getNewestReceivedMsg() != nullptr) {
_showPathOverlay = true;
_pathOverlayScroll = 0;
_pathScrollPos = 0;
return true;
}
return false; // No received messages to show
@@ -754,6 +1122,8 @@ public:
// A - previous channel
if (c == 'a' || c == 'A') {
_replySelectMode = false;
_replySelectPos = -1;
if (_viewChannelIdx > 0) {
_viewChannelIdx--;
} else {
@@ -767,11 +1137,14 @@ public:
}
}
_scrollPos = 0;
markChannelRead(_viewChannelIdx);
return true;
}
// D - next channel
if (c == 'd' || c == 'D') {
_replySelectMode = false;
_replySelectPos = -1;
ChannelDetails ch;
uint8_t nextIdx = _viewChannelIdx + 1;
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
@@ -780,6 +1153,7 @@ public:
_viewChannelIdx = 0;
}
_scrollPos = 0;
markChannelRead(_viewChannelIdx);
return true;
}

View File

@@ -18,6 +18,7 @@ public:
FILTER_REPEATER,
FILTER_ROOM, // Room servers
FILTER_SENSOR,
FILTER_FAVOURITE, // Contacts marked as favourite (any type)
FILTER_COUNT // keep last
};
@@ -30,9 +31,9 @@ private:
// Cached filtered contact indices for efficient scrolling
// We rebuild this on filter change or when entering the screen
static const int MAX_VISIBLE = 400; // matches MAX_CONTACTS build flag
uint16_t _filteredIdx[MAX_VISIBLE]; // indices into contact table
uint32_t _filteredTs[MAX_VISIBLE]; // cached last_advert_timestamp for sorting
// Arrays allocated in PSRAM when available (supports 1000+ contacts)
uint16_t* _filteredIdx; // indices into contact table
uint32_t* _filteredTs; // cached last_advert_timestamp for sorting
int _filteredCount; // how many contacts match current filter
bool _cacheValid;
@@ -48,6 +49,7 @@ private:
case FILTER_REPEATER: return "Rptr";
case FILTER_ROOM: return "Room";
case FILTER_SENSOR: return "Sens";
case FILTER_FAVOURITE: return "Fav";
default: return "?";
}
}
@@ -61,7 +63,7 @@ private:
}
}
bool matchesFilter(uint8_t adv_type) const {
bool matchesFilter(uint8_t adv_type, uint8_t flags = 0) const {
switch (_filter) {
case FILTER_ALL: return true;
case FILTER_CHAT: return adv_type == ADV_TYPE_CHAT;
@@ -70,6 +72,7 @@ private:
case FILTER_SENSOR: return (adv_type != ADV_TYPE_CHAT &&
adv_type != ADV_TYPE_REPEATER &&
adv_type != ADV_TYPE_ROOM);
case FILTER_FAVOURITE: return (flags & 0x01) != 0;
default: return true;
}
}
@@ -78,9 +81,9 @@ private:
_filteredCount = 0;
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo contact;
for (uint32_t i = 0; i < numContacts && _filteredCount < MAX_VISIBLE; i++) {
for (uint32_t i = 0; i < numContacts && _filteredCount < MAX_CONTACTS; i++) {
if (the_mesh.getContactByIdx(i, contact)) {
if (matchesFilter(contact.type)) {
if (matchesFilter(contact.type, contact.flags)) {
_filteredIdx[_filteredCount] = (uint16_t)i;
_filteredTs[_filteredCount] = contact.last_advert_timestamp;
_filteredCount++;
@@ -88,7 +91,7 @@ private:
}
}
// Sort by last_advert_timestamp descending (most recently seen first)
// Simple insertion sort — fine for up to 400 entries on ESP32
// Insertion sort fine for up to ~1000 entries on ESP32
for (int i = 1; i < _filteredCount; i++) {
uint16_t tmpIdx = _filteredIdx[i];
uint32_t tmpTs = _filteredTs[i];
@@ -130,7 +133,15 @@ private:
public:
ContactsScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _scrollPos(0), _filter(FILTER_ALL),
_filteredCount(0), _cacheValid(false), _rowsPerPage(5) {}
_filteredCount(0), _cacheValid(false), _rowsPerPage(5) {
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
_filteredIdx = (uint16_t*)ps_calloc(MAX_CONTACTS, sizeof(uint16_t));
_filteredTs = (uint32_t*)ps_calloc(MAX_CONTACTS, sizeof(uint32_t));
#else
_filteredIdx = new uint16_t[MAX_CONTACTS]();
_filteredTs = new uint32_t[MAX_CONTACTS]();
#endif
}
void invalidateCache() { _cacheValid = false; }
@@ -141,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 {
@@ -208,7 +244,11 @@ public:
display.setCursor(0, y);
display.print("No contacts");
display.setCursor(0, y + lineHeight);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe to change filter");
#else
display.print("A/D: Change filter");
#endif
} else {
// Center visible window around selected item (TextReaderScreen pattern)
int maxVisible = (maxY - headerHeight) / lineHeight;
@@ -226,7 +266,11 @@ public:
// Highlight: fill LIGHT rect first, then draw DARK text on top
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);
@@ -286,19 +330,22 @@ public:
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
// Left: Q:Back
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setCursor(0, footerY);
display.print("Q:Back");
// Center: A/D:Filter
const char* mid = "A/D:Filtr";
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
display.print(mid);
// Right: W/S:Scroll
const char* right = "W/S:Scrll";
display.print("Swipe:Filter");
const char* right = "Hold:DM/Admin";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#else
// Left: Q:Bk
display.setCursor(0, footerY);
display.print("Q:Bk A/D:Filter");
// Right: Tap/Ent:Select
const char* right = "Tap/Ent:Select";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#endif
return 5000; // e-ink: next render after 5s
}

View File

@@ -0,0 +1,247 @@
#pragma once
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/AdvertDataHelpers.h>
#include <MeshCore.h>
// Forward declarations
class UITask;
class MyMesh;
extern MyMesh the_mesh;
class DiscoveryScreen : public UIScreen {
UITask* _task;
mesh::RTCClock* _rtc;
int _scrollPos;
int _rowsPerPage;
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 '?';
}
}
static const char* typeLabel(uint8_t adv_type) {
switch (adv_type) {
case ADV_TYPE_CHAT: return "Chat";
case ADV_TYPE_REPEATER: return "Rptr";
case ADV_TYPE_ROOM: return "Room";
case ADV_TYPE_SENSOR: return "Sens";
default: return "?";
}
}
public:
DiscoveryScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _scrollPos(0), _rowsPerPage(5) {}
void resetScroll() { _scrollPos = 0; }
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();
// === Header ===
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
char hdr[32];
if (active) {
snprintf(hdr, sizeof(hdr), "Scanning... %d found", count);
} else {
snprintf(hdr, sizeof(hdr), "Scan done: %d found", count);
}
display.print(hdr);
// Divider
display.drawRect(0, 11, display.width(), 1);
// === Body — discovered node rows ===
display.setTextSize(0); // tiny font for compact rows
int lineHeight = 9;
int headerHeight = 14;
int footerHeight = 14;
int maxY = display.height() - footerHeight;
int y = headerHeight;
int rowsDrawn = 0;
if (count == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(4, 28);
display.print(active ? "Listening for adverts..." : "No nodes found");
if (!active) {
display.setCursor(4, 38);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Long press: Rescan");
#else
display.print("F: Scan again Q: Back");
#endif
}
} else {
// Center visible window around selected item
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 DiscoveredNode& node = the_mesh.getDiscovered(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 prefix[4];
if (selected) {
snprintf(prefix, sizeof(prefix), ">%c", typeChar(node.contact.type));
} else {
snprintf(prefix, sizeof(prefix), " %c", typeChar(node.contact.type));
}
display.print(prefix);
// Build right-side info: SNR or hop count + status
char rightStr[16];
if (node.snr != 0) {
// Active discovery result — show SNR in dB (value is ×4 scaled)
int snr_db = node.snr / 4;
if (node.already_in_contacts) {
snprintf(rightStr, sizeof(rightStr), "%ddB [+]", snr_db);
} else {
snprintf(rightStr, sizeof(rightStr), "%ddB", snr_db);
}
} else {
// Pre-seeded from cache — show hop count
if (node.already_in_contacts) {
snprintf(rightStr, sizeof(rightStr), "%dh [+]", node.path_len & 63);
} else {
snprintf(rightStr, sizeof(rightStr), "%dh", node.path_len & 63);
}
}
int rightWidth = display.getTextWidth(rightStr) + 2;
// Name (truncated with ellipsis)
char filteredName[32];
display.translateUTF8ToBlocks(filteredName, node.contact.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;
rowsDrawn++;
}
_rowsPerPage = (rowsDrawn > 0) ? rowsDrawn : 1;
}
display.setTextSize(1); // restore for footer
// === 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* mid = "Tap:Add";
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
display.print(mid);
const char* right = "Hold:Rescan";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#else
display.print("Q:Bk F:Rescan");
const char* right = "Tap/Ent:Add";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#endif
// Faster refresh while actively scanning
return active ? 1000 : 5000;
}
bool handleInput(char c) override {
int count = the_mesh.getDiscoveredCount();
// W - scroll up
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_scrollPos > 0) {
_scrollPos--;
return true;
}
}
// S - scroll down
if (c == 's' || c == 'S' || c == 0xF1) {
if (_scrollPos < count - 1) {
_scrollPos++;
return true;
}
}
// F - rescan (handled here as well as in main.cpp for consistency)
if (c == 'f') {
the_mesh.startDiscovery();
_scrollPos = 0;
return true;
}
// Enter - handled by main.cpp for alert feedback
return false; // Q/back and Enter handled by main.cpp
}
};

View File

@@ -3,7 +3,7 @@
// Emoji sprites for e-ink display - dual size
// Large (12x12) for compose/picker, Small (10x10) for channel view
// MSB-first, 2 bytes per row
// 46 total emoji: joy/thumbsup/frown first, then 43 original (telephone removed)
// 65 total emoji: joy/thumbsup/frown first, then 43 original, then 19 new
#include <stdint.h>
#ifdef ESP32
@@ -15,11 +15,11 @@
#define EMOJI_SM_W 10
#define EMOJI_SM_H 10
#define EMOJI_COUNT 46
#define EMOJI_COUNT 65
// Escape codes in 0x80+ range - safe from keyboard ASCII (32-126)
#define EMOJI_ESCAPE_START 0x80
#define EMOJI_ESCAPE_END 0xAD // 0x80 + 45
#define EMOJI_ESCAPE_END 0xC0 // 0x80 + 64
#define EMOJI_PAD_BYTE 0x7F // DEL, not typeable (key < 127 guard)
// ======== LARGE 12x12 SPRITES ========
@@ -208,6 +208,82 @@ static const uint8_t emoji_lg_peach[] PROGMEM = {
static const uint8_t emoji_lg_racing_car[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0x00,0x00, 0x07,0x80, 0x0F,0xC0, 0x7F,0xE0, 0xFF,0xF0, 0xFF,0xF0, 0x6F,0x60, 0x49,0x20, 0x00,0x00, 0x00,0x00,
};
// [46] mouse 🐭
static const uint8_t emoji_lg_mouse[] PROGMEM = {
0x30,0xC0, 0x79,0xE0, 0x79,0xE0, 0x3F,0xC0, 0x49,0x20, 0x80,0x10, 0x86,0x10, 0x89,0x10, 0x40,0x20, 0x20,0x40, 0x1F,0x80, 0x00,0x00,
};
// [47] mushroom 🍄
static const uint8_t emoji_lg_mushroom[] PROGMEM = {
0x1F,0x80, 0x3F,0xC0, 0x7F,0xE0, 0xE6,0x70, 0xE6,0x70, 0x7F,0xE0, 0x3F,0xC0, 0x0F,0x00, 0x0F,0x00, 0x0F,0x00, 0x1F,0x80, 0x00,0x00,
};
// [48] biohazard ☣️
static const uint8_t emoji_lg_biohazard[] PROGMEM = {
0x0F,0x00, 0x1F,0x80, 0x3F,0xC0, 0x1F,0x80, 0x0F,0x00, 0x66,0x60, 0x76,0xE0, 0x70,0xE0, 0x79,0xE0, 0x39,0xC0, 0x19,0x80, 0x00,0x00,
};
// [49] panda 🐼
static const uint8_t emoji_lg_panda[] PROGMEM = {
0x00,0x00, 0x60,0x60, 0xF0,0xF0, 0xF0,0xF0, 0x7F,0xE0, 0x59,0xA0, 0x59,0xA0, 0x40,0x20, 0x46,0x20, 0x20,0x40, 0x1F,0x80, 0x00,0x00,
};
// [50] anger 💢
static const uint8_t emoji_lg_anger[] PROGMEM = {
0x00,0x00, 0x3C,0xC0, 0x3C,0xC0, 0x30,0xC0, 0x30,0x00, 0x00,0x00, 0x00,0x00, 0x00,0xC0, 0x30,0xC0, 0x33,0xC0, 0x33,0xC0, 0x00,0x00,
};
// [51] dragon_face 🐲
static const uint8_t emoji_lg_dragon_face[] PROGMEM = {
0xC0,0x30, 0xE0,0x70, 0x76,0xE0, 0x3F,0xC0, 0x69,0x60, 0x40,0x20, 0x4F,0x20, 0x29,0x40, 0x30,0xC0, 0x1F,0x80, 0x00,0x00, 0x00,0x00,
};
// [52] pager 📟
static const uint8_t emoji_lg_pager[] PROGMEM = {
0x00,0x00, 0x7F,0xE0, 0x40,0x20, 0x5F,0xA0, 0x5F,0xA0, 0x40,0x20, 0x5B,0x20, 0x5B,0x20, 0x40,0x20, 0x7F,0xE0, 0x00,0x00, 0x00,0x00,
};
// [53] bee 🐝
static const uint8_t emoji_lg_bee[] PROGMEM = {
0x00,0x00, 0x19,0x80, 0x19,0x80, 0x3F,0x80, 0x7F,0xC0, 0x7F,0xE0, 0x7F,0xE0, 0x7F,0xC0, 0x3F,0x80, 0x1F,0x40, 0x0A,0x00, 0x00,0x00,
};
// [54] bulb 💡
static const uint8_t emoji_lg_bulb[] PROGMEM = {
0x1F,0x80, 0x20,0x40, 0x40,0x20, 0x40,0x20, 0x40,0x20, 0x20,0x40, 0x30,0xC0, 0x1F,0x80, 0x16,0x80, 0x1F,0x80, 0x0F,0x00, 0x00,0x00,
};
// [55] cat 🐱
static const uint8_t emoji_lg_cat[] PROGMEM = {
0x40,0x20, 0x60,0x60, 0x70,0xE0, 0x3F,0xC0, 0x59,0xA0, 0x40,0x20, 0x40,0x20, 0x46,0x20, 0x29,0x40, 0x30,0xC0, 0x1F,0x80, 0x00,0x00,
};
// [56] fleur ⚜️
static const uint8_t emoji_lg_fleur[] PROGMEM = {
0x06,0x00, 0x06,0x00, 0x0F,0x00, 0x6F,0x60, 0xF6,0xF0, 0xF6,0xF0, 0x76,0xE0, 0x3F,0xC0, 0x1F,0x80, 0x0F,0x00, 0x19,0x80, 0x00,0x00,
};
// [57] moon 🌔
static const uint8_t emoji_lg_moon[] PROGMEM = {
0x1F,0x80, 0x3F,0xC0, 0x7F,0xE0, 0x7F,0x80, 0xFF,0x80, 0xFF,0x00, 0xFF,0x00, 0xFF,0x80, 0x7F,0x80, 0x7F,0xE0, 0x3F,0xC0, 0x1F,0x80,
};
// [58] coffee ☕
static const uint8_t emoji_lg_coffee[] PROGMEM = {
0x24,0x80, 0x12,0x40, 0x00,0x00, 0x7F,0xC0, 0x40,0x70, 0x40,0x50, 0x40,0x50, 0x40,0x70, 0x7F,0xC0, 0x00,0x00, 0xFF,0xC0, 0x00,0x00,
};
// [59] tooth 🦷
static const uint8_t emoji_lg_tooth[] PROGMEM = {
0x3F,0xC0, 0x7F,0xE0, 0xFF,0xF0, 0xFF,0xF0, 0xFF,0xF0, 0x7F,0xE0, 0x3F,0xC0, 0x3F,0xC0, 0x39,0xC0, 0x39,0xC0, 0x30,0xC0, 0x20,0x40,
};
// [60] pretzel 🥨
static const uint8_t emoji_lg_pretzel[] PROGMEM = {
0x39,0xC0, 0x46,0x20, 0x80,0x20, 0x86,0x10, 0x49,0x20, 0x30,0xC0, 0x30,0xC0, 0x49,0x20, 0x86,0x10, 0x80,0x10, 0x46,0x20, 0x39,0xC0,
};
// [61] abacus 🧮
static const uint8_t emoji_lg_abacus[] PROGMEM = {
0xFF,0xF0, 0x80,0x10, 0xB6,0x50, 0x80,0x10, 0xA6,0x90, 0x80,0x10, 0x94,0xD0, 0x80,0x10, 0xB2,0x50, 0x80,0x10, 0xFF,0xF0, 0x00,0x00,
};
// [62] moai 🗿
static const uint8_t emoji_lg_moai[] PROGMEM = {
0x3F,0xC0, 0x7F,0xC0, 0x7F,0xC0, 0x39,0xC0, 0x39,0xC0, 0x3F,0xC0, 0x27,0x40, 0x3F,0x80, 0x2F,0x00, 0x3F,0x00, 0x3F,0x00, 0x1E,0x00,
};
// [63] tipping 💁
static const uint8_t emoji_lg_tipping[] PROGMEM = {
0x1E,0x00, 0x3F,0x00, 0x3F,0x00, 0x1E,0x00, 0x0C,0x00, 0x1E,0x00, 0x3F,0x00, 0x7F,0x80, 0x0C,0xE0, 0x0D,0xE0, 0x12,0xE0, 0x33,0x00,
};
// [64] hedgehog 🦔
static const uint8_t emoji_lg_hedgehog[] PROGMEM = {
0x00,0x00, 0x0A,0x80, 0x15,0x40, 0x2A,0xA0, 0x55,0x60, 0x7E,0xF0, 0xDB,0x90, 0xFF,0xD0, 0x7F,0xE0, 0x3F,0xC0, 0x24,0x80, 0x00,0x00,
};
static const uint8_t* const EMOJI_SPRITES_LG[] PROGMEM = {
emoji_lg_joy, emoji_lg_thumbsup, emoji_lg_frown,
@@ -220,6 +296,11 @@ static const uint8_t* const EMOJI_SPRITES_LG[] PROGMEM = {
emoji_lg_kangaroo, emoji_lg_feather, emoji_lg_bright, emoji_lg_part_alt, emoji_lg_motorboat,
emoji_lg_domino, emoji_lg_satellite, emoji_lg_customs, emoji_lg_cowboy, emoji_lg_wheel,
emoji_lg_koala, emoji_lg_control_knobs, emoji_lg_peach, emoji_lg_racing_car,
emoji_lg_mouse, emoji_lg_mushroom, emoji_lg_biohazard, emoji_lg_panda,
emoji_lg_anger, emoji_lg_dragon_face, emoji_lg_pager, emoji_lg_bee,
emoji_lg_bulb, emoji_lg_cat, emoji_lg_fleur, emoji_lg_moon,
emoji_lg_coffee, emoji_lg_tooth, emoji_lg_pretzel, emoji_lg_abacus,
emoji_lg_moai, emoji_lg_tipping, emoji_lg_hedgehog,
};
// ======== SMALL 10x10 SPRITES ========
@@ -362,6 +443,82 @@ static const uint8_t emoji_sm_peach[] PROGMEM = {
static const uint8_t emoji_sm_racing_car[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0x0E,0x00, 0x1F,0x00, 0x7F,0x80, 0xFF,0xC0, 0xFF,0xC0, 0x5E,0x80, 0x00,0x00, 0x00,0x00,
};
// [46] mouse 🐭
static const uint8_t emoji_sm_mouse[] PROGMEM = {
0x61,0x80, 0xF3,0xC0, 0x7F,0x80, 0x92,0x40, 0x80,0x40, 0x8C,0x40, 0x52,0x80, 0x40,0x80, 0x3F,0x00, 0x00,0x00,
};
// [47] mushroom 🍄
static const uint8_t emoji_sm_mushroom[] PROGMEM = {
0x3F,0x00, 0x7F,0x80, 0xED,0xC0, 0xED,0xC0, 0x7F,0x80, 0x3F,0x00, 0x1E,0x00, 0x1E,0x00, 0x3F,0x00, 0x00,0x00,
};
// [48] biohazard ☣️
static const uint8_t emoji_sm_biohazard[] PROGMEM = {
0x1E,0x00, 0x3F,0x00, 0x3F,0x00, 0x1E,0x00, 0x6D,0x80, 0x73,0x80, 0x73,0x80, 0x7B,0x80, 0x33,0x00, 0x00,0x00,
};
// [49] panda 🐼
static const uint8_t emoji_sm_panda[] PROGMEM = {
0xC0,0xC0, 0xF3,0xC0, 0x7F,0x80, 0xB3,0x40, 0xB3,0x40, 0x80,0x40, 0x4C,0x80, 0x21,0x00, 0x3F,0x00, 0x00,0x00,
};
// [50] anger 💢
static const uint8_t emoji_sm_anger[] PROGMEM = {
0x00,0x00, 0x73,0x00, 0x73,0x00, 0x63,0x00, 0x60,0x00, 0x01,0x80, 0x63,0x00, 0x67,0x00, 0x67,0x00, 0x00,0x00,
};
// [51] dragon_face 🐲
static const uint8_t emoji_sm_dragon_face[] PROGMEM = {
0xC0,0xC0, 0xED,0xC0, 0x7F,0x80, 0x52,0x80, 0x40,0x80, 0x4C,0x80, 0x33,0x00, 0x2D,0x00, 0x1E,0x00, 0x00,0x00,
};
// [52] pager 📟
static const uint8_t emoji_sm_pager[] PROGMEM = {
0x00,0x00, 0x7F,0x80, 0x40,0x80, 0x5E,0x80, 0x40,0x80, 0x5A,0x80, 0x5A,0x80, 0x40,0x80, 0x7F,0x80, 0x00,0x00,
};
// [53] bee 🐝
static const uint8_t emoji_sm_bee[] PROGMEM = {
0x33,0x00, 0x33,0x00, 0x7F,0x00, 0xFF,0x80, 0xFF,0xC0, 0xFF,0x80, 0x7F,0x00, 0x3E,0x80, 0x14,0x00, 0x00,0x00,
};
// [54] bulb 💡
static const uint8_t emoji_sm_bulb[] PROGMEM = {
0x3F,0x00, 0x40,0x80, 0x80,0x40, 0x80,0x40, 0x40,0x80, 0x33,0x00, 0x3F,0x00, 0x1E,0x00, 0x1E,0x00, 0x00,0x00,
};
// [55] cat 🐱
static const uint8_t emoji_sm_cat[] PROGMEM = {
0x80,0x40, 0xC0,0xC0, 0x7F,0x80, 0xB3,0x40, 0x80,0x40, 0x8C,0x40, 0x52,0x80, 0x61,0x80, 0x3F,0x00, 0x00,0x00,
};
// [56] fleur ⚜️
static const uint8_t emoji_sm_fleur[] PROGMEM = {
0x0C,0x00, 0x0C,0x00, 0x6D,0x80, 0xED,0xC0, 0xED,0xC0, 0x6D,0x80, 0x3F,0x00, 0x1E,0x00, 0x33,0x00, 0x00,0x00,
};
// [57] moon 🌔
static const uint8_t emoji_sm_moon[] PROGMEM = {
0x3F,0x00, 0x7F,0x80, 0xFF,0x80, 0xFE,0x00, 0xFE,0x00, 0xFE,0x00, 0xFE,0x00, 0xFF,0x80, 0x7F,0x80, 0x3F,0x00,
};
// [58] coffee ☕
static const uint8_t emoji_sm_coffee[] PROGMEM = {
0x49,0x00, 0x24,0x80, 0x00,0x00, 0xFF,0x00, 0x81,0xC0, 0x81,0x40, 0x81,0xC0, 0xFF,0x00, 0x00,0x00, 0xFE,0x00,
};
// [59] tooth 🦷
static const uint8_t emoji_sm_tooth[] PROGMEM = {
0x7F,0x80, 0xFF,0xC0, 0xFF,0xC0, 0xFF,0xC0, 0x7F,0x80, 0x3F,0x00, 0x3B,0x80, 0x31,0x80, 0x20,0x80, 0x00,0x00,
};
// [60] pretzel 🥨
static const uint8_t emoji_sm_pretzel[] PROGMEM = {
0x73,0x80, 0x9E,0x40, 0x8C,0x40, 0x52,0x80, 0x33,0x00, 0x33,0x00, 0x52,0x80, 0x8C,0x40, 0x9E,0x40, 0x73,0x80,
};
// [61] abacus 🧮
static const uint8_t emoji_sm_abacus[] PROGMEM = {
0xFF,0xC0, 0x80,0x40, 0xB5,0x40, 0x80,0x40, 0xAD,0x40, 0x80,0x40, 0xAB,0x40, 0x80,0x40, 0xFF,0xC0, 0x00,0x00,
};
// [62] moai 🗿
static const uint8_t emoji_sm_moai[] PROGMEM = {
0x7F,0x00, 0x7F,0x00, 0x33,0x00, 0x33,0x00, 0x3F,0x00, 0x2E,0x00, 0x3E,0x00, 0x3E,0x00, 0x3E,0x00, 0x1C,0x00,
};
// [63] tipping 💁
static const uint8_t emoji_sm_tipping[] PROGMEM = {
0x3C,0x00, 0x7E,0x00, 0x7E,0x00, 0x3C,0x00, 0x18,0x00, 0x3C,0x00, 0x7E,0x00, 0x1B,0x80, 0x1B,0x80, 0x36,0x00,
};
// [64] hedgehog 🦔
static const uint8_t emoji_sm_hedgehog[] PROGMEM = {
0x15,0x00, 0x2A,0x80, 0x55,0x40, 0xFF,0xC0, 0xDB,0x40, 0xFF,0x80, 0x7F,0x80, 0x3F,0x00, 0x24,0x00, 0x00,0x00,
};
static const uint8_t* const EMOJI_SPRITES_SM[] PROGMEM = {
emoji_sm_joy, emoji_sm_thumbsup, emoji_sm_frown,
@@ -374,6 +531,11 @@ static const uint8_t* const EMOJI_SPRITES_SM[] PROGMEM = {
emoji_sm_kangaroo, emoji_sm_feather, emoji_sm_bright, emoji_sm_part_alt, emoji_sm_motorboat,
emoji_sm_domino, emoji_sm_satellite, emoji_sm_customs, emoji_sm_cowboy, emoji_sm_wheel,
emoji_sm_koala, emoji_sm_control_knobs, emoji_sm_peach, emoji_sm_racing_car,
emoji_sm_mouse, emoji_sm_mushroom, emoji_sm_biohazard, emoji_sm_panda,
emoji_sm_anger, emoji_sm_dragon_face, emoji_sm_pager, emoji_sm_bee,
emoji_sm_bulb, emoji_sm_cat, emoji_sm_fleur, emoji_sm_moon,
emoji_sm_coffee, emoji_sm_tooth, emoji_sm_pretzel, emoji_sm_abacus,
emoji_sm_moai, emoji_sm_tipping, emoji_sm_hedgehog,
};
// ---- Codepoint lookup for UTF-8 conversion ----
@@ -426,10 +588,37 @@ static const EmojiCodepoint EMOJI_CODEPOINTS[EMOJI_COUNT] = {
{ 0x1F39B, 0x0000, 0xAB }, // control_knobs
{ 0x1F351, 0x0000, 0xAC }, // peach
{ 0x1F3CE, 0x0000, 0xAD }, // racing_car
{ 0x1F42D, 0x0000, 0xAE }, // mouse
{ 0x1F344, 0x0000, 0xAF }, // mushroom
{ 0x2623, 0x0000, 0xB0 }, // biohazard
{ 0x1F43C, 0x0000, 0xB1 }, // panda
{ 0x1F4A2, 0x0000, 0xB2 }, // anger
{ 0x1F432, 0x0000, 0xB3 }, // dragon_face
{ 0x1F4DF, 0x0000, 0xB4 }, // pager
{ 0x1F41D, 0x0000, 0xB5 }, // bee
{ 0x1F4A1, 0x0000, 0xB6 }, // bulb
{ 0x1F431, 0x0000, 0xB7 }, // cat
{ 0x269C, 0x0000, 0xB8 }, // fleur
{ 0x1F314, 0x0000, 0xB9 }, // moon
{ 0x2615, 0x0000, 0xBA }, // coffee
{ 0x1F9B7, 0x0000, 0xBB }, // tooth
{ 0x1F968, 0x0000, 0xBC }, // pretzel
{ 0x1F9EE, 0x0000, 0xBD }, // abacus
{ 0x1F5FF, 0x0000, 0xBE }, // moai
{ 0x1F481, 0x0000, 0xBF }, // tipping
{ 0x1F994, 0x0000, 0xC0 }, // hedgehog
};
// ---- Helper functions ----
// Alias table: extra codepoints that map to existing emoji escape bytes.
// Used for variant codepoints (e.g. MWD node identifier 🂎 U+1F08E -> domino sprite)
struct EmojiAlias { uint32_t cp; uint8_t escape; };
#define EMOJI_ALIAS_COUNT 1
static const EmojiAlias EMOJI_ALIASES[EMOJI_ALIAS_COUNT] = {
{ 0x1F08E, 0xA5 }, // domino tile (MWD node signifier) -> domino sprite
};
static uint32_t emojiDecodeUtf8(const uint8_t* s, int remaining, int* bytes_consumed) {
uint8_t b0 = s[0];
if (b0 < 0x80) { *bytes_consumed = 1; return b0; }
@@ -483,6 +672,18 @@ static void emojiSanitize(const char* src, char* dst, int dstLen) {
found = true; break;
}
}
if (!found) {
// Check alias table for variant codepoints
for (int a = 0; a < EMOJI_ALIAS_COUNT; a++) {
if (EMOJI_ALIASES[a].cp == cp) {
dst[di++] = EMOJI_ALIASES[a].escape;
si += consumed;
// Skip trailing variation selector U+FE0F
if (si + 2 < srcLen && s[si] == 0xEF && s[si+1] == 0xB8 && s[si+2] == 0x8F) si += 3;
found = true; break;
}
}
}
if (!found) si += consumed; // Skip unknown multi-byte chars
} else {
dst[di++] = (char)b;

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

@@ -0,0 +1,916 @@
#pragma once
// =============================================================================
// MapScreen — OSM Tile Map for T-Deck Pro E-Ink Display
// =============================================================================
//
// Renders standard OSM "slippy map" PNG tiles from SD card onto the e-ink
// display at native 240×320 resolution (bypassing the 128×128 logical grid).
//
// Tiles are B&W PNGs stored at /tiles/{zoom}/{x}/{y}.png — the same format
// used by Ripple, tdeck-maps, and MTD-Script tile downloaders.
//
// REQUIREMENTS:
// 1. Add PNGdec library to platformio.ini:
// lib_deps = ... bitbank2/PNGdec@^1.0.1
//
// 2. Add raw display access to GxEPDDisplay.h (public section):
// // --- Raw pixel access for MapScreen (bypasses scaling) ---
// void drawPixelRaw(int16_t x, int16_t y, uint16_t color) {
// display.drawPixel(x, y, color);
// }
// int16_t rawWidth() { return display.width(); }
// int16_t rawHeight() { return display.height(); }
// // Force endFrame() to push to display even if CRC unchanged
// // (needed because drawPixelRaw bypasses CRC tracking)
// void invalidateFrameCRC() { last_display_crc_value = 0; }
//
// 3. Add to UITask.h:
// #include "MapScreen.h"
// UIScreen* map_screen;
// void gotoMapScreen();
// bool isOnMapScreen() const { return curr == map_screen; }
// UIScreen* getMapScreen() const { return map_screen; }
//
// 4. Initialise in UITask::begin():
// map_screen = new MapScreen(this);
//
// 5. Implement UITask::gotoMapScreen() following gotoTextReader() pattern.
//
// 6. Hook 'g' key in main.cpp for GPS/Map access:
// case 'g':
// if (ui_task.isOnMapScreen()) {
// // Already on map — 'g' re-centers on GPS
// ui_task.injectKey('g');
// } else {
// Serial.println("Opening map");
// {
// MapScreen* ms = (MapScreen*)ui_task.getMapScreen();
// if (ms) {
// ms->setSDReady(sdCardReady);
// ms->setGPSPosition(sensors.node_lat,
// sensors.node_lon);
// // Populate contact markers via iterator
// ms->clearMarkers();
// ContactsIterator it = the_mesh.startContactsIterator();
// ContactInfo ci;
// while (it.hasNext(&the_mesh, ci)) {
// double lat = ((double)ci.gps_lat) / 1000000.0;
// double lon = ((double)ci.gps_lon) / 1000000.0;
// ms->addMarker(lat, lon, ci.name, ci.type);
// }
// }
// }
// ui_task.gotoMapScreen();
// }
// break;
//
// 7. Route WASD/zoom keys to map screen in main.cpp (in existing handlers):
// For 'w', 's', 'a', 'd' cases, add:
// if (ui_task.isOnMapScreen()) { ui_task.injectKey(key); break; }
// For the default case, add map screen passthrough:
// if (ui_task.isOnMapScreen()) { ui_task.injectKey(key); break; }
// This covers +, -, i, o, g (re-center) keys too.
//
// TILE SOURCES (B&W recommended for e-ink):
// - MTD-Script: github.com/fistulareffigy/MTD-Script
// - tdeck-maps: github.com/JustDr00py/tdeck-maps
// - Stamen Toner style gives best e-ink contrast
// =============================================================================
#include <Arduino.h>
#include <SD.h>
#include <PNGdec.h>
#undef local // PNGdec's zutil.h defines 'local' as 'static' — breaks any variable named 'local'
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/GxEPDDisplay.h>
// ---------------------------------------------------------------------------
// Layout constants (physical pixel coordinates, 240×320 display)
// ---------------------------------------------------------------------------
#define MAP_DISPLAY_W 240
#define MAP_DISPLAY_H 320
// Footer bar occupies the bottom — matches other screens' setTextSize(1) footer
#define MAP_FOOTER_H 24 // ~24px at bottom for nav hints
#define MAP_VIEWPORT_Y 0 // Map starts at top
#define MAP_VIEWPORT_H (MAP_DISPLAY_H - MAP_FOOTER_H) // 296px for map
#define MAP_TILE_SIZE 256 // Standard OSM tile size in pixels
#define MAP_DEFAULT_ZOOM 13
#define MAP_MIN_ZOOM 1
#define MAP_MAX_ZOOM 17
// PNG decode buffer size — 256×256 RGB = 196KB, but PNGdec streams row-by-row
// We only need a line buffer. Allocate in PSRAM for safety.
#define MAP_PNG_BUF_SIZE (65536) // 64KB for PNG file read buffer
// Tile path on SD card
#define MAP_TILE_ROOT "/tiles"
// Contact type (for label display — matches AdvertDataHelpers.h)
#ifndef ADV_TYPE_REPEATER
#define ADV_TYPE_REPEATER 2
#endif
// Pan step: fraction of viewport to move per keypress
#define MAP_PAN_FRACTION 4 // 1/4 of viewport per press
// Max contact markers (PSRAM-allocated, ~37 bytes each)
#define MAP_MAX_MARKERS 500
class MapScreen : public UIScreen {
public:
MapScreen(UITask* task)
: _task(task),
_einkDisplay(nullptr),
_sdReady(false),
_needsRedraw(true),
_hasFix(false),
_centerLat(-33.8688), // Default: Sydney (most Ripple users)
_centerLon(151.2093),
_gpsLat(0.0),
_gpsLon(0.0),
_zoom(MAP_DEFAULT_ZOOM),
_zoomMin(MAP_MIN_ZOOM),
_zoomMax(MAP_MAX_ZOOM),
_pngBuf(nullptr),
_lineBuf(nullptr),
_tileFound(false)
{
// 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; }
}
void setSDReady(bool ready) { _sdReady = ready; }
// Set initial GPS position (called when opening map — centers viewport)
void setGPSPosition(double lat, double lon) {
if (lat != 0.0 || lon != 0.0) {
_gpsLat = lat;
_gpsLon = lon;
_centerLat = lat;
_centerLon = lon;
_hasFix = true;
_needsRedraw = true;
}
}
// Update own GPS position without moving viewport (called periodically)
void updateGPSPosition(double lat, double lon) {
if (lat == 0.0 && lon == 0.0) return;
if (lat != _gpsLat || lon != _gpsLon) {
_gpsLat = lat;
_gpsLon = lon;
_hasFix = true;
_needsRedraw = true; // Redraw to move own-position marker
}
}
// 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) {
// 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;
_markers[_numMarkers].type = type;
strncpy(_markers[_numMarkers].name, name, sizeof(_markers[0].name) - 1);
_markers[_numMarkers].name[sizeof(_markers[0].name) - 1] = '\0';
_numMarkers++;
}
// Refresh contact markers (called periodically from main loop)
// Clears and rebuilds — caller iterates contacts and calls addMarker()
int getNumMarkers() const { return _numMarkers; }
// Called when navigating to map screen
void enter(DisplayDriver& display) {
_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);
if (!_pngBuf) {
Serial.println("MapScreen: PSRAM alloc failed, trying heap");
_pngBuf = (uint8_t*)malloc(MAP_PNG_BUF_SIZE);
}
if (_pngBuf) {
Serial.printf("MapScreen: PNG buffer allocated (%d bytes)\n", MAP_PNG_BUF_SIZE);
} else {
Serial.println("MapScreen: PNG buffer alloc FAILED");
}
}
// 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();
}
// ---- UIScreen interface ----
int render(DisplayDriver& display) override {
if (!_einkDisplay) {
_einkDisplay = static_cast<GxEPDDisplay*>(&display);
}
if (!_sdReady) {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(10, 20);
display.print("SD card not found");
display.setCursor(10, 35);
display.print("Insert SD with");
display.setCursor(10, 48);
display.print("/tiles/{z}/{x}/{y}.png");
return 5000;
}
// Always render tiles — UITask clears the buffer via startFrame() before
// calling us, so we must redraw every time (e.g. after alert overlays)
bool wasRedraw = _needsRedraw;
_needsRedraw = false;
// Render map tiles into the viewport
renderMapViewport();
// Overlay contact markers
renderContactMarkers();
// Crosshair at viewport center
renderCrosshair();
// Footer bar (uses normal display API with scaling)
renderFooter(display);
// Raw pixel writes bypass CRC tracking — force refresh
_einkDisplay->invalidateFrameCRC();
// If user panned/zoomed, allow quick re-render; otherwise idle longer
return wasRedraw ? 1000 : 30000;
}
bool handleInput(char c) override {
// Pan distances in degrees — adaptive to zoom level
// At zoom Z, one tile covers 360/2^Z degrees of longitude
double tileLonSpan = 360.0 / (1 << _zoom);
double tileLatSpan = tileLonSpan * cos(_centerLat * PI / 180.0); // Rough approx
// Pan by 1/MAP_PAN_FRACTION of viewport (viewport ≈ 1 tile)
double panLon = tileLonSpan / MAP_PAN_FRACTION;
double panLat = tileLatSpan / MAP_PAN_FRACTION;
switch (c) {
// ---- WASD panning ----
case 'w':
case 'W':
_centerLat += panLat;
if (_centerLat > 85.05) _centerLat = 85.05; // Web Mercator limit
_needsRedraw = true;
return true;
case 's':
case 'S':
_centerLat -= panLat;
if (_centerLat < -85.05) _centerLat = -85.05;
_needsRedraw = true;
return true;
case 'a':
case 'A':
_centerLon -= panLon;
if (_centerLon < -180.0) _centerLon += 360.0;
_needsRedraw = true;
return true;
case 'd':
case 'D':
_centerLon += panLon;
if (_centerLon > 180.0) _centerLon -= 360.0;
_needsRedraw = true;
return true;
// ---- Zoom controls ----
case 'z':
case 'Z':
if (_zoom < _zoomMax) {
_zoom++;
_needsRedraw = true;
Serial.printf("MapScreen: zoom in -> %d\n", _zoom);
}
return true;
case 'x':
case 'X':
if (_zoom > _zoomMin) {
_zoom--;
_needsRedraw = true;
Serial.printf("MapScreen: zoom out -> %d\n", _zoom);
}
return true;
// ---- Re-center on GPS fix ----
case 'g':
if (_hasFix) {
_centerLat = _gpsLat;
_centerLon = _gpsLon;
_needsRedraw = true;
Serial.println("MapScreen: re-center on GPS");
}
return true;
default:
return false;
}
}
private:
UITask* _task;
GxEPDDisplay* _einkDisplay;
bool _sdReady;
bool _needsRedraw;
bool _hasFix;
// Map state
double _centerLat;
double _centerLon;
double _gpsLat; // Own GPS position (separate from viewport center)
double _gpsLon;
int _zoom;
int _zoomMin; // Detected from SD card
int _zoomMax; // Detected from SD card
// 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
PNG _png;
// Contacts for marker overlay
struct MapMarker {
double lat;
double lon;
char name[20]; // Truncated display name
uint8_t type; // ADV_TYPE_CHAT, ADV_TYPE_REPEATER, etc.
};
MapMarker* _markers = nullptr; // PSRAM-allocated
int _numMarkers = 0;
// ---- Rendering state passed to PNG callback ----
// PNGdec calls our callback per scanline — we need to know where to draw.
// Also carries a PNG* so the static callback can call getLineAsRGB565().
struct DrawContext {
GxEPDDisplay* display;
PNG* png; // Pointer to the decoder (for getLineAsRGB565)
int offsetX; // Screen X offset for this tile
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;
// ==========================================================================
// Detect available zoom levels from /tiles/{z}/ directories on SD
// ==========================================================================
void detectZoomRange() {
if (!_sdReady) return;
_zoomMin = MAP_MAX_ZOOM;
_zoomMax = MAP_MIN_ZOOM;
char path[32];
for (int z = MAP_MIN_ZOOM; z <= MAP_MAX_ZOOM; z++) {
snprintf(path, sizeof(path), MAP_TILE_ROOT "/%d", z);
if (SD.exists(path)) {
if (z < _zoomMin) _zoomMin = z;
if (z > _zoomMax) _zoomMax = z;
}
}
// If no tiles found, reset to defaults
if (_zoomMin > _zoomMax) {
_zoomMin = MAP_MIN_ZOOM;
_zoomMax = MAP_MAX_ZOOM;
Serial.println("MapScreen: no tile directories found");
} else {
Serial.printf("MapScreen: detected zoom range %d-%d\n", _zoomMin, _zoomMax);
}
// Clamp current zoom to available range
if (_zoom > _zoomMax) _zoom = _zoomMax;
if (_zoom < _zoomMin) _zoom = _zoomMin;
}
// ==========================================================================
// Tile coordinate math (Web Mercator / Slippy Map convention)
// ==========================================================================
// Convert lat/lon to tile X,Y and sub-tile pixel offset at given zoom
static void latLonToTileXY(double lat, double lon, int zoom,
int& tileX, int& tileY,
int& pixelX, int& pixelY)
{
int n = 1 << zoom;
// Tile X (longitude is linear)
double x = (lon + 180.0) / 360.0 * n;
tileX = (int)floor(x);
pixelX = (int)((x - tileX) * MAP_TILE_SIZE);
// Tile Y (latitude uses Mercator projection)
double latRad = lat * PI / 180.0;
double y = (1.0 - log(tan(latRad) + 1.0 / cos(latRad)) / PI) / 2.0 * n;
tileY = (int)floor(y);
pixelY = (int)((y - tileY) * MAP_TILE_SIZE);
}
// Convert tile X,Y + pixel offset back to lat/lon
static void tileXYToLatLon(int tileX, int tileY, int pixelX, int pixelY,
int zoom, double& lat, double& lon)
{
int n = 1 << zoom;
double x = tileX + (double)pixelX / MAP_TILE_SIZE;
double y = tileY + (double)pixelY / MAP_TILE_SIZE;
lon = x / n * 360.0 - 180.0;
double latRad = atan(sinh(PI * (1.0 - 2.0 * y / n)));
lat = latRad * 180.0 / PI;
}
// Convert a lat/lon to pixel position within the current viewport
// Returns false if off-screen
bool latLonToScreen(double lat, double lon, int& screenX, int& screenY) {
int centerTileX, centerTileY, centerPixelX, centerPixelY;
latLonToTileXY(_centerLat, _centerLon, _zoom,
centerTileX, centerTileY, centerPixelX, centerPixelY);
int targetTileX, targetTileY, targetPixelX, targetPixelY;
latLonToTileXY(lat, lon, _zoom,
targetTileX, targetTileY, targetPixelX, targetPixelY);
// Calculate pixel delta from center
int dx = (targetTileX - centerTileX) * MAP_TILE_SIZE + (targetPixelX - centerPixelX);
int dy = (targetTileY - centerTileY) * MAP_TILE_SIZE + (targetPixelY - centerPixelY);
screenX = MAP_DISPLAY_W / 2 + dx;
screenY = MAP_VIEWPORT_Y + MAP_VIEWPORT_H / 2 + dy;
return (screenX >= 0 && screenX < MAP_DISPLAY_W &&
screenY >= MAP_VIEWPORT_Y && screenY < MAP_VIEWPORT_Y + MAP_VIEWPORT_H);
}
// ==========================================================================
// Tile loading and rendering
// ==========================================================================
// Build tile file path: /tiles/{zoom}/{x}/{y}.png
static void buildTilePath(char* buf, int bufSize, int zoom, int x, int y) {
snprintf(buf, bufSize, MAP_TILE_ROOT "/%d/%d/%d.png", zoom, x, y);
}
// 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 || !_lineBuf || !_einkDisplay) return false;
char path[64];
buildTilePath(path, sizeof(path), _zoom, tileX, tileY);
// Check existence first to avoid noisy ESP32 VFS error logs
if (!SD.exists(path)) return false;
File f = SD.open(path, FILE_READ);
if (!f) return false;
// Read entire PNG into buffer
int fileSize = f.size();
if (fileSize > MAP_PNG_BUF_SIZE) {
Serial.printf("MapScreen: tile too large: %s (%d bytes)\n", path, fileSize);
f.close();
return false;
}
int bytesRead = f.read(_pngBuf, fileSize);
f.close();
if (bytesRead != fileSize) {
Serial.printf("MapScreen: short read: %s (%d/%d)\n", path, bytesRead, fileSize);
return false;
}
// Set up draw context for the PNG callback
_drawCtx.display = _einkDisplay;
_drawCtx.png = &_png;
_drawCtx.offsetX = screenX;
_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);
if (rc != PNG_SUCCESS) {
Serial.printf("MapScreen: PNG open failed: %s (rc=%d)\n", path, rc);
return false;
}
// Decode — triggers pngDrawCallback for each scanline.
// First arg is user pointer, passed as pDraw->pUser in callback.
rc = _png.decode(&_drawCtx, 0);
_png.close();
if (rc != PNG_SUCCESS) {
Serial.printf("MapScreen: PNG decode failed: %s (rc=%d)\n", path, rc);
return false;
}
return true;
}
// PNGdec scanline callback — called once per row of the decoded image.
// Draws directly to the e-ink display at raw pixel coordinates.
// Uses getLineAsRGB565 with correct (little) endianness for ESP32.
static int pngDrawCallback(PNGDRAW* pDraw) {
DrawContext* ctx = (DrawContext*)pDraw->pUser;
if (!ctx || !ctx->display || !ctx->png || !ctx->lineBuf) return 0;
int screenY = ctx->offsetY + pDraw->y;
// Clip to viewport vertically
if (screenY < ctx->viewportY || screenY >= ctx->viewportY + ctx->viewportH) return 1;
// Debug: log format on first row of first tile only
if (pDraw->y == 0 && ctx->offsetX >= 0 && ctx->offsetY >= 0) {
static bool logged = false;
if (!logged) {
Serial.printf("MapScreen: PNG iBpp=%d iWidth=%d\n", pDraw->iBpp, pDraw->iWidth);
logged = true;
}
}
uint16_t lineWidth = pDraw->iWidth;
if (lineWidth > MAP_TILE_SIZE) lineWidth = MAP_TILE_SIZE;
ctx->png->getLineAsRGB565(pDraw, ctx->lineBuf, PNG_RGB565_LITTLE_ENDIAN, 0xFFFFFFFF);
for (int x = 0; x < lineWidth; x++) {
int screenX = ctx->offsetX + x;
if (screenX < 0 || screenX >= MAP_DISPLAY_W) continue;
// RGB565 little-endian on ESP32: standard bit layout
// R[15:11] G[10:5] B[4:0]
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
uint16_t color = (pixel > 0x7FFF) ? GxEPD_WHITE : GxEPD_BLACK;
ctx->display->drawPixelRaw(screenX, screenY, color);
}
return 1;
}
// ==========================================================================
// Viewport rendering — stitch tiles to fill the screen
// ==========================================================================
void renderMapViewport() {
if (!_einkDisplay) return;
// Find which tile the center point falls in
int centerTileX, centerTileY, centerPixelX, centerPixelY;
latLonToTileXY(_centerLat, _centerLon, _zoom,
centerTileX, centerTileY, centerPixelX, centerPixelY);
Serial.printf("MapScreen: center tile %d/%d/%d px(%d,%d)\n",
_zoom, centerTileX, centerTileY, centerPixelX, centerPixelY);
// Screen position where the center tile's (0,0) corner should be placed
// such that the GPS point ends up at viewport center
int viewCenterX = MAP_DISPLAY_W / 2;
int viewCenterY = MAP_VIEWPORT_Y + MAP_VIEWPORT_H / 2;
int baseTileScreenX = viewCenterX - centerPixelX;
int baseTileScreenY = viewCenterY - centerPixelY;
// Determine tile grid range needed to cover the entire viewport
int startDX = 0, startDY = 0;
int endDX = 0, endDY = 0;
while (baseTileScreenX + startDX * MAP_TILE_SIZE > 0) startDX--;
while (baseTileScreenY + startDY * MAP_TILE_SIZE > MAP_VIEWPORT_Y) startDY--;
while (baseTileScreenX + (endDX + 1) * MAP_TILE_SIZE < MAP_DISPLAY_W) endDX++;
while (baseTileScreenY + (endDY + 1) * MAP_TILE_SIZE < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) endDY++;
int maxTile = (1 << _zoom) - 1;
int loaded = 0, missing = 0;
for (int dy = startDY; dy <= endDY; dy++) {
for (int dx = startDX; dx <= endDX; dx++) {
int tx = centerTileX + dx;
int ty = centerTileY + dy;
// Longitude wraps
if (tx < 0) tx += (1 << _zoom);
if (tx > maxTile) tx -= (1 << _zoom);
// Latitude doesn't wrap — skip out-of-range
if (ty < 0 || ty > maxTile) continue;
int screenX = baseTileScreenX + dx * MAP_TILE_SIZE;
int screenY = baseTileScreenY + dy * MAP_TILE_SIZE;
if (loadAndRenderTile(tx, ty, screenX, screenY)) {
loaded++;
} else {
missing++;
}
yield(); // Feed WDT between tiles — each tile can take 1-2s at 80MHz
}
}
Serial.printf("MapScreen: rendered %d tiles, %d missing\n", loaded, missing);
_tileFound = (loaded > 0);
}
// ==========================================================================
// Contact marker overlay
// ==========================================================================
void renderContactMarkers() {
if (!_einkDisplay || !_markers) return;
int visible = 0;
for (int i = 0; i < _numMarkers; i++) {
int sx, sy;
if (latLonToScreen(_markers[i].lat, _markers[i].lon, sx, sy)) {
int r = markerRadius();
drawDiamond(sx, sy, r);
// Draw name label for repeaters (and at higher zoom for all contacts)
if (_markers[i].name[0] != '\0' &&
(_markers[i].type == ADV_TYPE_REPEATER || _zoom >= 14)) {
drawLabel(sx, sy - r - 2, _markers[i].name);
}
visible++;
}
}
// Render own GPS position as a distinct marker (circle)
if (_hasFix) {
int sx, sy;
if (latLonToScreen(_gpsLat, _gpsLon, sx, sy)) {
drawOwnPosition(sx, sy);
visible++;
}
}
}
// Marker radius scaled by zoom level
// z10→3px, z11→4, z12→5, z13→6, z14→7, z15→8, z16→9, z17→10
int markerRadius() {
int r = _zoom - 7;
if (r < 3) r = 3;
if (r > 10) r = 10;
return r;
}
// Draw a filled diamond marker at screen coordinates with given radius
void drawDiamond(int cx, int cy, int r) {
// White outline first (1px larger than fill)
for (int dy = -(r + 1); dy <= (r + 1); dy++) {
int span = (r + 1) - abs(dy);
int innerSpan = r - abs(dy);
for (int dx = -span; dx <= span; dx++) {
if (abs(dy) <= r && abs(dx) <= innerSpan) continue;
int px = cx + dx, py = cy + dy;
if (px >= 0 && px < MAP_DISPLAY_W &&
py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
_einkDisplay->drawPixelRaw(px, py, GxEPD_WHITE);
}
}
}
// Filled black diamond
for (int dy = -r; dy <= r; dy++) {
int span = r - abs(dy);
for (int dx = -span; dx <= span; dx++) {
int px = cx + dx, py = cy + dy;
if (px >= 0 && px < MAP_DISPLAY_W &&
py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
_einkDisplay->drawPixelRaw(px, py, GxEPD_BLACK);
}
}
}
}
// Strip non-ASCII characters (emoji, flags, symbols) from label text.
// Copies only printable ASCII (0x20-0x7E) into dest buffer.
// Skips leading whitespace after stripping. Returns length.
static int extractAsciiLabel(const char* src, char* dest, int destSize) {
int j = 0;
for (int i = 0; src[i] != '\0' && j < destSize - 1; i++) {
uint8_t ch = (uint8_t)src[i];
if (ch >= 0x20 && ch <= 0x7E) {
dest[j++] = src[i];
}
// Skip continuation bytes of multi-byte UTF-8 sequences
}
dest[j] = '\0';
// Trim leading spaces (left after stripping emoji prefix)
int start = 0;
while (dest[start] == ' ') start++;
if (start > 0) {
memmove(dest, dest + start, j - start + 1);
j -= start;
}
return j;
}
// Draw a text label above a marker with white background for readability
// Built-in font is 5×7 pixels per character
void drawLabel(int cx, int topY, const char* text) {
// Clean emoji/non-ASCII from label
char clean[24];
int len = extractAsciiLabel(text, clean, sizeof(clean));
if (len == 0) return; // Nothing printable
if (len > 14) len = 14; // Truncate long names
clean[len] = '\0';
int textW = len * 6; // 5px char + 1px spacing
int textH = 8; // 7px + 1px padding
int lx = cx - textW / 2;
int ly = topY - textH;
// Clamp to viewport
if (lx < 1) lx = 1;
if (lx + textW >= MAP_DISPLAY_W - 1) lx = MAP_DISPLAY_W - textW - 1;
if (ly < MAP_VIEWPORT_Y) ly = MAP_VIEWPORT_Y;
if (ly + textH >= MAP_VIEWPORT_Y + MAP_VIEWPORT_H) return;
// White background rectangle
for (int y = ly - 1; y <= ly + textH; y++) {
for (int x = lx - 1; x <= lx + textW; x++) {
if (x >= 0 && x < MAP_DISPLAY_W &&
y >= MAP_VIEWPORT_Y && y < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
_einkDisplay->drawPixelRaw(x, y, GxEPD_WHITE);
}
}
}
// Draw text using raw font rendering
_einkDisplay->drawTextRaw(lx, ly, clean, GxEPD_BLACK);
}
// Draw own-position marker: bold circle with filled center dot
// Fixed size (doesn't scale with zoom) so it's always clearly visible
void drawOwnPosition(int cx, int cy) {
int r = 8; // Outer radius — always prominent
// White halo (clears map underneath)
for (int dy = -(r + 2); dy <= (r + 2); dy++) {
for (int dx = -(r + 2); dx <= (r + 2); dx++) {
if (dx * dx + dy * dy <= (r + 2) * (r + 2)) {
int px = cx + dx, py = cy + dy;
if (px >= 0 && px < MAP_DISPLAY_W &&
py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
_einkDisplay->drawPixelRaw(px, py, GxEPD_WHITE);
}
}
}
}
// Thick black circle outline (2px wide ring)
for (int dy = -r; dy <= r; dy++) {
for (int dx = -r; dx <= r; dx++) {
int d2 = dx * dx + dy * dy;
if (d2 >= (r - 2) * (r - 2) && d2 <= r * r) {
int px = cx + dx, py = cy + dy;
if (px >= 0 && px < MAP_DISPLAY_W &&
py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
_einkDisplay->drawPixelRaw(px, py, GxEPD_BLACK);
}
}
}
}
// Filled black center dot (radius 3)
for (int dy = -3; dy <= 3; dy++) {
for (int dx = -3; dx <= 3; dx++) {
if (dx * dx + dy * dy <= 9) {
int px = cx + dx, py = cy + dy;
if (px >= 0 && px < MAP_DISPLAY_W &&
py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
_einkDisplay->drawPixelRaw(px, py, GxEPD_BLACK);
}
}
}
}
}
// ==========================================================================
// Crosshair at viewport center
// ==========================================================================
void renderCrosshair() {
if (!_einkDisplay) return;
int cx = MAP_DISPLAY_W / 2;
int cy = MAP_VIEWPORT_Y + MAP_VIEWPORT_H / 2;
int len = markerRadius() + 2; // Scales with zoom
// Draw thin crosshair: black line with white border for contrast
// Horizontal arm
for (int x = cx - len; x <= cx + len; x++) {
if (x >= 0 && x < MAP_DISPLAY_W) {
if (cy - 1 >= MAP_VIEWPORT_Y)
_einkDisplay->drawPixelRaw(x, cy - 1, GxEPD_WHITE);
if (cy + 1 < MAP_VIEWPORT_Y + MAP_VIEWPORT_H)
_einkDisplay->drawPixelRaw(x, cy + 1, GxEPD_WHITE);
_einkDisplay->drawPixelRaw(x, cy, GxEPD_BLACK);
}
}
// Vertical arm
for (int y = cy - len; y <= cy + len; y++) {
if (y >= MAP_VIEWPORT_Y && y < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
if (cx - 1 >= 0)
_einkDisplay->drawPixelRaw(cx - 1, y, GxEPD_WHITE);
if (cx + 1 < MAP_DISPLAY_W)
_einkDisplay->drawPixelRaw(cx + 1, y, GxEPD_WHITE);
_einkDisplay->drawPixelRaw(cx, y, GxEPD_BLACK);
}
}
}
// ==========================================================================
// Footer bar — zoom level, GPS status, navigation hints
// ==========================================================================
void renderFooter(DisplayDriver& display) {
// Use the standard footer pattern: setTextSize(1) at height()-12
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
int footerY = display.height() - 12;
// Separator line
display.drawRect(0, footerY - 2, display.width(), 1);
// Left: zoom level
char left[8];
snprintf(left, sizeof(left), "Z%d", _zoom);
display.setCursor(0, footerY);
display.print(left);
// Right: navigation hint
const char* right = "WASD:pan Z/X:zoom";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
};

View File

@@ -17,6 +17,10 @@ ModemManager modemManager;
#define AT_BUF_SIZE 512
static char _atBuf[AT_BUF_SIZE];
// Config file paths
#define MODEM_CONFIG_FILE "/sms/modem.cfg"
#define APN_CONFIG_FILE "/sms/apn.cfg"
// ---------------------------------------------------------------------------
// Public API - SMS (unchanged)
// ---------------------------------------------------------------------------
@@ -29,7 +33,15 @@ void ModemManager::begin() {
_operator[0] = '\0';
_callPhone[0] = '\0';
_callStartTime = 0;
_ringtoneEnabled = false;
_ringing = false;
_nextRingTone = 0;
_toneActive = false;
_urcPos = 0;
_imei[0] = '\0';
_imsi[0] = '\0';
_apn[0] = '\0';
strcpy(_apnSource, "none");
// Create FreeRTOS primitives
_sendQueue = xQueueCreate(MODEM_SEND_QUEUE_SIZE, sizeof(SMSOutgoing));
@@ -192,8 +204,6 @@ const char* ModemManager::stateToString(ModemState s) {
// Persistent modem enable/disable config
// ---------------------------------------------------------------------------
#define MODEM_CONFIG_FILE "/sms/modem.cfg"
bool ModemManager::loadEnabledConfig() {
File f = SD.open(MODEM_CONFIG_FILE, FILE_READ);
if (!f) {
@@ -217,6 +227,112 @@ void ModemManager::saveEnabledConfig(bool enabled) {
}
}
// ---------------------------------------------------------------------------
// APN Configuration
// ---------------------------------------------------------------------------
void ModemManager::setAPN(const char* apn) {
strncpy(_apn, apn, sizeof(_apn) - 1);
_apn[sizeof(_apn) - 1] = '\0';
strcpy(_apnSource, "user");
saveAPNConfig(apn);
MESH_DEBUG_PRINTLN("[Modem] APN set by user: %s", _apn);
}
bool ModemManager::loadAPNConfig(char* apnOut, int maxLen) {
File f = SD.open(APN_CONFIG_FILE, FILE_READ);
if (!f) { return false; }
String line = f.readStringUntil('\n');
f.close();
line.trim();
if (line.length() == 0) return false;
strncpy(apnOut, line.c_str(), maxLen - 1);
apnOut[maxLen - 1] = '\0';
return true;
}
void ModemManager::saveAPNConfig(const char* apn) {
if (!SD.exists("/sms")) SD.mkdir("/sms");
File f = SD.open(APN_CONFIG_FILE, FILE_WRITE);
if (f) {
f.println(apn);
f.close();
Serial.printf("[Modem] APN config saved: %s\n", apn);
}
}
// ---------------------------------------------------------------------------
// APN Resolution — called during init after network registration
//
// Priority:
// 1. User-configured APN (from /sms/apn.cfg)
// 2. Network-provisioned APN (AT+CGDCONT? — modem already has one)
// 3. Auto-detected from IMSI via embedded ApnDatabase
// 4. Blank (some carriers work with empty APN)
// ---------------------------------------------------------------------------
void ModemManager::resolveAPN() {
// 1. Check for user-configured APN on SD card
char userApn[64];
if (loadAPNConfig(userApn, sizeof(userApn))) {
strncpy(_apn, userApn, sizeof(_apn) - 1);
strcpy(_apnSource, "user");
MESH_DEBUG_PRINTLN("[Modem] APN from user config: %s", _apn);
// Apply to modem
char cmd[80];
snprintf(cmd, sizeof(cmd), "AT+CGDCONT=1,\"IP\",\"%s\"", _apn);
sendAT(cmd, "OK", 3000);
return;
}
// 2. Check if modem already has a network-provisioned APN
if (sendAT("AT+CGDCONT?", "OK", 3000)) {
// Response: +CGDCONT: 1,"IP","telstra.internet",,0,0
char* p = strstr(_atBuf, "+CGDCONT:");
if (p) {
char* q1 = strchr(p, '"'); // first quote (before IP)
if (q1) q1 = strchr(q1 + 1, '"'); // close quote of IP
if (q1) q1 = strchr(q1 + 1, '"'); // open quote of APN
if (q1) {
q1++;
char* q2 = strchr(q1, '"');
if (q2 && q2 > q1) {
int len = q2 - q1;
if (len > 0 && len < (int)sizeof(_apn)) {
memcpy(_apn, q1, len);
_apn[len] = '\0';
strcpy(_apnSource, "network");
MESH_DEBUG_PRINTLN("[Modem] APN from network/modem: %s", _apn);
return;
}
}
}
}
}
// 3. Auto-detect from IMSI using embedded database
if (_imsi[0]) {
const ApnEntry* entry = apnLookupFromIMSI(_imsi);
if (entry) {
strncpy(_apn, entry->apn, sizeof(_apn) - 1);
strcpy(_apnSource, "auto");
MESH_DEBUG_PRINTLN("[Modem] APN auto-detected: %s (%s)", _apn, entry->carrier);
// Apply to modem
char cmd[80];
snprintf(cmd, sizeof(cmd), "AT+CGDCONT=1,\"IP\",\"%s\"", _apn);
sendAT(cmd, "OK", 3000);
return;
}
}
// 4. No APN found — leave blank
_apn[0] = '\0';
strcpy(_apnSource, "none");
MESH_DEBUG_PRINTLN("[Modem] APN: none detected (IMSI=%s)", _imsi[0] ? _imsi : "unknown");
}
// ---------------------------------------------------------------------------
// URC (Unsolicited Result Code) Handling
// ---------------------------------------------------------------------------
@@ -493,6 +609,46 @@ bool ModemManager::doSetVolume(uint8_t level) {
return ok;
}
// ---------------------------------------------------------------------------
// Incoming call ringtone — tone bursts via AT+SIMTONE on modem speaker
// Pattern: 400ms tone → 1200ms silence → repeat
// ---------------------------------------------------------------------------
void ModemManager::handleRingtone() {
bool nowRinging = (_state == ModemState::RINGING_IN);
if (nowRinging && !_ringing) {
// Just started ringing
_ringing = true;
_nextRingTone = 0; // Play first burst immediately
_toneActive = false;
} else if (!nowRinging && _ringing) {
// Ringing stopped (answered, rejected, missed)
_ringing = false;
if (_toneActive) {
sendAT("AT+SIMTONE=0", "OK", 500);
_toneActive = false;
}
return;
}
if (!_ringing || !_ringtoneEnabled) return;
unsigned long now = millis();
if (now < _nextRingTone) return;
if (!_toneActive) {
// Play tone burst: 1000 Hz, level 5000 (of 50-25500), 400ms duration
sendAT("AT+SIMTONE=1,1000,5000,400", "OK", 500);
_toneActive = true;
_nextRingTone = now + 400; // Tone plays for 400ms
} else {
// Tone just finished — gap before next burst
_toneActive = false;
_nextRingTone = now + 1200; // 1.2s silence (classic ring cadence)
}
}
// ---------------------------------------------------------------------------
// FreeRTOS Task
// ---------------------------------------------------------------------------
@@ -537,6 +693,28 @@ restart:
// Disable echo
sendAT("ATE0", "OK");
// --- Query device identity ---
// IMEI (International Mobile Equipment Identity)
if (sendAT("AT+GSN", "OK", 3000)) {
// Response is just the IMEI number on its own line
char* p = _atBuf;
while (*p && !isdigit(*p)) p++; // skip to first digit
int i = 0;
while (isdigit(p[i]) && i < 19) { _imei[i] = p[i]; i++; }
_imei[i] = '\0';
MESH_DEBUG_PRINTLN("[Modem] IMEI: %s", _imei);
}
// IMSI (International Mobile Subscriber Identity) — for APN auto-detection
if (sendAT("AT+CIMI", "OK", 3000)) {
char* p = _atBuf;
while (*p && !isdigit(*p)) p++;
int i = 0;
while (isdigit(p[i]) && i < 19) { _imsi[i] = p[i]; i++; }
_imsi[i] = '\0';
MESH_DEBUG_PRINTLN("[Modem] IMSI: %s", _imsi);
}
// Set SMS text mode
sendAT("AT+CMGF=1", "OK");
@@ -589,6 +767,10 @@ restart:
}
// Query operator name
// AT+COPS=3,0 sets the format to "long alphanumeric" so AT+COPS?
// returns "Optus" instead of "50502"
sendAT("AT+COPS=3,0", "OK", 2000);
if (sendAT("AT+COPS?", "OK", 5000)) {
char* p = strchr(_atBuf, '"');
if (p) {
@@ -604,9 +786,28 @@ restart:
}
}
// If operator is still numeric (all digits), look up friendly name from IMSI
if (_operator[0] && isdigit(_operator[0])) {
bool allDigits = true;
for (int i = 0; _operator[i]; i++) {
if (!isdigit(_operator[i])) { allDigits = false; break; }
}
if (allDigits && _imsi[0]) {
const ApnEntry* entry = apnLookupFromIMSI(_imsi);
if (entry && entry->carrier) {
strncpy(_operator, entry->carrier, sizeof(_operator) - 1);
_operator[sizeof(_operator) - 1] = '\0';
MESH_DEBUG_PRINTLN("[Modem] operator (from IMSI lookup): %s", _operator);
}
}
}
// Initial signal query
pollCSQ();
// Resolve APN (user config → network provisioned → IMSI auto-detect)
resolveAPN();
// Sync ESP32 system clock from modem network time
bool clockSet = false;
for (int attempt = 0; attempt < 5 && !clockSet; attempt++) {
@@ -653,7 +854,8 @@ restart:
sendAT("AT+CMGD=1,4", "OK", 5000);
_state = ModemState::READY;
MESH_DEBUG_PRINTLN("[Modem] READY (CSQ=%d, operator=%s)", _csq, _operator);
MESH_DEBUG_PRINTLN("[Modem] READY (CSQ=%d, operator=%s, APN=%s [%s], IMEI=%s)",
_csq, _operator, _apn[0] ? _apn : "(none)", _apnSource, _imei);
// ---- Phase 4: Main loop ----
unsigned long lastCSQPoll = 0;
@@ -671,6 +873,11 @@ restart:
// ================================================================
drainURCs();
// ================================================================
// Step 1b: Ringtone — play tone bursts while incoming call rings
// ================================================================
handleRingtone();
// ================================================================
// Step 2: Process call commands from main loop
// ================================================================
@@ -715,8 +922,10 @@ restart:
// Primary detection is via "VOICE CALL: BEGIN" URC (handled by
// drainURCs/processURCLine above). CLCC polling is a safety net
// in case the URC is missed or delayed.
// Skip when paused to avoid Core 0 contention with WiFi TLS.
// ================================================================
if (_state == ModemState::DIALING &&
if (!_paused &&
_state == ModemState::DIALING &&
millis() - lastCLCCPoll > CLCC_POLL_INTERVAL) {
if (sendAT("AT+CLCC", "OK", 2000)) {
// +CLCC: 1,0,0,0,0,"number",129 — stat field:
@@ -747,8 +956,11 @@ restart:
// ================================================================
// Step 4: SMS and signal polling (only when not in a call)
// Skip when paused to avoid Core 0 contention with WiFi/TLS.
// The modem task's sendAT() calls (AT+CMGL 5s, AT+CSQ 2s) do
// tight UART poll loops that disrupt WiFi packet timing.
// ================================================================
if (!isCallActive()) {
if (!_paused && !isCallActive()) {
// Check for outgoing SMS in queue
SMSOutgoing outMsg;
if (xQueueReceive(_sendQueue, &outMsg, 0) == pdTRUE) {
@@ -766,7 +978,7 @@ restart:
}
// Periodic signal strength update (always, even during calls)
if (millis() - lastCSQPoll > CSQ_POLL_INTERVAL) {
if (!_paused && millis() - lastCSQPoll > CSQ_POLL_INTERVAL) {
// Only poll CSQ if not actively in a call (avoid interrupting audio)
if (!isCallActive()) {
pollCSQ();

View File

@@ -22,6 +22,7 @@
#include <freertos/queue.h>
#include <freertos/semphr.h>
#include "variant.h"
#include "ApnDatabase.h"
// ---------------------------------------------------------------------------
// Modem pins (from variant.h, always defined for reference)
@@ -141,6 +142,10 @@ public:
bool setCallVolume(uint8_t level); // Set volume 0-5
bool pollCallEvent(CallEvent& out); // Poll from main loop
// Ringtone control — called from main loop
void setRingtoneEnabled(bool en) { _ringtoneEnabled = en; }
bool isRingtoneEnabled() const { return _ringtoneEnabled; }
// --- State queries (lock-free reads) ---
ModemState getState() const { return _state; }
int getSignalBars() const; // 0-5
@@ -158,6 +163,28 @@ public:
const char* getCallPhone() const { return _callPhone; }
uint32_t getCallStartTime() const { return _callStartTime; }
// --- Device info (populated during init) ---
const char* getIMEI() const { return _imei; }
const char* getIMSI() const { return _imsi; }
const char* getAPN() const { return _apn; }
const char* getAPNSource() const { return _apnSource; } // "auto", "network", "user", "none"
// --- APN configuration ---
// Set APN manually (overrides auto-detection). Persists to SD.
void setAPN(const char* apn);
// Load user-configured APN from SD card. Returns true if found.
static bool loadAPNConfig(char* apnOut, int maxLen);
// Save user-configured APN to SD card.
static void saveAPNConfig(const char* apn);
// Pause/resume polling — used by web reader to avoid Core 0 contention
// during WiFi TLS handshakes. While paused, the task skips AT commands
// (SMS poll, CSQ poll) but still drains URCs and handles call commands
// so incoming calls aren't missed.
void pausePolling() { _paused = true; }
void resumePolling() { _paused = false; }
bool isPaused() const { return _paused; }
static const char* stateToString(ModemState s);
// Persistent enable/disable config (SD file /sms/modem.cfg)
@@ -167,12 +194,25 @@ public:
private:
volatile ModemState _state = ModemState::OFF;
volatile int _csq = 99; // 99 = unknown
volatile bool _paused = false; // Suppresses AT polling when true
char _operator[24] = {0};
// Device identity (populated during Phase 2 init)
char _imei[20] = {0}; // IMEI from AT+GSN
char _imsi[20] = {0}; // IMSI from AT+CIMI (for APN lookup)
char _apn[64] = {0}; // Active APN
char _apnSource[8] = {0}; // "auto", "network", "user", "none"
// Call state (written by modem task, read by main loop)
char _callPhone[SMS_PHONE_LEN] = {0}; // Current call number
volatile uint32_t _callStartTime = 0; // millis() when call connected
// Ringtone state
volatile bool _ringtoneEnabled = false;
bool _ringing = false; // Shadow of RINGING_IN for tone logic
unsigned long _nextRingTone = 0; // Next tone burst timestamp (modem task)
bool _toneActive = false; // Is a tone currently sounding
TaskHandle_t _taskHandle = nullptr;
// SMS queues
@@ -202,6 +242,9 @@ private:
void drainURCs(); // Read available UART data, process complete lines
void processURCLine(const char* line); // Handle a single URC line
// APN resolution (called from modem task during init)
void resolveAPN(); // Auto-detect APN from network/IMSI/user config
// Call control (called from modem task)
bool doDialCall(const char* phone);
bool doAnswerCall();
@@ -209,6 +252,7 @@ private:
bool doSendDTMF(char digit);
bool doSetVolume(uint8_t level);
void queueCallEvent(CallEventType type, const char* phone = nullptr, uint32_t duration = 0);
void handleRingtone(); // Play tone bursts while incoming call rings
// FreeRTOS task
static void taskEntry(void* param);

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) {
@@ -496,7 +500,11 @@ private:
int rightX = display.width() - display.getTextWidth(tmp) - 2;
if (_selectedFile >= 1 && _selectedFile <= (int)_fileList.size()) {
#if defined(LilyGo_T5S3_EPaper_Pro)
const char* hint = "[Hold:Rename]";
#else
const char* hint = "[R:Rename]";
#endif
int hintX = rightX - display.getTextWidth(hint) - 4;
display.setCursor(hintX, 0);
display.setColor(DisplayDriver::YELLOW);
@@ -511,7 +519,7 @@ private:
// File list with "+ New Note" at index 0
display.setTextSize(0);
int listLineH = 8;
int listLineH = 9; // Match contacts/discovery for consistent selection highlight
int startY = 14;
int totalItems = 1 + (int)_fileList.size();
int maxVisible = (display.height() - startY - _footerHeight) / listLineH;
@@ -528,7 +536,11 @@ private:
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), listLineH);
#else
display.fillRect(0, y + 5, display.width(), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
@@ -558,9 +570,13 @@ private:
display.drawRect(0, footerY - 2, display.width(), 1);
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
display.print("Q:Back W/S:Nav");
const char* right = "Ent:Open";
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe:Nav");
const char* right = "Tap:Open";
#else
display.print("Q:Bk");
const char* right = "Tap/Ent:Open";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
@@ -576,9 +592,13 @@ private:
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
display.print("Q:Bck Ent:Edit");
const char* right = "Sh+Del:Del";
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Tap:Edit");
const char* right = "Hold:Delete";
#else
display.print("Q:Bk Ent:Edit");
const char* right = "X:Delete";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
return;
@@ -663,9 +683,15 @@ private:
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
display.print("Q:Bck Ent:Edit");
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe:Page");
const char* right = "Sh+Del:Del";
const char* right = "Tap:Edit";
#else
display.print("Q:Bk Ent:Edit");
const char* right = "X:Delete";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
@@ -766,11 +792,25 @@ private:
snprintf(status, sizeof(status), "Pg %d/%d", curPage, totalPg);
display.print(status);
#if defined(LilyGo_T5S3_EPaper_Pro)
const char* mid = "Tap:Type";
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
display.print(mid);
#endif
const char* right;
if (_bufLen == 0 || !_dirty) {
#if defined(LilyGo_T5S3_EPaper_Pro)
right = "Back";
#else
right = "Q:Back";
#endif
} else {
#if defined(LilyGo_T5S3_EPaper_Pro)
right = "Hold:Save";
#else
right = "Sh+Del:Save";
#endif
}
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
@@ -817,9 +857,13 @@ private:
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Cancel");
const char* right = "Tap:Confirm";
#else
display.print("Q:Cancel");
const char* right = "Ent:Confirm";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
@@ -852,9 +896,13 @@ private:
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Cancel");
const char* right = "Tap:Delete";
#else
display.print("Q:Cancel");
const char* right = "Ent:Delete";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
@@ -1033,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;
@@ -1124,12 +1176,16 @@ public:
void setSDReady(bool ready) { _sdReady = ready; }
bool isSDReady() const { return _sdReady; }
bool isDirty() const { return _dirty; }
void triggerSaveAndExit() { saveAndExit(); }
void setTimestamp(uint32_t rtcTime, int8_t utcOffset) {
_rtcTime = rtcTime;
_utcOffset = utcOffset;
}
void setTimeGetter(TimeGetterFn fn) { _getTimeFn = fn; }
void enter(DisplayDriver& display) {
initLayout(display);
scanFiles();
@@ -1145,7 +1201,6 @@ public:
bool isInFileList() const { return _mode == FILE_LIST; }
bool isRenaming() const { return _mode == RENAMING; }
bool isConfirmingDelete() const { return _mode == CONFIRM_DELETE; }
bool isDirty() const { return _dirty; }
bool isEmpty() const { return _bufLen == 0; }
// ---- Cursor Navigation (called from main.cpp) ----

View File

@@ -0,0 +1,34 @@
#pragma once
// ---------------------------------------------------------------------------
// Radio presets — shared between SettingsScreen (UI) and MyMesh (Serial CLI)
// ---------------------------------------------------------------------------
struct RadioPreset {
const char* name;
float freq;
float bw;
uint8_t sf;
uint8_t cr;
uint8_t tx_power;
};
static const RadioPreset RADIO_PRESETS[] = {
{ "Australia", 915.800f, 250.0f, 10, 5, 22 },
{ "Australia (Narrow)", 916.575f, 62.5f, 7, 8, 22 },
{ "Australia: SA, WA", 923.125f, 62.5f, 8, 8, 22 },
{ "Australia: QLD", 923.125f, 62.5f, 8, 5, 22 },
{ "EU/UK (Narrow)", 869.618f, 62.5f, 8, 8, 14 },
{ "EU/UK (Long Range)", 869.525f, 250.0f, 11, 5, 14 },
{ "EU/UK (Medium Range)", 869.525f, 250.0f, 10, 5, 14 },
{ "Czech Republic (Narrow)",869.432f, 62.5f, 7, 5, 14 },
{ "EU 433 (Long Range)", 433.650f, 250.0f, 11, 5, 14 },
{ "New Zealand", 917.375f, 250.0f, 11, 5, 22 },
{ "New Zealand (Narrow)", 917.375f, 62.5f, 7, 5, 22 },
{ "Portugal 433", 433.375f, 62.5f, 9, 6, 14 },
{ "Portugal 868", 869.618f, 62.5f, 7, 6, 14 },
{ "Switzerland", 869.618f, 62.5f, 8, 8, 14 },
{ "USA/Canada (Recommended)",910.525f, 62.5f, 7, 5, 22 },
{ "Vietnam", 920.250f, 250.0f, 11, 5, 22 },
};
#define NUM_RADIO_PRESETS (sizeof(RADIO_PRESETS) / sizeof(RADIO_PRESETS[0]))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@ class UITask;
// ============================================================================
#define BOOKS_FOLDER "/books"
#define INDEX_FOLDER "/.indexes"
#define INDEX_VERSION 5 // v5: UTF-8 aware word wrap (accented char support)
#define INDEX_VERSION 9 // v9: indexer buffer matches page buffer (fixes chunk boundary gaps)
#define PREINDEX_PAGES 100
#define READER_MAX_FILES 50
#define READER_BUF_SIZE 4096
@@ -97,6 +97,145 @@ inline WrapResult findLineBreak(const char* buffer, int bufLen, int lineStart, i
return result;
}
// ============================================================================
// Pixel-width line breaking for proportional fonts (T5S3)
//
// Measures actual rendered text width via DisplayDriver::getTextWidth() at
// each word boundary. This gives correct line breaks regardless of character
// width variation in proportional fonts like FreeSans12pt.
// maxChars is a safety upper bound to prevent runaway on spaceless lines.
// ============================================================================
#if defined(LilyGo_T5S3_EPaper_Pro)
#include <helpers/ui/DisplayDriver.h>
inline WrapResult findLineBreakPixel(const char* buffer, int bufLen, int lineStart,
DisplayDriver* display, int maxChars) {
WrapResult result;
result.lineEnd = lineStart;
result.nextStart = lineStart;
if (lineStart >= bufLen || !display) return result;
#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;
for (int i = lineStart; i < bufLen; i++) {
char c = buffer[i];
// Newline handling (identical to char-count version)
if (c == '\n') {
result.lineEnd = i;
result.nextStart = i + 1;
if (result.nextStart < bufLen && buffer[result.nextStart] == '\r')
result.nextStart++;
return result;
}
if (c == '\r') {
result.lineEnd = i;
result.nextStart = i + 1;
if (result.nextStart < bufLen && buffer[result.nextStart] == '\n')
result.nextStart++;
return result;
}
if ((uint8_t)c >= 32) {
// 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;
uint32_t cp = decodeUtf8Char(buffer, bufLen, &decPos);
uint8_t glyph = unicodeToCP437(cp);
if (glyph >= 32 && measLen < 298) {
measBuf[measLen++] = (char)glyph;
charCount++;
}
i = decPos - 1; // -1 because the for loop will i++
inWord = true;
} 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;
} 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;
}
}
// 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;
}
return result;
}
}
// Safety: hard char limit (handles spaceless lines, URLs, etc.)
if (charCount >= maxChars) {
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;
}
}
}
result.lineEnd = bufLen;
result.nextStart = bufLen;
return result;
}
#endif // LilyGo_T5S3_EPaper_Pro
// ============================================================================
// Page Indexer (word-wrap aware, matches display rendering)
// ============================================================================
@@ -104,7 +243,7 @@ inline int indexPagesWordWrap(File& file, long startPos,
std::vector<long>& pagePositions,
int linesPerPage, int charsPerLine,
int maxPages) {
const int BUF_SIZE = 2048;
const int BUF_SIZE = READER_BUF_SIZE; // Match page buffer to avoid chunk boundary wrap mismatches
char buffer[BUF_SIZE];
file.seek(startPos);
@@ -148,6 +287,63 @@ inline int indexPagesWordWrap(File& file, long startPos,
return pagesAdded;
}
// ============================================================================
// Pixel-based Page Indexer for T5S3 (proportional font word wrap)
// ============================================================================
#if defined(LilyGo_T5S3_EPaper_Pro)
inline int indexPagesWordWrapPixel(File& file, long startPos,
std::vector<long>& pagePositions,
int linesPerPage, int maxChars,
DisplayDriver* display, int maxPages) {
const int BUF_SIZE = READER_BUF_SIZE; // Match page buffer to avoid chunk boundary wrap mismatches
char buffer[BUF_SIZE];
// Ensure body font is active for pixel measurement
display->setTextSize(0);
file.seek(startPos);
int pagesAdded = 0;
int lineCount = 0;
int leftover = 0;
long chunkFileStart = startPos;
while (file.available() && (maxPages <= 0 || pagesAdded < maxPages)) {
int bytesRead = file.readBytes(buffer + leftover, BUF_SIZE - leftover);
int bufLen = leftover + bytesRead;
if (bufLen == 0) break;
int pos = 0;
while (pos < bufLen) {
WrapResult wrap = findLineBreakPixel(buffer, bufLen, pos, display, maxChars);
if (wrap.nextStart <= pos && wrap.lineEnd >= bufLen) break;
lineCount++;
pos = wrap.nextStart;
if (lineCount >= linesPerPage) {
long pageFilePos = chunkFileStart + pos;
pagePositions.push_back(pageFilePos);
pagesAdded++;
lineCount = 0;
if (maxPages > 0 && pagesAdded >= maxPages) break;
}
if (pos >= bufLen) break;
}
leftover = bufLen - pos;
if (leftover > 0 && leftover < BUF_SIZE) {
memmove(buffer, buffer + pos, leftover);
} else {
leftover = 0;
}
chunkFileStart = file.position() - leftover;
}
display->setTextSize(1); // Restore
return pagesAdded;
}
#endif // LilyGo_T5S3_EPaper_Pro
// ============================================================================
// TextReaderScreen
// ============================================================================
@@ -200,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.
@@ -699,12 +900,22 @@ private:
if (_pagePositions.empty()) {
// Cache had no pages (e.g. dummy entry) — full index from scratch
_pagePositions.push_back(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
indexPagesWordWrapPixel(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, _display, 0);
#else
indexPagesWordWrap(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, 0);
#endif
} else {
long lastPos = cache->pagePositions.back();
#if defined(LilyGo_T5S3_EPaper_Pro)
indexPagesWordWrapPixel(_file, lastPos, _pagePositions,
_linesPerPage, _charsPerLine, _display, 0);
#else
indexPagesWordWrap(_file, lastPos, _pagePositions,
_linesPerPage, _charsPerLine, 0);
#endif
}
} else {
// No cache — full index from scratch
@@ -722,8 +933,13 @@ private:
drawSplash("Indexing...", "Please wait", shortName);
_pagePositions.push_back(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
indexPagesWordWrapPixel(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, _display, 0);
#else
indexPagesWordWrap(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, 0);
#endif
}
// Save complete index
@@ -863,9 +1079,13 @@ private:
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), listLineH);
#else
// setCursor adds +5 to y internally, but fillRect does not.
// Offset fillRect by +5 to align highlight bar with text.
display.fillRect(0, y + 5, display.width(), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
@@ -918,13 +1138,19 @@ private:
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
display.print("Q:Back W/S:Nav");
const char* right = "Ent:Open";
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setTextSize(0);
display.drawTextCentered(display.width() / 2, footerY, "Swipe: Scroll Tap: Open Boot: home");
#else
display.setCursor(0, footerY);
display.print("Q:Bk");
const char* right = "Tap/Ent:Open";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#endif
}
void renderPage(DisplayDriver& display) {
@@ -942,7 +1168,11 @@ private:
// so we render everything in it.
while (pos < _pageBufLen && lineCount < _linesPerPage && y <= maxY) {
int oldPos = pos;
#if defined(LilyGo_T5S3_EPaper_Pro)
WrapResult wrap = findLineBreakPixel(_pageBuf, _pageBufLen, pos, &display, _charsPerLine);
#else
WrapResult wrap = findLineBreak(_pageBuf, _pageBufLen, pos, _charsPerLine);
#endif
// Safety: stop if findLineBreak made no progress (stuck at end of buffer)
if (wrap.nextStart <= oldPos && wrap.lineEnd >= _pageBufLen) break;
@@ -950,6 +1180,7 @@ private:
display.setCursor(0, y);
// Print line with UTF-8 decoding: multi-byte sequences are decoded
// to Unicode codepoints, then mapped to CP437 for the built-in font.
bool lineHasContent = false;
char charStr[2] = {0, 0};
int j = pos;
while (j < wrap.lineEnd && j < _pageBufLen) {
@@ -965,6 +1196,7 @@ private:
// Plain ASCII — print directly
charStr[0] = (char)b;
display.print(charStr);
lineHasContent = true;
j++;
} else if (b >= 0xC0) {
// UTF-8 lead byte — decode full sequence and map to CP437
@@ -974,6 +1206,7 @@ private:
if (glyph) {
charStr[0] = (char)glyph;
display.print(charStr);
lineHasContent = true;
}
// If unmappable (glyph==0), just skip the character
} else {
@@ -981,11 +1214,20 @@ private:
// Treat as CP437 pass-through (e.g. from EPUB numeric entity decoding).
charStr[0] = (char)b;
display.print(charStr);
lineHasContent = true;
j++;
}
}
y += _lineHeight;
// Blank lines (paragraph breaks) get reduced height for compact layout.
// Full _lineHeight for blank lines wastes too much space — on T5S3 each
// blank line is ~34px, making paragraph gaps 7-8× the normal line spacing.
// Using 40% height gives a visible paragraph break without wasting space.
if (lineHasContent) {
y += _lineHeight;
} else {
y += max(2, _lineHeight * 2 / 5); // ~40% height for blank lines
}
lineCount++;
pos = wrap.nextStart;
if (pos >= _pageBufLen) break;
@@ -1001,13 +1243,29 @@ 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: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
}
public:
@@ -1021,6 +1279,20 @@ public:
_pageBufLen(0), _contentDirty(true) {
}
// Reset layout so it recalculates on next render (orientation change).
// If a book is open, forces full reindex with new layout params.
void invalidateLayout() {
_initialized = false;
if (_fileOpen) {
_pagePositions.clear();
_totalPages = 0;
_currentPage = 0;
_pageBufLen = 0;
_contentDirty = true;
Serial.println("TextReader: Layout invalidated, will reindex on next enter");
}
}
// Call once after display is available to calculate layout metrics
void initLayout(DisplayDriver& display) {
if (_initialized) return;
@@ -1036,8 +1308,15 @@ public:
if (tenCharsW > 0) {
_charsPerLine = (display.width() * 10) / tenCharsW;
}
#if defined(LilyGo_T5S3_EPaper_Pro)
// T5S3 uses pixel-based line breaking (findLineBreakPixel) which measures
// actual text width via getTextWidth(). _charsPerLine serves only as a
// safety upper bound for lines without word breaks (URLs, etc.).
_charsPerLine = 120;
#else
if (_charsPerLine < 15) _charsPerLine = 15;
if (_charsPerLine > 60) _charsPerLine = 60;
#endif
// Line height for built-in 6x8 font:
// setCursor adds +5 to y, so effective text top = (y+5)*scale_y
@@ -1052,6 +1331,17 @@ public:
_lineHeight = 5; // Safe fallback
}
#if defined(LilyGo_T5S3_EPaper_Pro)
// T5S3 uses FreeSans12pt/FreeSerif12pt for size 0 (yAdvance=29px).
// Line height in virtual coords depends on orientation:
// Landscape: 29px / scale_y(4.22) ≈ 7 + 1 spacing = 8
// Portrait: 29px / scale_y(7.50) ≈ 4 + 1 spacing = 5
{
extern DISPLAY_CLASS display;
_lineHeight = display.isPortraitMode() ? 5 : 8;
}
#endif
_headerHeight = 0; // No header in reading mode (maximize text area)
_footerHeight = 14;
int textAreaHeight = display.height() - _headerHeight - _footerHeight;
@@ -1174,9 +1464,15 @@ public:
cache.pagePositions.clear();
cache.pagePositions.push_back(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
int added = indexPagesWordWrapPixel(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
_display, PREINDEX_PAGES - 1);
#else
int added = indexPagesWordWrap(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
PREINDEX_PAGES - 1);
#endif
cache.fullyIndexed = !file.available();
file.close();
@@ -1215,6 +1511,21 @@ public:
if (!_fileOpen) {
_selectedFile = 0;
_mode = FILE_LIST;
} else if (_pagePositions.empty()) {
// Layout was invalidated (orientation change) — reindex the open book
Serial.println("TextReader: Reindexing after layout change");
_pagePositions.push_back(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
indexPagesWordWrapPixel(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, _display, 0);
#else
indexPagesWordWrap(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, 0);
#endif
_totalPages = _pagePositions.size();
if (_currentPage >= _totalPages) _currentPage = 0;
_mode = READING;
loadPageContent();
} else {
_mode = READING;
loadPageContent();
@@ -1225,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);
@@ -1249,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;
@@ -1335,9 +1689,15 @@ public:
cache.lastReadPage = 0;
cache.pagePositions.clear();
cache.pagePositions.push_back(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
indexPagesWordWrapPixel(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
_display, PREINDEX_PAGES - 1);
#else
indexPagesWordWrap(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
PREINDEX_PAGES - 1);
#endif
cache.fullyIndexed = !file.available();
file.close();
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
@@ -1363,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();
@@ -1374,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();
@@ -1384,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

@@ -0,0 +1,128 @@
#pragma once
// =============================================================================
// TouchInput - Minimal CST328/CST3530 touch driver for T-Deck Pro
//
// Uses raw I2C reads on the shared Wire bus. No external library needed.
// Protocol confirmed via raw serial capture from actual hardware:
//
// Register 0xD000, 7 bytes:
// buf[0]: event flags (0xAB = idle/no touch, other = active touch)
// buf[1]: X coordinate high data
// buf[2]: Y coordinate high data
// buf[3]: X low nibble (bits 7:4) | Y low nibble (bits 3:0)
// buf[4]: pressure
// buf[5]: touch count (& 0x7F), typically 0x01 for single touch
// buf[6]: 0xAB always (check byte, ignore)
//
// Coordinate formula:
// x = (buf[1] << 4) | ((buf[3] >> 4) & 0x0F) → 0..239
// y = (buf[2] << 4) | (buf[3] & 0x0F) → 0..319
//
// Hardware: CST328 at 0x1A, INT=GPIO12, RST=GPIO38 (V1.1)
//
// Guard: HAS_TOUCHSCREEN
// =============================================================================
#ifdef HAS_TOUCHSCREEN
#ifndef TOUCH_INPUT_H
#define TOUCH_INPUT_H
#include <Arduino.h>
#include <Wire.h>
class TouchInput {
public:
static const uint8_t TOUCH_ADDR = 0x1A;
TouchInput(TwoWire* wire = &Wire)
: _wire(wire), _intPin(-1), _initialized(false), _debugCount(0), _lastPoll(0) {}
bool begin(int intPin) {
_intPin = intPin;
pinMode(_intPin, INPUT);
// Verify the touch controller is present on the bus
_wire->beginTransmission(TOUCH_ADDR);
uint8_t err = _wire->endTransmission();
if (err != 0) {
Serial.printf("[Touch] CST328 not found at 0x%02X (err=%d)\n", TOUCH_ADDR, err);
return false;
}
Serial.printf("[Touch] CST328 found at 0x%02X, INT=GPIO%d\n", TOUCH_ADDR, _intPin);
_initialized = true;
return true;
}
bool isReady() const { return _initialized; }
// Poll for touch. Returns true if a finger is down, fills x and y.
// Coordinates are in physical display space (0-239 X, 0-319 Y).
// NOTE: CST328 INT pin is pulse-based, not level. We cannot rely on
// digitalRead(INT) for touch state. Instead, always read and check buf[0].
bool getPoint(int16_t &x, int16_t &y) {
if (!_initialized) return false;
// Rate limit: poll at most every 20ms (50 Hz) to avoid I2C bus congestion
unsigned long now = millis();
if (now - _lastPoll < 20) return false;
_lastPoll = now;
uint8_t buf[7];
memset(buf, 0, sizeof(buf));
// Write register address 0xD000
_wire->beginTransmission(TOUCH_ADDR);
_wire->write(0xD0);
_wire->write(0x00);
if (_wire->endTransmission(false) != 0) return false;
// Read 7 bytes of touch data
uint8_t received = _wire->requestFrom(TOUCH_ADDR, (uint8_t)7);
if (received < 7) return false;
for (int i = 0; i < 7; i++) buf[i] = _wire->read();
// buf[0] == 0xAB means idle (no touch active)
if (buf[0] == 0xAB) return false;
// buf[0] == 0x00 can appear on finger-up transition — ignore
if (buf[0] == 0x00) return false;
// Touch count from buf[5]
uint8_t count = buf[5] & 0x7F;
if (count == 0 || count > 5) return false;
// Parse coordinates (CST226/CST328 format confirmed by hardware capture)
// x = (buf[1] << 4) | high nibble of buf[3]
// y = (buf[2] << 4) | low nibble of buf[3]
int16_t tx = ((int16_t)buf[1] << 4) | ((buf[3] >> 4) & 0x0F);
int16_t ty = ((int16_t)buf[2] << 4) | (buf[3] & 0x0F);
// Sanity check (panel is 240x320)
if (tx < 0 || tx > 260 || ty < 0 || ty > 340) return false;
// Debug: log first 20 touch events with parsed coordinates
if (_debugCount < 50) {
Serial.printf("[Touch] Raw: %02X %02X %02X %02X %02X %02X %02X → x=%d y=%d\n",
buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6],
tx, ty);
_debugCount++;
}
x = tx;
y = ty;
return true;
}
private:
TwoWire* _wire;
int _intPin;
bool _initialized;
int _debugCount;
unsigned long _lastPoll;
};
#endif // TOUCH_INPUT_H
#endif // HAS_TOUCHSCREEN

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,13 @@
#include "WebReaderScreen.h"
#endif
#if defined(LilyGo_T5S3_EPaper_Pro)
#include "VirtualKeyboard.h"
#endif
// MapScreen.h included in UITask.cpp and main.cpp only (PNGdec headers
// conflict with BLE if pulled into the global include chain)
class UITask : public AbstractUITask {
DisplayDriver* _display;
SensorManager* _sensors;
@@ -41,11 +48,17 @@ class UITask : public AbstractUITask {
#endif
unsigned long _next_refresh, _auto_off;
unsigned long _kb_flash_off_at; // Keyboard flash turn-off timer
#ifdef HAS_4G_MODEM
bool _incomingCallRinging; // Currently ringing (incoming call)
unsigned long _nextCallFlash; // Next LED toggle time
bool _callFlashState; // Current LED state during ring
#endif
NodePrefs* _node_prefs;
char _alert[80];
unsigned long _alert_expiry;
int _msgcount;
unsigned long ui_started_at, next_batt_chck;
uint8_t _low_batt_count = 0; // Consecutive low-voltage readings for debounce
int next_backlight_btn_check = 0;
#ifdef PIN_STATUS_LED
int led_state = 0;
@@ -70,10 +83,36 @@ class UITask : public AbstractUITask {
UIScreen* sms_screen; // SMS messaging screen (4G variant only)
#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
UIScreen* map_screen; // Map tile screen (GPS + SD card tiles)
UIScreen* curr;
bool _homeShowingTiles = false; // Set by HomeScreen render when tile grid is visible
int _tileGridVY = 44; // Virtual Y of tile grid top (updated each render)
#if defined(LilyGo_T5S3_EPaper_Pro)
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();
@@ -90,6 +129,11 @@ public:
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
next_batt_chck = _next_refresh = 0;
_kb_flash_off_at = 0;
#ifdef HAS_4G_MODEM
_incomingCallRinging = false;
_nextCallFlash = 0;
_callFlashState = false;
#endif
ui_started_at = 0;
curr = NULL;
}
@@ -104,6 +148,11 @@ public:
void gotoOnboarding(); // Navigate to settings in onboarding mode
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
#ifdef MECK_WEB_READER
void gotoWebReader(); // Navigate to web reader (browser)
#endif
@@ -114,16 +163,49 @@ public:
#endif
void showAlert(const char* text, int duration_millis) override;
void forceRefresh() override { _next_refresh = 100; }
// Wake display and extend auto-off timer. Call this when handling keys
// outside of injectKey() to prevent display auto-off during direct input.
void keepAlive() {
if (_display != NULL && !_display->isOn()) _display->turnOn();
_auto_off = millis() + 15000; // matches AUTO_OFF_MILLIS default
}
int getMsgCount() const { return _msgcount; }
int getUnreadMsgCount() const; // Per-channel unread tracking (standalone)
bool hasDisplay() const { return _display != NULL; }
bool isButtonPressed() const;
bool isOnChannelScreen() const { return curr == channel_screen; }
bool isOnContactsScreen() const { return curr == contacts_screen; }
bool isOnTextReader() const { return curr == text_reader; } // *** NEW ***
bool isOnHomeScreen() const { return curr == home; }
bool isHomeShowingTiles() const { return _homeShowingTiles; }
void setHomeShowingTiles(bool v) { _homeShowingTiles = v; }
int getTileGridVY() const { return _tileGridVY; }
void setTileGridVY(int vy) { _tileGridVY = vy; }
bool isOnNotesScreen() const { return curr == notes_screen; }
bool isOnSettingsScreen() const { return curr == settings_screen; }
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) || 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; }
#endif
@@ -141,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);
@@ -148,9 +232,13 @@ public:
// Add a sent message to the channel screen history
void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) override;
// Mark channel as read when BLE companion app syncs messages
void markChannelReadFromBLE(uint8_t channel_idx) override;
// Repeater admin callbacks
void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) override;
void onAdminCliResponse(const char* from_name, const char* text) override;
void onAdminTelemetryResult(const uint8_t* data, uint8_t len) override;
// Get current screen for checking state
UIScreen* getCurrentScreen() const { return curr; }
@@ -163,6 +251,9 @@ public:
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
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; }
#endif
@@ -170,7 +261,7 @@ public:
// from AbstractUITask
void msgRead(int msgcount) override;
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
const uint8_t* path = nullptr) override;
const uint8_t* path = nullptr, int8_t snr = 0) override;
void notify(UIEventType t = UIEventType::none) override;
void loop() override;

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
#pragma once
// Emoji Picker with scrolling grid and scroll bar
// 5 columns, 4 visible rows, scrollable through all 46 emoji
// 5 columns, 4 visible rows, scrollable through all 65 emoji
// WASD navigation, Enter to select, $/Q/Backspace to cancel
#include <helpers/ui/DisplayDriver.h>
@@ -58,6 +58,25 @@ static const char* EMOJI_LABELS[EMOJI_COUNT] = {
"Knob", // 43 control_knobs
"Pch", // 44 peach
"Race", // 45 racing_car
"Mous", // 46 mouse
"Shrm", // 47 mushroom
"Bio", // 48 biohazard
"Pnda", // 49 panda
"Bang", // 50 anger
"DrgF", // 51 dragon_face
"Pagr", // 52 pager
"Bee", // 53 bee
"Bulb", // 54 bulb
"Cat", // 55 cat
"Flur", // 56 fleur
"Moon", // 57 moon
"Cafe", // 58 coffee
"Toth", // 59 tooth
"Prtz", // 60 pretzel
"Abac", // 61 abacus
"Moai", // 62 moai
"Hiii", // 63 tipping
"Hedg", // 64 hedgehog
};
struct EmojiPicker {

View File

@@ -0,0 +1,49 @@
#pragma once
// =============================================================================
// HomeIcons — 12x12 icon sprites for T5S3 home screen tiles
// MSB-first, 2 bytes per row (same format as emoji sprites)
// =============================================================================
#include <stdint.h>
#ifdef ESP32
#include <pgmspace.h>
#endif
#define HOME_ICON_W 12
#define HOME_ICON_H 12
// ✉️ Envelope (Messages)
static const uint8_t icon_envelope[] PROGMEM = {
0xFF,0xF0, 0x80,0x10, 0xC0,0x30, 0xA0,0x50, 0x90,0x90, 0x89,0x10,
0x86,0x10, 0x80,0x10, 0x80,0x10, 0x80,0x10, 0x80,0x10, 0xFF,0xF0,
};
// 👥 People (Contacts)
static const uint8_t icon_people[] PROGMEM = {
0x31,0x80, 0x7B,0xC0, 0x7B,0xC0, 0x31,0x80, 0x00,0x00, 0x7B,0xC0,
0xFD,0xE0, 0xFD,0xE0, 0x7B,0xC0, 0x00,0x00, 0x00,0x00, 0x00,0x00,
};
// 🎚 Sliders (Settings)
static const uint8_t icon_gear[] PROGMEM = {
0x22,0x20, 0x22,0x20, 0x72,0x70, 0x72,0x70, 0x27,0x20, 0x27,0x20,
0x22,0x20, 0x72,0x20, 0x72,0x70, 0x22,0x70, 0x22,0x20, 0x22,0x20,
};
// 📖 Book (Reader)
static const uint8_t icon_book[] PROGMEM = {
0x7F,0xC0, 0x41,0x40, 0x5D,0x40, 0x5D,0x40, 0x41,0x40, 0x5D,0x40,
0x5D,0x40, 0x41,0x40, 0x5D,0x40, 0x41,0x40, 0x7F,0xC0, 0x00,0x00,
};
// 🗒 Notepad (Notes)
static const uint8_t icon_notepad[] PROGMEM = {
0x3F,0xC0, 0x20,0x40, 0x2F,0x40, 0x20,0x40, 0x2F,0x40, 0x20,0x40,
0x2F,0x40, 0x20,0x40, 0x2F,0x40, 0x20,0x40, 0x3F,0xC0, 0x00,0x00,
};
// 🔍 Magnifying glass (Discover)
static const uint8_t icon_search[] PROGMEM = {
0x3C,0x00, 0x42,0x00, 0x81,0x00, 0x81,0x00, 0x81,0x00, 0x42,0x00,
0x3C,0x00, 0x03,0x00, 0x01,0x80, 0x00,0xC0, 0x00,0x40, 0x00,0x00,
};

View File

@@ -0,0 +1,365 @@
#pragma once
// =============================================================================
// VirtualKeyboard — On-screen QWERTY keyboard for T5S3 (touch-only devices)
//
// Renders in virtual coordinate space (128×128). Touch hit testing converts
// physical GT911 coords (960×540) to virtual coords.
//
// Usage:
// keyboard.open("To: General", "", 137); // label, initial text, max len
// keyboard.render(display); // in render loop
// keyboard.handleTap(vx, vy); // on touch tap (virtual coords)
// if (keyboard.status() == VKB_SUBMITTED) { ... keyboard.getText() ... }
// =============================================================================
#if defined(LilyGo_T5S3_EPaper_Pro)
#ifndef VIRTUAL_KEYBOARD_H
#define VIRTUAL_KEYBOARD_H
#include <Arduino.h>
#include <helpers/ui/DisplayDriver.h>
enum VKBStatus { VKB_EDITING, VKB_SUBMITTED, VKB_CANCELLED };
// What the keyboard is being used for (dispatch on submit)
enum VKBPurpose {
VKB_CHANNEL_MSG, // Send to channel
VKB_DM, // Direct message to contact
VKB_ADMIN_PASSWORD, // Repeater admin login
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
VKB_WEB_SEARCH, // Web reader DuckDuckGo search query
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 {
public:
static const int MAX_TEXT = 140;
VirtualKeyboard() : _status(VKB_CANCELLED), _purpose(VKB_CHANNEL_MSG),
_contextIdx(0), _textLen(0), _shifted(false), _symbols(false) {
_text[0] = '\0';
_label[0] = '\0';
}
void open(VKBPurpose purpose, const char* label, const char* initial, int maxLen, int contextIdx = 0) {
_purpose = purpose;
_contextIdx = contextIdx;
_status = VKB_EDITING;
_shifted = false;
_symbols = false;
_maxLen = (maxLen > 0 && maxLen < MAX_TEXT) ? maxLen : MAX_TEXT;
strncpy(_label, label, sizeof(_label) - 1);
_label[sizeof(_label) - 1] = '\0';
if (initial && initial[0]) {
strncpy(_text, initial, _maxLen);
_text[_maxLen] = '\0';
_textLen = strlen(_text);
} else {
_text[0] = '\0';
_textLen = 0;
}
}
VKBStatus status() const { return _status; }
VKBPurpose purpose() const { return _purpose; }
int contextIdx() const { return _contextIdx; }
const char* getText() const { return _text; }
int getTextLen() const { return _textLen; }
bool isActive() const { return _status == VKB_EDITING; }
// --- Render keyboard + input field ---
void render(DisplayDriver& display) {
// Header label (To: channel, DM: name, etc.)
display.setTextSize(0);
display.setColor(DisplayDriver::GREEN);
display.setCursor(2, 0);
display.print(_label);
// Input text field
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 10, 128, 18); // Border
display.setCursor(2, 12);
display.setColor(DisplayDriver::LIGHT);
// Show text with cursor
char dispBuf[MAX_TEXT + 2];
snprintf(dispBuf, sizeof(dispBuf), "%s_", _text);
display.print(dispBuf);
// Character count
{
char countBuf[12];
snprintf(countBuf, sizeof(countBuf), "%d/%d", _textLen, _maxLen);
int cw = display.getTextWidth(countBuf);
display.setCursor(128 - cw - 2, 0);
display.setColor(DisplayDriver::LIGHT);
display.print(countBuf);
}
// Separator
display.drawRect(0, 30, 128, 1);
// --- Draw keyboard rows ---
const char* const* layout = getLayout();
for (int row = 0; row < 3; row++) {
int numKeys = strlen(layout[row]);
int rowY = KEY_START_Y + row * (KEY_H + KEY_GAP);
// Calculate key width and starting X for this row
int totalW = numKeys * KEY_W + (numKeys - 1) * KEY_GAP;
int startX = (128 - totalW) / 2;
for (int k = 0; k < numKeys; k++) {
int kx = startX + k * (KEY_W + KEY_GAP);
char ch = layout[row][k];
// Draw key background (inverted for special keys)
bool special = (ch == '<' || ch == '^' || ch == '~' || ch == '>' || ch == '\x01');
if (special) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(kx, rowY + 1, KEY_W, KEY_H - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(kx, rowY + 1, KEY_W, KEY_H - 1);
}
// Draw key label
char keyLabel[2] = { ch, '\0' };
// Remap special chars to display labels
if (ch == '<') keyLabel[0] = '<'; // Backspace
if (ch == '^') keyLabel[0] = '^'; // Shift
if (ch == '>') keyLabel[0] = '>'; // Enter
if (ch == '~') {
// Space key — don't draw individual label
} else if (ch == '\x01') {
// Symbol toggle in row — show "ab" hint
int lx = kx + KEY_W / 2 - display.getTextWidth("ab") / 2;
display.setCursor(lx, rowY + 2);
display.print("ab");
} else {
int lx = kx + KEY_W / 2 - display.getTextWidth(keyLabel) / 2;
display.setCursor(lx, rowY + 2);
display.print(keyLabel);
}
// Restore color
display.setColor(DisplayDriver::LIGHT);
}
}
// Draw row 4 with variable-width keys
int r4y = KEY_START_Y + 3 * (KEY_H + KEY_GAP);
drawRow4(display, r4y);
// Shift/symbol indicator
display.setTextSize(0);
display.setColor(DisplayDriver::GREEN);
if (_shifted) {
display.setCursor(2, 126);
display.print("SHIFT");
} else if (_symbols) {
display.setCursor(2, 126);
display.print("123");
}
}
// --- Handle touch tap (virtual coordinates) ---
// Returns true if the tap was consumed
bool handleTap(int vx, int vy) {
if (_status != VKB_EDITING) return false;
// Check keyboard rows 0-2
const char* const* layout = getLayout();
for (int row = 0; row < 3; row++) {
int numKeys = strlen(layout[row]);
int rowY = KEY_START_Y + row * (KEY_H + KEY_GAP);
if (vy < rowY || vy >= rowY + KEY_H) continue;
int totalW = numKeys * KEY_W + (numKeys - 1) * KEY_GAP;
int startX = (128 - totalW) / 2;
for (int k = 0; k < numKeys; k++) {
int kx = startX + k * (KEY_W + KEY_GAP);
if (vx >= kx && vx < kx + KEY_W) {
char ch = layout[row][k];
processKey(ch);
return true;
}
}
return true; // Tap was in row area but between keys — consume
}
// Check row 4 (variable width keys)
int r4y = KEY_START_Y + 3 * (KEY_H + KEY_GAP);
if (vy >= r4y && vy < r4y + KEY_H) {
return handleRow4Tap(vx);
}
return false;
}
// 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;
int _contextIdx;
char _text[MAX_TEXT + 1];
int _textLen;
int _maxLen;
char _label[40];
bool _shifted;
bool _symbols;
// Layout constants (virtual coords)
static const int KEY_W = 11;
static const int KEY_H = 19;
static const int KEY_GAP = 1;
static const int KEY_START_Y = 34;
// Key layouts — rows 0-2 as char arrays
// Special: ^ = shift, < = backspace, # = symbols, > = enter, ~ = space
const char* const* getLayout() const {
static const char* const lower[3] = { "qwertyuiop", "asdfghjkl", "^zxcvbnm<" };
static const char* const upper[3] = { "QWERTYUIOP", "ASDFGHJKL", "^ZXCVBNM<" };
static const char* const syms[3] = { "1234567890", "-/:;()@$&#", "\x01.,?!'\"_<" };
return _symbols ? syms : (_shifted ? upper : lower);
}
// Row 4: variable-width keys [#/ABC] [,] [SPACE] [.] [Enter]
// Defined by physical zones, not the char-array approach
struct R4Key { int x; int w; char ch; const char* label; };
void drawRow4(DisplayDriver& display, int y) {
// # or ABC toggle: x=4, w=20
// comma: x=26, w=11
// space: x=39, w=50
// period: x=91, w=11
// enter: x=104, w=20
const R4Key keys[] = {
{ 4, 20, '\x01', _symbols ? "ABC" : "123" },
{ 26, 11, ',', "," },
{ 39, 50, '~', "space" },
{ 91, 11, '.', "." },
{ 104, 20, '>', "Send" }
};
for (int i = 0; i < 5; i++) {
bool special = (keys[i].ch == '\x01' || keys[i].ch == '>');
if (special) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(keys[i].x, y + 1, keys[i].w, KEY_H - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(keys[i].x, y + 1, keys[i].w, KEY_H - 1);
}
// Center label in key
display.setTextSize(0);
int lw = display.getTextWidth(keys[i].label);
int lx = keys[i].x + (keys[i].w - lw) / 2;
display.setCursor(lx, y + 2);
display.print(keys[i].label);
display.setColor(DisplayDriver::LIGHT);
}
}
bool handleRow4Tap(int vx) {
const R4Key keys[] = {
{ 4, 20, '\x01', nullptr },
{ 26, 11, ',', nullptr },
{ 39, 50, '~', nullptr },
{ 91, 11, '.', nullptr },
{ 104, 20, '>', nullptr }
};
for (int i = 0; i < 5; i++) {
if (vx >= keys[i].x && vx < keys[i].x + keys[i].w) {
processKey(keys[i].ch);
return true;
}
}
return true; // Consume tap in row area
}
void processKey(char ch) {
if (ch == '^') {
// Shift toggle
_shifted = !_shifted;
_symbols = false;
} else if (ch == '\x01') {
// Symbol/letter toggle
_symbols = !_symbols;
_shifted = false;
} else if (ch == '<') {
// Backspace
if (_textLen > 0) {
_textLen--;
_text[_textLen] = '\0';
}
} else if (ch == '>') {
// Enter/Send
_status = VKB_SUBMITTED;
} else if (ch == '~') {
// Space
if (_textLen < _maxLen) {
_text[_textLen++] = ' ';
_text[_textLen] = '\0';
}
} else {
// Regular character
if (_textLen < _maxLen) {
_text[_textLen++] = ch;
_text[_textLen] = '\0';
// Auto-unshift after typing one character
if (_shifted) _shifted = false;
}
}
}
};
#endif // VIRTUAL_KEYBOARD_H
#endif // LilyGo_T5S3_EPaper_Pro

View File

@@ -1,8 +1,11 @@
#include "UITask.h"
#include <helpers/TxtDataHelpers.h>
#include "../MyMesh.h"
#include "NotesScreen.h"
#include "RepeaterAdminScreen.h"
#include "MapScreen.h"
#include "target.h"
#ifdef WIFI_SSID
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
#include <WiFi.h>
#endif
@@ -34,11 +37,18 @@
#include "ContactsScreen.h"
#include "TextReaderScreen.h"
#include "SettingsScreen.h"
#ifdef MECK_AUDIO_VARIANT
#include "AudiobookPlayerScreen.h"
#endif
#ifdef HAS_4G_MODEM
#include "SMSScreen.h"
#include "ModemManager.h"
#endif
class SplashScreen : public UIScreen {
UITask* _task;
unsigned long dismiss_after;
char _version_info[12];
char _version_info[24];
public:
SplashScreen(UITask* task) : _task(task) {
@@ -85,13 +95,20 @@ class HomeScreen : public UIScreen {
FIRST,
RECENT,
RADIO,
#ifdef BLE_PIN_CODE
BLUETOOTH,
#elif defined(MECK_WIFI_COMPANION)
WIFI_STATUS,
#endif
ADVERT,
#if ENV_INCLUDE_GPS == 1
GPS,
#endif
#if UI_SENSORS_PAGE == 1
SENSORS,
#endif
#if HAS_BQ27220
BATTERY,
#endif
SHUTDOWN,
Count // keep as last
@@ -103,12 +120,13 @@ class HomeScreen : public UIScreen {
NodePrefs* _node_prefs;
uint8_t _page;
bool _shutdown_init;
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];
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int* outIconX = nullptr) {
// Use voltage-based estimation to match BLE app readings
uint8_t batteryPercentage = 0;
if (batteryMilliVolts > 0) {
@@ -137,6 +155,8 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
int iconX = display.width() - totalWidth;
int iconY = 0; // vertically align with node name text
if (outIconX) *outIconX = iconX;
// battery outline
display.drawRect(iconX, iconY, iconWidth, iconHeight);
@@ -156,6 +176,24 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
display.setTextSize(1); // restore default text size
}
#ifdef MECK_AUDIO_VARIANT
// ---- Audio background playback indicator ----
// Shows a small play symbol to the left of the battery icon when an
// audiobook is actively playing in the background.
// Uses the font renderer (not manual pixel drawing) since it handles
// the e-ink coordinate scaling correctly.
void renderAudioIndicator(DisplayDriver& display, int batteryLeftX) {
if (!_task->isAudioPlayingInBackground()) return;
display.setColor(DisplayDriver::GREEN);
display.setTextSize(0); // tiny font (same as clock & battery %)
int x = batteryLeftX - display.getTextWidth(">>") - 2;
display.setCursor(x, -3); // align vertically with battery text
display.print(">>");
display.setTextSize(1); // restore
}
#endif
CayenneLPP sensors_lpp;
int sensors_nb = 0;
bool sensors_scroll = false;
@@ -186,7 +224,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
public:
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs)
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
_shutdown_init(false), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
_shutdown_init(false), _shutdown_at(0), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
bool isEditingUTC() const { return _editing_utc; }
void cancelEditUTC() {
@@ -197,23 +235,31 @@ public:
}
void poll() override {
if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released
if (_shutdown_init && millis() >= _shutdown_at && !_task->isButtonPressed()) {
_task->shutdown();
}
}
int render(DisplayDriver& display) override {
char tmp[80];
// node name
display.setTextSize(1);
// node name (tinyfont to avoid overlapping clock)
display.setTextSize(0);
display.setColor(DisplayDriver::GREEN);
char filtered_name[sizeof(_node_prefs->node_name)];
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
display.setCursor(0, 0);
display.setCursor(0, -3);
display.print(filtered_name);
// battery voltage
#ifdef MECK_AUDIO_VARIANT
int battLeftX = display.width(); // default if battery doesn't render
renderBatteryIndicator(display, _task->getBattMilliVolts(), &battLeftX);
// audio background playback indicator (>> icon next to battery)
renderAudioIndicator(display, battLeftX);
#else
renderBatteryIndicator(display, _task->getBattMilliVolts());
#endif
// centered clock (tinyfont) - only show when time is valid
{
@@ -250,28 +296,70 @@ public:
}
if (_page == HomePage::FIRST) {
int y = 20;
display.setColor(DisplayDriver::YELLOW);
display.setTextSize(2);
sprintf(tmp, "MSG: %d", _task->getMsgCount());
display.drawTextCentered(display.width() / 2, 20, tmp);
sprintf(tmp, "MSG: %d", _task->getUnreadMsgCount());
display.drawTextCentered(display.width() / 2, y, tmp);
y += 18;
#ifdef WIFI_SSID
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
IPAddress ip = WiFi.localIP();
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 54, tmp);
if (ip != IPAddress(0,0,0,0)) {
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d:%d", ip[0], ip[1], ip[2], ip[3], TCP_PORT);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, y, tmp);
y += 12;
}
#endif
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 43, "< Connected >");
} else if (the_mesh.getBLEPin() != 0) { // BT pin
display.drawTextCentered(display.width() / 2, y, "< Connected >");
y += 12;
#ifdef BLE_PIN_CODE
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
display.setColor(DisplayDriver::RED);
display.setTextSize(2);
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
display.drawTextCentered(display.width() / 2, 43, tmp);
display.drawTextCentered(display.width() / 2, y, tmp);
y += 18;
#endif
}
#endif
// Menu shortcuts - tinyfont monospaced grid
y += 6;
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(0); // tinyfont 6x8 monospaced
display.drawTextCentered(display.width() / 2, y, "Press:");
y += 12;
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts ");
y += 10;
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
y += 10;
display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps ");
y += 10;
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[T] Phone [B] Browser ");
#elif defined(HAS_4G_MODEM)
display.drawTextCentered(display.width() / 2, y, "[T] Phone ");
#elif defined(MECK_AUDIO_VARIANT) && defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [B] Browser ");
#elif defined(MECK_AUDIO_VARIANT)
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks ");
#elif defined(MECK_WEB_READER)
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
#else
y -= 10; // reclaim the row for standalone
#endif
y += 14;
// Nav hint
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, y, "Press A/D to cycle home views");
display.setTextSize(1); // restore
} else if (_page == HomePage::RECENT) {
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
display.setColor(DisplayDriver::GREEN);
@@ -316,34 +404,83 @@ public:
display.setCursor(0, 53);
sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
display.print(tmp);
#ifdef BLE_PIN_CODE
} else if (_page == HomePage::BLUETOOTH) {
display.setColor(DisplayDriver::GREEN);
display.drawXbm((display.width() - 32) / 2, 18,
_task->isSerialEnabled() ? bluetooth_on : bluetooth_off,
32, 32);
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 53, "< Connected >");
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
display.setColor(DisplayDriver::RED);
display.setTextSize(2);
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
display.drawTextCentered(display.width() / 2, 53, tmp);
}
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL);
display.drawTextCentered(display.width() / 2, 72, "toggle: " PRESS_LABEL);
#endif
#ifdef MECK_WIFI_COMPANION
} else if (_page == HomePage::WIFI_STATUS) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 18, "WiFi Companion");
int wy = 36;
display.setTextSize(0);
if (WiFi.status() == WL_CONNECTED) {
display.setColor(DisplayDriver::GREEN);
snprintf(tmp, sizeof(tmp), "SSID: %s", WiFi.SSID().c_str());
display.drawTextCentered(display.width() / 2, wy, tmp);
wy += 10;
IPAddress ip = WiFi.localIP();
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
display.drawTextCentered(display.width() / 2, wy, tmp);
wy += 10;
snprintf(tmp, sizeof(tmp), "Port: %d", TCP_PORT);
display.drawTextCentered(display.width() / 2, wy, tmp);
wy += 12;
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, wy, "< App Connected >");
} else {
display.setColor(DisplayDriver::YELLOW);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, wy, "Waiting for app...");
}
} else {
display.setColor(DisplayDriver::RED);
display.drawTextCentered(display.width() / 2, wy, "Not connected");
wy += 12;
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, wy, "Configure in Settings");
}
display.setTextSize(1);
#endif
} else if (_page == HomePage::ADVERT) {
display.setColor(DisplayDriver::GREEN);
display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL);
#if ENV_INCLUDE_GPS == 1
} else if (_page == HomePage::GPS) {
extern GPSStreamCounter gpsStream;
LocationProvider* nmea = sensors.getLocationProvider();
char buf[50];
int y = 18;
bool gps_state = _task->getGPSState();
#ifdef PIN_GPS_SWITCH
bool hw_gps_state = digitalRead(PIN_GPS_SWITCH);
if (gps_state != hw_gps_state) {
strcpy(buf, gps_state ? "gps off(hw)" : "gps off(sw)");
// GPS state line
if (!_node_prefs->gps_enabled) {
strcpy(buf, "gps off");
} else {
strcpy(buf, gps_state ? "gps on" : "gps off");
strcpy(buf, "gps on");
}
#else
strcpy(buf, gps_state ? "gps on" : "gps off");
#endif
display.drawTextLeftAlign(0, y, buf);
if (nmea == NULL) {
y = y + 12;
display.drawTextLeftAlign(0, y, "Can't access GPS");
@@ -355,6 +492,19 @@ public:
sprintf(buf, "%d", nmea->satellitesCount());
display.drawTextRightAlign(display.width()-1, y, buf);
y = y + 12;
// NMEA sentence counter — confirms baud rate and data flow
display.drawTextLeftAlign(0, y, "sentences");
if (_node_prefs->gps_enabled) {
uint16_t sps = gpsStream.getSentencesPerSec();
uint32_t total = gpsStream.getSentenceCount();
sprintf(buf, "%u/s (%lu)", sps, (unsigned long)total);
} else {
strcpy(buf, "hw off");
}
display.drawTextRightAlign(display.width()-1, y, buf);
y = y + 12;
display.drawTextLeftAlign(0, y, "pos");
sprintf(buf, "%.4f %.4f",
nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.);
@@ -473,6 +623,68 @@ public:
}
if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb;
else sensors_scroll_offset = 0;
#endif
#if HAS_BQ27220
} else if (_page == HomePage::BATTERY) {
char buf[30];
int y = 18;
// Title
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, y, "Battery Gauge");
y += 12;
display.setColor(DisplayDriver::LIGHT);
// Time to empty
uint16_t tte = board.getTimeToEmpty();
display.drawTextLeftAlign(0, y, "remaining");
if (tte == 0xFFFF || tte == 0) {
strcpy(buf, tte == 0 ? "depleted" : "charging");
} else if (tte >= 60) {
sprintf(buf, "%dh %dm", tte / 60, tte % 60);
} else {
sprintf(buf, "%d min", tte);
}
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Average current
int16_t avgCur = board.getAvgCurrent();
display.drawTextLeftAlign(0, y, "avg current");
sprintf(buf, "%d mA", avgCur);
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Average power
int16_t avgPow = board.getAvgPower();
display.drawTextLeftAlign(0, y, "avg power");
sprintf(buf, "%d mW", avgPow);
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Voltage (already available)
uint16_t mv = board.getBattMilliVolts();
display.drawTextLeftAlign(0, y, "voltage");
sprintf(buf, "%d.%03d V", mv / 1000, mv % 1000);
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Remaining capacity (clamped to design capacity — gauge FCC may be
// stale from factory defaults until a full charge cycle re-learns it)
uint16_t remCap = board.getRemainingCapacity();
uint16_t desCap = board.getDesignCapacity();
if (desCap > 0 && remCap > desCap) remCap = desCap;
display.drawTextLeftAlign(0, y, "remaining cap");
sprintf(buf, "%d mAh", remCap);
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Battery temperature
int16_t battTemp = board.getBattTemperature();
display.drawTextLeftAlign(0, y, "temperature");
sprintf(buf, "%d.%d C", battTemp / 10, abs(battTemp % 10));
display.drawTextRightAlign(display.width()-1, y, buf);
#endif
} else if (_page == HomePage::SHUTDOWN) {
display.setColor(DisplayDriver::GREEN);
@@ -533,6 +745,7 @@ public:
}
return true;
}
#ifdef BLE_PIN_CODE
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
if (_task->isSerialEnabled()) { // toggle Bluetooth on/off
_task->disableSerial();
@@ -541,6 +754,7 @@ public:
}
return true;
}
#endif
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
_task->notify(UIEventType::ack);
if (the_mesh.advert()) {
@@ -569,7 +783,8 @@ public:
}
#endif
if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) {
_shutdown_init = true; // need to wait for button to be released
_shutdown_init = true;
_shutdown_at = millis() + 900; // allow e-ink refresh (644ms) before shutdown
return true;
}
return false;
@@ -708,6 +923,17 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
vibration.begin();
#endif
// Keyboard backlight for message flash notifications
#ifdef KB_BL_PIN
pinMode(KB_BL_PIN, OUTPUT);
digitalWrite(KB_BL_PIN, LOW);
#endif
#ifdef HAS_4G_MODEM
// Sync ringtone enabled state to modem manager
modemManager.setRingtoneEnabled(node_prefs->ringtone_enabled);
#endif
ui_started_at = millis();
_alert_expiry = 0;
@@ -717,7 +943,14 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
channel_screen = new ChannelScreen(this, &rtc_clock);
contacts_screen = new ContactsScreen(this, &rtc_clock);
text_reader = new TextReaderScreen(this);
notes_screen = new NotesScreen(this);
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
#ifdef HAS_4G_MODEM
sms_screen = new SMSScreen(this);
#endif
map_screen = new MapScreen(this);
setCurrScreen(splash);
}
@@ -759,12 +992,13 @@ switch(t){
void UITask::msgRead(int msgcount) {
_msgcount = msgcount;
if (msgcount == 0) {
if (msgcount == 0 && curr == msg_preview) {
gotoHomeScreen();
}
}
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
const uint8_t* path) {
_msgcount = msgcount;
// Add to preview screen (for notifications on non-keyboard devices)
@@ -782,15 +1016,25 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
}
}
// Add to channel history screen with channel index
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text);
// Add to channel history screen with channel index and path data
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path);
// If user is currently viewing this channel, mark it as read immediately
// (they can see the message arrive in real-time)
if (isOnChannelScreen() &&
((ChannelScreen *) channel_screen)->getViewChannelIdx() == channel_idx) {
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
}
#if defined(LilyGo_TDeck_Pro)
// T-Deck Pro: Don't interrupt user with popup - just show brief notification
// Messages are stored in channel history, accessible via 'M' key
char alertBuf[40];
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
showAlert(alertBuf, 2000);
// Suppress alert entirely on admin screen - it needs focused interaction
if (!isOnRepeaterAdmin()) {
char alertBuf[40];
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
showAlert(alertBuf, 2000);
}
#else
// Other devices: Show full preview screen (legacy behavior)
setCurrScreen(msg_preview);
@@ -805,6 +1049,14 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
_next_refresh = 100; // trigger refresh
}
}
// Keyboard flash notification
#ifdef KB_BL_PIN
if (_node_prefs->kb_flash_notify) {
digitalWrite(KB_BL_PIN, HIGH);
_kb_flash_off_at = millis() + 200; // 200ms flash
}
#endif
}
void UITask::userLedHandler() {
@@ -854,8 +1106,32 @@ void UITask::shutdown(bool restart){
if (restart) {
_board->reboot();
} else {
_display->turnOff();
// Disable BLE if active
if (_serial != NULL && _serial->isEnabled()) {
_serial->disable();
}
// Disable WiFi if active
#ifdef WIFI_SSID
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
#endif
// Disable GPS if active
#if ENV_INCLUDE_GPS == 1
{
if (_sensors != NULL && _node_prefs != NULL && _node_prefs->gps_enabled) {
_sensors->setSettingValue("gps", "0");
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
#endif
}
}
#endif
// Power off LoRa radio, display, and board
radio_driver.powerOff();
_display->turnOff();
_board->powerOff();
}
}
@@ -940,6 +1216,63 @@ void UITask::loop() {
userLedHandler();
// Turn off keyboard flash after timeout
#ifdef KB_BL_PIN
if (_kb_flash_off_at && millis() >= _kb_flash_off_at) {
#ifdef HAS_4G_MODEM
// Don't turn off LED if incoming call flash is active
if (!_incomingCallRinging) {
digitalWrite(KB_BL_PIN, LOW);
}
#else
digitalWrite(KB_BL_PIN, LOW);
#endif
_kb_flash_off_at = 0;
}
#endif
// Incoming call LED flash — rapid repeated pulse while ringing
#if defined(HAS_4G_MODEM) && defined(KB_BL_PIN)
{
bool ringing = modemManager.isRinging();
if (ringing && !_incomingCallRinging) {
// Ringing just started
_incomingCallRinging = true;
_callFlashState = false;
_nextCallFlash = 0; // Start immediately
// Wake display for incoming call
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + 60000; // Keep display on while ringing (60s)
} else if (!ringing && _incomingCallRinging) {
// Ringing stopped
_incomingCallRinging = false;
// Only turn off LED if message flash isn't also active
if (!_kb_flash_off_at) {
digitalWrite(KB_BL_PIN, LOW);
}
_callFlashState = false;
}
// Rapid LED flash while ringing (if kb_flash_notify is ON)
if (_incomingCallRinging && _node_prefs->kb_flash_notify) {
unsigned long now = millis();
if (now >= _nextCallFlash) {
_callFlashState = !_callFlashState;
digitalWrite(KB_BL_PIN, _callFlashState ? HIGH : LOW);
// 250ms on, 250ms off — fast pulse to distinguish from single msg flash
_nextCallFlash = now + 250;
}
// Extend auto-off while ringing
_auto_off = millis() + 60000;
}
}
#endif
#ifdef PIN_BUZZER
if (buzzer.isPlaying()) buzzer.loop();
#endif
@@ -980,10 +1313,11 @@ if (curr) curr->poll();
if (millis() > next_batt_chck) {
uint16_t milliVolts = getBattMilliVolts();
if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) {
_low_batt_count++;
if (_low_batt_count >= 3) { // 3 consecutive low readings (~24s) to avoid transient sags
// show low battery shutdown alert
// we should only do this for eink displays, which will persist after power loss
#if defined(THINKNODE_M1) || defined(LILYGO_TECHO)
// show low battery shutdown alert on e-ink (persists after power loss)
#if defined(THINKNODE_M1) || defined(LILYGO_TECHO) || defined(LilyGo_TDeck_Pro)
if (_display != NULL) {
_display->startFrame();
_display->setTextSize(2);
@@ -995,7 +1329,9 @@ if (curr) curr->poll();
#endif
shutdown();
}
} else {
_low_batt_count = 0;
}
next_batt_chck = millis() + 8000;
}
@@ -1037,39 +1373,38 @@ char UITask::handleTripleClick(char c) {
}
bool UITask::getGPSState() {
if (_sensors != NULL) {
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) {
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
return !strcmp(_sensors->getSettingValue(i), "1");
}
}
}
return false;
#if ENV_INCLUDE_GPS == 1
return _node_prefs != NULL && _node_prefs->gps_enabled;
#else
return false;
#endif
}
void UITask::toggleGPS() {
#if ENV_INCLUDE_GPS == 1
if (_sensors != NULL) {
// toggle GPS on/off
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) {
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
if (strcmp(_sensors->getSettingValue(i), "1") == 0) {
_sensors->setSettingValue("gps", "0");
_node_prefs->gps_enabled = 0;
notify(UIEventType::ack);
} else {
_sensors->setSettingValue("gps", "1");
_node_prefs->gps_enabled = 1;
notify(UIEventType::ack);
}
the_mesh.savePrefs();
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
_next_refresh = 0;
break;
if (_node_prefs->gps_enabled) {
// Disable GPS — cut hardware power
_sensors->setSettingValue("gps", "0");
_node_prefs->gps_enabled = 0;
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
#endif
notify(UIEventType::ack);
} else {
// Enable GPS — power on hardware
_sensors->setSettingValue("gps", "1");
_node_prefs->gps_enabled = 1;
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
#endif
notify(UIEventType::ack);
}
the_mesh.savePrefs();
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
_next_refresh = 0;
}
}
#endif
}
void UITask::toggleBuzzer() {
@@ -1126,6 +1461,10 @@ bool UITask::isEditingHomeScreen() const {
void UITask::gotoChannelScreen() {
((ChannelScreen *) channel_screen)->resetScroll();
// Mark the currently viewed channel as read
((ChannelScreen *) channel_screen)->markChannelRead(
((ChannelScreen *) channel_screen)->getViewChannelIdx()
);
setCurrScreen(channel_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
@@ -1157,8 +1496,21 @@ void UITask::gotoTextReader() {
_next_refresh = 100;
}
void UITask::gotoNotesScreen() {
NotesScreen* notes = (NotesScreen*)notes_screen;
if (_display != NULL) {
notes->enter(*_display);
}
setCurrScreen(notes_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoSettingsScreen() {
((SettingsScreen*)settings_screen)->enter();
((SettingsScreen *) settings_screen)->enter();
setCurrScreen(settings_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
@@ -1168,7 +1520,7 @@ void UITask::gotoSettingsScreen() {
}
void UITask::gotoOnboarding() {
((SettingsScreen*)settings_screen)->enterOnboarding();
((SettingsScreen *) settings_screen)->enterOnboarding();
setCurrScreen(settings_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
@@ -1177,10 +1529,43 @@ void UITask::gotoOnboarding() {
_next_refresh = 100;
}
void UITask::gotoAudiobookPlayer() {
#ifdef MECK_AUDIO_VARIANT
if (audiobook_screen == nullptr) return; // No audio hardware
AudiobookPlayerScreen* abPlayer = (AudiobookPlayerScreen*)audiobook_screen;
if (_display != NULL) {
abPlayer->enter(*_display);
}
setCurrScreen(audiobook_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
#endif
}
#ifdef HAS_4G_MODEM
void UITask::gotoSMSScreen() {
SMSScreen* smsScr = (SMSScreen*)sms_screen;
smsScr->activate();
setCurrScreen(sms_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#endif
uint8_t UITask::getChannelScreenViewIdx() const {
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
}
int UITask::getUnreadMsgCount() const {
return ((ChannelScreen *) channel_screen)->getTotalUnread();
}
void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {
// Format the message as "Sender: message"
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
@@ -1188,4 +1573,109 @@ void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, cons
// Add to channel history with path_len=0 (local message)
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
}
}
void UITask::markChannelReadFromBLE(uint8_t channel_idx) {
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
// Trigger a refresh so the home screen unread count updates in real-time
_next_refresh = millis() + 200;
}
void UITask::gotoRepeaterAdmin(int contactIdx) {
// Lazy-initialize on first use (same pattern as audiobook player)
if (repeater_admin == nullptr) {
repeater_admin = new RepeaterAdminScreen(this, &rtc_clock);
}
// Get contact name for the screen header
ContactInfo contact;
char name[32] = "Unknown";
if (the_mesh.getContactByIdx(contactIdx, contact)) {
strncpy(name, contact.name, sizeof(name) - 1);
name[sizeof(name) - 1] = '\0';
}
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)repeater_admin;
admin->openForContact(contactIdx, name);
setCurrScreen(repeater_admin);
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)
if (web_reader == nullptr) {
Serial.printf("WebReader: lazy init - free heap: %d, largest block: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
web_reader = new WebReaderScreen(this);
Serial.printf("WebReader: init complete - free heap: %d\n", ESP.getFreeHeap());
}
WebReaderScreen* wr = (WebReaderScreen*)web_reader;
if (_display != NULL) {
wr->enter(*_display);
}
// Heap diagnostic — check state after web reader entry (WiFi connects later)
Serial.printf("[HEAP] WebReader enter - free: %u, largest: %u, PSRAM: %u\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap(), ESP.getFreePsram());
setCurrScreen(web_reader);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#endif
void UITask::gotoMapScreen() {
MapScreen* map = (MapScreen*)map_screen;
if (_display != NULL) {
map->enter(*_display);
}
setCurrScreen(map_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
_next_refresh = 100; // trigger re-render
}
}
void UITask::onAdminCliResponse(const char* from_name, const char* text) {
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onCliResponse(text);
_next_refresh = 100; // trigger re-render
}
}
void UITask::onAdminTelemetryResult(const uint8_t* data, uint8_t len) {
Serial.printf("[UITask] onAdminTelemetryResult: %d bytes, onAdmin=%d\n", len, isOnRepeaterAdmin());
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onTelemetryResult(data, len);
_next_refresh = 100; // trigger re-render
}
}
#ifdef MECK_AUDIO_VARIANT
bool UITask::isAudioPlayingInBackground() const {
if (!audiobook_screen) return false;
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
return player->isAudioActive();
}
bool UITask::isAudioPausedInBackground() const {
if (!audiobook_screen) return false;
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
return player->isBookOpen() && !player->isAudioActive();
}
#endif

65
merge_firmware.py Normal file
View File

@@ -0,0 +1,65 @@
"""
PlatformIO post-build script: merge bootloader + partitions + firmware
into a single flashable binary.
Output: .pio/build/<env>/firmware_merged.bin
Flash: esptool.py --chip esp32s3 write_flash 0x0 firmware_merged.bin
Place this file in the project root alongside platformio.ini.
Add to each environment (or the base section):
extra_scripts = post:merge_firmware.py
"""
Import("env")
def merge_bin(source, target, env):
import subprocess, os
build_dir = env.subst("$BUILD_DIR")
env_name = env.subst("$PIOENV")
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")
# Verify all inputs exist
for f in [bootloader, partitions, firmware]:
if not os.path.isfile(f):
print(f"[merge] WARNING: {f} not found, skipping merge")
return
# Read flash settings from board config
flash_mode = env.BoardConfig().get("build.flash_mode", "qio")
flash_freq = env.BoardConfig().get("build.f_flash", "80000000L").rstrip("L")
flash_size = env.BoardConfig().get("upload.flash_size", "16MB")
mcu = env.BoardConfig().get("build.mcu", "esp32s3")
# Convert numeric frequency to esptool format
freq_map = {"80000000": "80m", "40000000": "40m", "26000000": "26m", "20000000": "20m"}
flash_freq_str = freq_map.get(flash_freq, "80m")
cmd = [
env.subst("$PYTHONEXE"), "-m", "esptool",
"--chip", mcu,
"merge_bin",
"-o", output,
"--flash_mode", flash_mode,
"--flash_freq", flash_freq_str,
"--flash_size", flash_size,
"0x0", bootloader,
"0x8000", partitions,
"0x10000", firmware,
]
print(f"\n[merge] Creating merged firmware for {env_name}...")
print(f"[merge] {' '.join(cmd[-6:])}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
size_kb = os.path.getsize(output) / 1024
print(f"[merge] OK: {output} ({size_kb:.0f} KB)")
else:
print(f"[merge] FAILED: {result.stderr}")
env.AddPostAction("$BUILD_DIR/firmware.bin", merge_bin)

View File

@@ -56,7 +56,7 @@ build_src_filter =
[esp32_base]
extends = arduino_base
platform = platformio/espressif32@6.11.0
monitor_filters = esp32_exception_decoder
monitor_filters = esp32_exception_decoder, clock_sync
extra_scripts = merge-bin.py
build_flags = ${arduino_base.build_flags}
-D ESP32_PLATFORM

BIN
readback.bin Normal file

Binary file not shown.

View File

@@ -68,7 +68,7 @@ void Dispatcher::loop() {
next_tx_time = futureMillis(t * getAirtimeBudgetFactor());
_radio->onSendFinished();
logTx(outbound, 2 + outbound->path_len + outbound->payload_len);
logTx(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len);
if (outbound->isRouteFlood()) {
n_sent_flood++;
} else {
@@ -80,7 +80,7 @@ void Dispatcher::loop() {
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): WARNING: outbound packed send timed out!", getLogDateTime());
_radio->onSendFinished();
logTxFail(outbound, 2 + outbound->path_len + outbound->payload_len);
logTxFail(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len);
releasePacket(outbound); // return to pool
outbound = NULL;
@@ -141,12 +141,13 @@ void Dispatcher::checkRecv() {
}
pkt->path_len = raw[i++];
if (pkt->path_len > MAX_PATH_SIZE || i + pkt->path_len > len) {
uint16_t path_byte_len = pkt->getPathByteLen();
if (path_byte_len > MAX_PATH_SIZE || i + path_byte_len > len) {
MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(): partial or corrupt packet received, len=%d", getLogDateTime(), len);
_mgr->free(pkt); // put back into pool
pkt = NULL;
} else {
memcpy(pkt->path, &raw[i], pkt->path_len); i += pkt->path_len;
memcpy(pkt->path, &raw[i], path_byte_len); i += path_byte_len;
pkt->payload_len = len - i; // payload is remainder
if (pkt->payload_len > sizeof(pkt->payload)) {
@@ -258,7 +259,8 @@ void Dispatcher::checkSend() {
memcpy(&raw[len], &outbound->transport_codes[1], 2); len += 2;
}
raw[len++] = outbound->path_len;
memcpy(&raw[len], outbound->path, outbound->path_len); len += outbound->path_len;
uint16_t out_pbl = outbound->getPathByteLen();
memcpy(&raw[len], outbound->path, out_pbl); len += out_pbl;
if (len + outbound->payload_len > MAX_TRANS_UNIT) {
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): FATAL: Invalid packet queued... too long, len=%d", getLogDateTime(), len + outbound->payload_len);
@@ -312,8 +314,8 @@ void Dispatcher::releasePacket(Packet* packet) {
}
void Dispatcher::sendPacket(Packet* packet, uint8_t priority, uint32_t delay_millis) {
if (packet->path_len > MAX_PATH_SIZE || packet->payload_len > MAX_PACKET_PAYLOAD) {
MESH_DEBUG_PRINTLN("%s Dispatcher::sendPacket(): ERROR: invalid packet... path_len=%d, payload_len=%d", getLogDateTime(), (uint32_t) packet->path_len, (uint32_t) packet->payload_len);
if (packet->getPathByteLen() > MAX_PATH_SIZE || packet->payload_len > MAX_PACKET_PAYLOAD) {
MESH_DEBUG_PRINTLN("%s Dispatcher::sendPacket(): ERROR: invalid packet... path_len=%d (byte_len=%d), payload_len=%d", getLogDateTime(), (uint32_t) packet->path_len, (uint32_t) packet->getPathByteLen(), (uint32_t) packet->payload_len);
_mgr->free(packet);
} else {
_mgr->queueOutbound(packet, priority, futureMillis(delay_millis));

View File

@@ -20,6 +20,10 @@ public:
memcpy(dest, pub_key, PATH_HASH_SIZE); // hash is just prefix of pub_key
return PATH_HASH_SIZE;
}
int copyHashTo(uint8_t* dest, uint8_t len) const {
memcpy(dest, pub_key, len);
return len;
}
bool isHashMatch(const uint8_t* hash) const {
return memcmp(hash, pub_key, PATH_HASH_SIZE) == 0;
}
@@ -90,5 +94,4 @@ public:
void readFrom(const uint8_t* src, size_t len);
};
}
}

View File

@@ -15,7 +15,7 @@ bool Mesh::allowPacketForward(const mesh::Packet* packet) {
return false; // by default, Transport NOT enabled
}
uint32_t Mesh::getRetransmitDelay(const mesh::Packet* packet) {
uint32_t t = (_radio->getEstAirtimeFor(packet->getRawLength()) * 52 / 50) / 2;
uint32_t t = (uint32_t)(_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * 0.5f);
return _rng->nextInt(0, 5)*t;
}
@@ -77,7 +77,9 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
return ACTION_RELEASE;
}
if (pkt->isRouteDirect() && pkt->path_len >= PATH_HASH_SIZE) {
if (pkt->isRouteDirect() && (pkt->path_len & 63) > 0) {
uint8_t dir_bph = (pkt->path_len >> 6) + 1; // bytes per hop for this packet
// check for 'early received' ACK
if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) {
int i = 0;
@@ -88,7 +90,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
}
}
if (self_id.isHashMatch(pkt->path) && allowPacketForward(pkt)) {
if (self_id.isHashMatch(pkt->path, dir_bph) && allowPacketForward(pkt)) {
if (pkt->getPayloadType() == PAYLOAD_TYPE_MULTIPART) {
return forwardMultipartDirect(pkt);
} else if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) {
@@ -158,7 +160,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) {
int k = 0;
uint8_t path_len = data[k++];
uint8_t* path = &data[k]; k += path_len;
uint8_t* path = &data[k]; k += Packet::getPathByteLenFor(path_len);
uint8_t extra_type = data[k++] & 0x0F; // upper 4 bits reserved for future use
uint8_t* extra = &data[k];
uint8_t extra_len = len - k; // remainder of packet (may be padded with zeroes!)
@@ -293,8 +295,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
if (type == PAYLOAD_TYPE_ACK && pkt->payload_len >= 5) { // a multipart ACK
Packet tmp;
tmp.header = pkt->header;
tmp.path_len = pkt->path_len;
memcpy(tmp.path, pkt->path, pkt->path_len);
tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len);
tmp.payload_len = pkt->payload_len - 1;
memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len);
@@ -320,28 +321,34 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
}
void Mesh::removeSelfFromPath(Packet* pkt) {
// remove our hash from 'path'
pkt->path_len -= PATH_HASH_SIZE;
#if 0
memcpy(pkt->path, &pkt->path[PATH_HASH_SIZE], pkt->path_len);
#elif PATH_HASH_SIZE == 1
for (int k = 0; k < pkt->path_len; k++) { // shuffle bytes by 1
pkt->path[k] = pkt->path[k + 1];
}
#else
#error "need path remove impl"
#endif
uint8_t bph = (pkt->path_len >> 6) + 1; // bytes per hop
uint8_t hops = pkt->path_len & 63;
if (hops == 0) return;
uint16_t new_byte_len = (hops - 1) * bph;
// remove first bph bytes (our hash) from path, shift remainder
memmove(pkt->path, &pkt->path[bph], new_byte_len);
// decrement hop count, preserve mode bits
pkt->path_len = (pkt->path_len & 0xC0) | ((hops - 1) & 63);
}
DispatcherAction Mesh::routeRecvPacket(Packet* packet) {
if (packet->isRouteFlood() && !packet->isMarkedDoNotRetransmit()
&& packet->path_len + PATH_HASH_SIZE <= MAX_PATH_SIZE && allowPacketForward(packet)) {
// append this node's hash to 'path'
packet->path_len += self_id.copyHashTo(&packet->path[packet->path_len]);
&& allowPacketForward(packet)) {
uint8_t bph = (packet->path_len >> 6) + 1; // bytes per hop
uint8_t hops = packet->path_len & 63;
uint16_t byte_len = hops * bph;
uint32_t d = getRetransmitDelay(packet);
// as this propagates outwards, give it lower and lower priority
return ACTION_RETRANSMIT_DELAYED(packet->path_len, d); // give priority to closer sources, than ones further away
if (byte_len + bph <= MAX_PATH_SIZE) {
// append this node's hash (bph bytes of pub_key) to path
memcpy(&packet->path[byte_len], self_id.pub_key, bph);
// increment hop count, preserve mode bits
packet->path_len = (packet->path_len & 0xC0) | ((hops + 1) & 63);
uint32_t d = getRetransmitDelay(packet);
// as this propagates outwards, give it lower and lower priority
return ACTION_RETRANSMIT_DELAYED(hops + 1, d); // give priority to closer sources, than ones further away
}
}
return ACTION_RELEASE;
}
@@ -353,8 +360,7 @@ DispatcherAction Mesh::forwardMultipartDirect(Packet* pkt) {
if (type == PAYLOAD_TYPE_ACK && pkt->payload_len >= 5) { // a multipart ACK
Packet tmp;
tmp.header = pkt->header;
tmp.path_len = pkt->path_len;
memcpy(tmp.path, pkt->path, pkt->path_len);
tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len);
tmp.payload_len = pkt->payload_len - 1;
memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len);
@@ -376,7 +382,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) {
delay_millis += getDirectRetransmitDelay(packet) + 300;
auto a1 = createMultiAck(crc, extra);
if (a1) {
memcpy(a1->path, packet->path, a1->path_len = packet->path_len);
a1->path_len = Packet::copyPath(a1->path, packet->path, packet->path_len);
a1->header &= ~PH_ROUTE_MASK;
a1->header |= ROUTE_TYPE_DIRECT;
sendPacket(a1, 0, delay_millis);
@@ -386,7 +392,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) {
auto a2 = createAck(crc);
if (a2) {
memcpy(a2->path, packet->path, a2->path_len = packet->path_len);
a2->path_len = Packet::copyPath(a2->path, packet->path, packet->path_len);
a2->header &= ~PH_ROUTE_MASK;
a2->header |= ROUTE_TYPE_DIRECT;
sendPacket(a2, 0, delay_millis);
@@ -624,7 +630,7 @@ Packet* Mesh::createControlData(const uint8_t* data, size_t len) {
return packet;
}
void Mesh::sendFlood(Packet* packet, uint32_t delay_millis) {
void Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_bytes_per_hop) {
if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) {
MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): TRACE type not suspported", getLogDateTime());
return;
@@ -632,7 +638,9 @@ void Mesh::sendFlood(Packet* packet, uint32_t delay_millis) {
packet->header &= ~PH_ROUTE_MASK;
packet->header |= ROUTE_TYPE_FLOOD;
packet->path_len = 0;
// encode bytes-per-hop mode in upper 2 bits of path_len, 0 hops initially
uint8_t mode = (path_bytes_per_hop > 1) ? (path_bytes_per_hop - 1) : 0;
packet->path_len = (mode << 6);
_tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
@@ -647,7 +655,7 @@ void Mesh::sendFlood(Packet* packet, uint32_t delay_millis) {
sendPacket(packet, pri, delay_millis);
}
void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis) {
void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis, uint8_t path_bytes_per_hop) {
if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) {
MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): TRACE type not suspported", getLogDateTime());
return;
@@ -657,7 +665,9 @@ void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_m
packet->header |= ROUTE_TYPE_TRANSPORT_FLOOD;
packet->transport_codes[0] = transport_codes[0];
packet->transport_codes[1] = transport_codes[1];
packet->path_len = 0;
// encode bytes-per-hop mode in upper 2 bits of path_len, 0 hops initially
uint8_t mode = (path_bytes_per_hop > 1) ? (path_bytes_per_hop - 1) : 0;
packet->path_len = (mode << 6);
_tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us
@@ -685,7 +695,7 @@ void Mesh::sendDirect(Packet* packet, const uint8_t* path, uint8_t path_len, uin
packet->path_len = 0;
pri = 5; // maybe make this configurable
} else {
memcpy(packet->path, path, packet->path_len = path_len);
packet->path_len = Packet::copyPath(packet->path, path, path_len);
if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) {
pri = 1; // slightly less priority
} else {

View File

@@ -195,14 +195,16 @@ public:
/**
* \brief send a locally-generated Packet with flood routing
* \param path_bytes_per_hop number of bytes per path hop (1=legacy, 2, or 3)
*/
void sendFlood(Packet* packet, uint32_t delay_millis=0);
void sendFlood(Packet* packet, uint32_t delay_millis=0, uint8_t path_bytes_per_hop=1);
/**
* \brief send a locally-generated Packet with flood routing
* \param transport_codes array of 2 codes to attach to packet
* \param path_bytes_per_hop number of bytes per path hop (1=legacy, 2, or 3)
*/
void sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis=0);
void sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis=0, uint8_t path_bytes_per_hop=1);
/**
* \brief send a locally-generated Packet with Direct routing
@@ -222,4 +224,4 @@ public:
};
}
}

View File

@@ -11,7 +11,7 @@ Packet::Packet() {
}
int Packet::getRawLength() const {
return 2 + path_len + payload_len + (hasTransportCodes() ? 4 : 0);
return 2 + getPathByteLen() + payload_len + (hasTransportCodes() ? 4 : 0);
}
void Packet::calculatePacketHash(uint8_t* hash) const {
@@ -33,7 +33,8 @@ uint8_t Packet::writeTo(uint8_t dest[]) const {
memcpy(&dest[i], &transport_codes[1], 2); i += 2;
}
dest[i++] = path_len;
memcpy(&dest[i], path, path_len); i += path_len;
uint16_t pbl = getPathByteLen();
memcpy(&dest[i], path, pbl); i += pbl;
memcpy(&dest[i], payload, payload_len); i += payload_len;
return i;
}
@@ -48,8 +49,9 @@ bool Packet::readFrom(const uint8_t src[], uint8_t len) {
transport_codes[0] = transport_codes[1] = 0;
}
path_len = src[i++];
if (path_len > sizeof(path)) return false; // bad encoding
memcpy(path, &src[i], path_len); i += path_len;
uint16_t pbl = getPathByteLen();
if (pbl > sizeof(path)) return false; // bad encoding
memcpy(path, &src[i], pbl); i += pbl;
if (i >= len) return false; // bad encoding
payload_len = len - i;
if (payload_len > sizeof(payload)) return false; // bad encoding

View File

@@ -1,6 +1,7 @@
#pragma once
#include <MeshCore.h>
#include <string.h>
namespace mesh {
@@ -81,6 +82,43 @@ public:
float getSNR() const { return ((float)_snr) / 4.0f; }
/**
* \returns the actual byte length of path data.
* path_len encodes: lower 6 bits = hop count, upper 2 bits = bytes-per-hop mode
* mode 0 = 1 byte/hop (legacy), mode 1 = 2 bytes/hop, mode 2 = 3 bytes/hop
*/
uint16_t getPathByteLen() const {
uint8_t hops = path_len & 63;
uint8_t bph = (path_len >> 6) + 1;
return hops * bph;
}
/** Static variant for computing byte length from any path_len value */
static uint16_t getPathByteLenFor(uint8_t path_len) {
return (path_len & 63) * ((path_len >> 6) + 1);
}
/** Validate that encoded path_len won't exceed buffer */
static bool isValidPathLen(uint8_t path_len) {
return getPathByteLenFor(path_len) <= MAX_PATH_SIZE;
}
/** Copy path bytes using encoded path_len; returns path_len unchanged */
static uint8_t copyPath(uint8_t* dest, const uint8_t* src, uint8_t path_len) {
uint16_t bl = getPathByteLenFor(path_len);
if (bl > MAX_PATH_SIZE) bl = MAX_PATH_SIZE;
memcpy(dest, src, bl);
return path_len;
}
/** Write path bytes to buffer; returns number of bytes written */
static uint8_t writePath(uint8_t* dest, const uint8_t* src, uint8_t path_len) {
uint16_t bl = getPathByteLenFor(path_len);
if (bl > MAX_PATH_SIZE) bl = MAX_PATH_SIZE;
memcpy(dest, src, bl);
return (uint8_t)bl;
}
/**
* \returns the encoded/wire format length of this packet
*/
@@ -101,4 +139,4 @@ public:
bool readFrom(const uint8_t src[], uint8_t len);
};
}
}

View File

@@ -39,7 +39,7 @@ mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name, double lat, doubl
}
void BaseChatMesh::sendAckTo(const ContactInfo& dest, uint32_t ack_hash) {
if (dest.out_path_len < 0) {
if (dest.out_path_len == OUT_PATH_UNKNOWN) {
mesh::Packet* ack = createAck(ack_hash);
if (ack) sendFloodScoped(dest, ack, TXT_ACK_DELAY);
} else {
@@ -56,6 +56,14 @@ void BaseChatMesh::sendAckTo(const ContactInfo& dest, uint32_t ack_hash) {
}
void BaseChatMesh::bootstrapRTCfromContacts() {
// If the RTC already has a sane time (e.g. hardware RTC like PCF8563, or
// GPS-synced), don't overwrite it with a potentially stale contact lastmod.
// This bootstrap is only useful for boards with no hardware RTC at all.
uint32_t current = getRTCClock()->getCurrentTime();
if (current > 1704067200UL) { // Jan 1 2024 — matches EPOCH_MIN_SANE
return;
}
uint32_t latest = 0;
for (int i = 0; i < num_contacts; i++) {
if (contacts[i].lastmod > latest) {
@@ -92,7 +100,7 @@ ContactInfo* BaseChatMesh::allocateContactSlot() {
void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identity& id, const AdvertDataParser& parser, uint32_t timestamp) {
memset(&ci, 0, sizeof(ci));
ci.id = id;
ci.out_path_len = -1; // initially out_path is unknown
ci.out_path_len = OUT_PATH_UNKNOWN; // initially out_path is unknown
StrHelper::strncpy(ci.name, parser.getName(), sizeof(ci.name));
ci.type = parser.getType();
if (parser.hasLatLon()) {
@@ -263,7 +271,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender
} else {
mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, from.id, secret, temp_buf, reply_len);
if (reply) {
if (from.out_path_len >= 0) { // we have an out_path, so send DIRECT
if (from.out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
sendDirect(reply, from.out_path, from.out_path_len, SERVER_RESPONSE_DELAY);
} else {
sendFloodScoped(from, reply, SERVER_RESPONSE_DELAY);
@@ -273,7 +281,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender
}
} else if (type == PAYLOAD_TYPE_RESPONSE && len > 0) {
onContactResponse(from, data, len);
if (packet->isRouteFlood() && from.out_path_len >= 0) {
if (packet->isRouteFlood() && from.out_path_len != OUT_PATH_UNKNOWN) {
// we have direct path, but other node is still sending flood response, so maybe they didn't receive reciprocal path properly(?)
handleReturnPathRetry(from, packet->path, packet->path_len);
}
@@ -295,7 +303,8 @@ bool BaseChatMesh::onPeerPathRecv(mesh::Packet* packet, int sender_idx, const ui
bool BaseChatMesh::onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) {
// NOTE: default impl, we just replace the current 'out_path' regardless, whenever sender sends us a new out_path.
// FUTURE: could store multiple out_paths per contact, and try to find which is the 'best'(?)
memcpy(from.out_path, out_path, from.out_path_len = out_path_len); // store a copy of path, for sendDirect()
from.out_path_len = out_path_len;
mesh::Packet::copyPath(from.out_path, out_path, out_path_len); // store a copy of path, for sendDirect()
from.lastmod = getRTCClock()->getCurrentTime();
onContactPathUpdated(from);
@@ -317,7 +326,7 @@ void BaseChatMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) {
txt_send_timeout = 0; // matched one we're waiting for, cancel timeout timer
packet->markDoNotRetransmit(); // ACK was for this node, so don't retransmit
if (packet->isRouteFlood() && from->out_path_len >= 0) {
if (packet->isRouteFlood() && from->out_path_len != OUT_PATH_UNKNOWN) {
// we have direct path, but other node is still sending flood, so maybe they didn't receive reciprocal path properly(?)
handleReturnPathRetry(*from, packet->path, packet->path_len);
}
@@ -386,7 +395,7 @@ int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp,
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
int rc;
if (recipient.out_path_len < 0) {
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
sendFloodScoped(recipient, pkt);
txt_send_timeout = futureMillis(est_timeout = calcFloodTimeoutMillisFor(t));
rc = MSG_SEND_SENT_FLOOD;
@@ -412,7 +421,7 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timest
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
int rc;
if (recipient.out_path_len < 0) {
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
sendFloodScoped(recipient, pkt);
txt_send_timeout = futureMillis(est_timeout = calcFloodTimeoutMillisFor(t));
rc = MSG_SEND_SENT_FLOOD;
@@ -500,7 +509,7 @@ int BaseChatMesh::sendLogin(const ContactInfo& recipient, const char* password,
}
if (pkt) {
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
if (recipient.out_path_len < 0) {
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
sendFloodScoped(recipient, pkt);
est_timeout = calcFloodTimeoutMillisFor(t);
return MSG_SEND_SENT_FLOOD;
@@ -525,7 +534,7 @@ int BaseChatMesh::sendAnonReq(const ContactInfo& recipient, const uint8_t* data,
}
if (pkt) {
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
if (recipient.out_path_len < 0) {
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
sendFloodScoped(recipient, pkt);
est_timeout = calcFloodTimeoutMillisFor(t);
return MSG_SEND_SENT_FLOOD;
@@ -552,7 +561,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t* req_
}
if (pkt) {
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
if (recipient.out_path_len < 0) {
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
sendFloodScoped(recipient, pkt);
est_timeout = calcFloodTimeoutMillisFor(t);
return MSG_SEND_SENT_FLOOD;
@@ -579,7 +588,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, uint8_t req_type, u
}
if (pkt) {
uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength());
if (recipient.out_path_len < 0) {
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
sendFloodScoped(recipient, pkt);
est_timeout = calcFloodTimeoutMillisFor(t);
return MSG_SEND_SENT_FLOOD;
@@ -683,7 +692,7 @@ void BaseChatMesh::checkConnections() {
MESH_DEBUG_PRINTLN("checkConnections(): Keep_alive contact not found!");
continue;
}
if (contact->out_path_len < 0) {
if (contact->out_path_len == OUT_PATH_UNKNOWN) {
MESH_DEBUG_PRINTLN("checkConnections(): Keep_alive contact, no out_path!");
continue;
}
@@ -710,7 +719,7 @@ void BaseChatMesh::checkConnections() {
}
void BaseChatMesh::resetPathTo(ContactInfo& recipient) {
recipient.out_path_len = -1;
recipient.out_path_len = OUT_PATH_UNKNOWN;
}
static ContactInfo* table; // pass via global :-(
@@ -875,4 +884,4 @@ void BaseChatMesh::loop() {
releasePacket(_pendingLoopback); // undo the obtainNewPacket()
_pendingLoopback = NULL;
}
}
}

View File

@@ -58,9 +58,9 @@ class BaseChatMesh : public mesh::Mesh {
friend class ContactsIterator;
ContactInfo contacts[MAX_CONTACTS];
ContactInfo* contacts;
int num_contacts;
int sort_array[MAX_CONTACTS];
int* sort_array;
int matching_peer_indexes[MAX_SEARCH_RESULTS];
unsigned long txt_send_timeout;
#ifdef MAX_GROUP_CHANNELS
@@ -78,6 +78,8 @@ protected:
BaseChatMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::PacketManager& mgr, mesh::MeshTables& tables)
: mesh::Mesh(radio, ms, rng, rtc, mgr, tables)
{
contacts = NULL;
sort_array = NULL;
num_contacts = 0;
#ifdef MAX_GROUP_CHANNELS
memset(channels, 0, sizeof(channels));
@@ -90,6 +92,19 @@ protected:
void bootstrapRTCfromContacts();
void resetContacts() { num_contacts = 0; }
// Must be called from begin() before loadContacts/bootstrapRTCfromContacts.
// Deferred from constructor because PSRAM is not available during global init.
void initContacts() {
if (contacts != NULL) return; // already initialized
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
contacts = (ContactInfo*)ps_calloc(MAX_CONTACTS, sizeof(ContactInfo));
sort_array = (int*)ps_calloc(MAX_CONTACTS, sizeof(int));
#else
contacts = new ContactInfo[MAX_CONTACTS]();
sort_array = new int[MAX_CONTACTS]();
#endif
}
void populateContactFromAdvert(ContactInfo& ci, const mesh::Identity& id, const AdvertDataParser& parser, uint32_t timestamp);
ContactInfo* allocateContactSlot(); // helper to find slot for new contact
@@ -98,6 +113,7 @@ protected:
virtual bool shouldAutoAddContactType(uint8_t type) const { return true; }
virtual void onContactsFull() {};
virtual bool shouldOverwriteWhenFull() const { return false; }
virtual uint8_t getAutoAddMaxHops() const { return 0; } // 0 = no limit
virtual void onContactOverwrite(const uint8_t* pub_key) {};
virtual void onDiscoveredContact(ContactInfo& contact, bool is_new, uint8_t path_len, const uint8_t* path) = 0;
virtual ContactInfo* processAck(const uint8_t *data) = 0;
@@ -169,4 +185,4 @@ public:
int findChannelIdx(const mesh::GroupChannel& ch);
void loop();
};
};

View File

@@ -114,7 +114,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
memset(c, 0, sizeof(*c));
c->permissions = init_perms;
c->id = id;
c->out_path_len = -1; // initially out_path is unknown
c->out_path_len = OUT_PATH_UNKNOWN; // initially out_path is unknown
return c;
}
@@ -140,4 +140,4 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8
self_id.calcSharedSecret(c->shared_secret, pubkey);
}
return true;
}
}

View File

@@ -4,6 +4,10 @@
#include <Mesh.h>
#include <helpers/IdentityStore.h>
#ifndef OUT_PATH_UNKNOWN
#define OUT_PATH_UNKNOWN 0xFF
#endif
#define PERM_ACL_ROLE_MASK 3 // lower 2 bits
#define PERM_ACL_GUEST 0
#define PERM_ACL_READ_ONLY 1
@@ -13,7 +17,7 @@
struct ClientInfo {
mesh::Identity id;
uint8_t permissions;
int8_t out_path_len;
uint8_t out_path_len; // OUT_PATH_UNKNOWN = no known path
uint8_t out_path[MAX_PATH_SIZE];
uint8_t shared_secret[PUB_KEY_SIZE];
uint32_t last_timestamp; // by THEIR clock (transient)
@@ -55,4 +59,4 @@ public:
int getNumClients() const { return num_clients; }
ClientInfo* getClientByIdx(int idx) { return &clients[idx]; }
};
};

View File

@@ -3,12 +3,14 @@
#include <Arduino.h>
#include <Mesh.h>
#define OUT_PATH_UNKNOWN 0xFF // no known path — triggers flood routing
struct ContactInfo {
mesh::Identity id;
char name[32];
uint8_t type; // on of ADV_TYPE_*
uint8_t flags;
int8_t out_path_len;
uint8_t out_path_len; // encoded: bits[7:6]=mode, bits[5:0]=hops. OUT_PATH_UNKNOWN=no path
mutable bool shared_secret_valid; // flag to indicate if shared_secret has been calculated
uint8_t out_path[MAX_PATH_SIZE];
uint32_t last_advert_timestamp; // by THEIR clock
@@ -26,4 +28,4 @@ struct ContactInfo {
private:
mutable uint8_t shared_secret[PUB_KEY_SIZE];
};
};

View File

@@ -147,15 +147,21 @@ void SerialBLEInterface::enable() {
}
void SerialBLEInterface::disable() {
bool wasEnabled = _isEnabled;
_isEnabled = false;
BLE_DEBUG_PRINTLN("SerialBLEInterface::disable");
pServer->getAdvertising()->stop();
pServer->disconnect(last_conn_id);
pService->stop();
// Only try BLE operations if we were previously enabled
// (avoids accessing dead BLE objects after btStop/mem_release)
if (wasEnabled && pServer) {
pServer->getAdvertising()->stop();
pServer->disconnect(last_conn_id);
pService->stop();
}
oldDeviceConnected = deviceConnected = false;
adv_restart_time = 0;
clearBuffers();
}
size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) {
@@ -186,6 +192,8 @@ bool SerialBLEInterface::isWriteBusy() const {
}
size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
if (!_isEnabled) return 0; // BLE disabled — skip all BLE operations
if (send_queue_len > 0 // first, check send queue
&& millis() >= _last_write + BLE_WRITE_MIN_INTERVAL // space the writes apart
) {

View File

@@ -0,0 +1,347 @@
#include "FastEPDDisplay.h"
#include "FastEPD.h"
#include <string.h>
// Fallback if FastEPD doesn't define these constants
#ifndef BBEP_SUCCESS
#define BBEP_SUCCESS 0
#endif
#ifndef CLEAR_FAST
#define CLEAR_FAST 0
#endif
#ifndef CLEAR_SLOW
#define CLEAR_SLOW 1
#endif
#ifndef BB_MODE_1BPP
#define BB_MODE_1BPP 0
#endif
// FastEPD constants (defined in FastEPD.h)
// BB_PANEL_LILYGO_T5PRO_V2 — board ID for V2 hardware
// BB_MODE_1BPP — 1-bit per pixel mode
// CLEAR_FAST, CLEAR_SLOW — full refresh modes
// Periodic slow (deep) refresh to clear ghosting
#define FULL_SLOW_PERIOD 1 // every frame — eliminates ghosting (increase to 2+ for less flashing)
FastEPDDisplay::~FastEPDDisplay() {
delete _canvas;
delete _epd;
}
bool FastEPDDisplay::begin() {
if (_init) return true;
Serial.println("[FastEPD] Initializing T5S3 E-Paper Pro V2...");
// Create FastEPD instance and init hardware
_epd = new FASTEPD;
// Meshtastic-proven init for V2 hardware (pinned FastEPD fork commit)
Serial.println("[FastEPD] Using BB_PANEL_LILYGO_T5PRO_V2");
int rc = _epd->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
if (rc != BBEP_SUCCESS) {
Serial.printf("[FastEPD] initPanel FAILED: %d\n", rc);
delete _epd;
_epd = nullptr;
return false;
}
Serial.printf("[FastEPD] Panel initialized (rc=%d)\n", rc);
// Enable display via PCA9535 GPIO (required for V2 hardware)
// Pin 0 on PCA9535 = EP_OE (output enable for source driver)
_epd->ioPinMode(0, OUTPUT);
_epd->ioWrite(0, HIGH);
Serial.println("[FastEPD] PCA9535 EP_OE set HIGH");
// Set 1-bit per pixel mode
_epd->setMode(BB_MODE_1BPP);
Serial.println("[FastEPD] Mode set to 1BPP");
// Create Adafruit_GFX canvas for drawing (960×540, 1-bit)
// ~64KB, should auto-allocate in PSRAM on ESP32-S3 with PSRAM enabled
_canvas = new GFXcanvas1(EPD_WIDTH, EPD_HEIGHT);
if (!_canvas || !_canvas->getBuffer()) {
Serial.println("[FastEPD] Canvas allocation FAILED!");
return false;
}
Serial.printf("[FastEPD] Canvas allocated: %dx%d (%d bytes)\n",
EPD_WIDTH, EPD_HEIGHT, (EPD_WIDTH * EPD_HEIGHT) / 8);
// Initial clear — white screen
Serial.println("[FastEPD] Calling clearWhite()...");
_epd->clearWhite();
Serial.println("[FastEPD] Calling fullUpdate(true) for initial clear...");
_epd->fullUpdate(true); // blocking initial clear
_epd->backupPlane(); // Save clean state for subsequent diffs
Serial.println("[FastEPD] Initial clear complete");
// Set canvas defaults
_canvas->fillScreen(1); // White background (bit=1 → white in FastEPD)
_canvas->setTextColor(0); // Black text (bit=0 → black in FastEPD)
#ifdef MECK_SERIF_FONT
_canvas->setFont(&FreeSerif12pt7b);
#else
_canvas->setFont(&FreeSans12pt7b);
#endif
_canvas->setTextWrap(false);
_curr_color = GxEPD_BLACK;
_init = true;
_isOn = true;
Serial.println("[FastEPD] Display ready (960x540, 1BPP)");
return true;
}
void FastEPDDisplay::turnOn() {
if (!_init) begin();
_isOn = true;
}
void FastEPDDisplay::turnOff() {
_isOn = false;
}
void FastEPDDisplay::clear() {
if (!_canvas) return;
_canvas->fillScreen(1); // White
_canvas->setTextColor(0);
_frameCRC.reset();
}
void FastEPDDisplay::startFrame(Color bkg) {
if (!_canvas) return;
_canvas->fillScreen(1); // White background
_canvas->setTextColor(0); // Black text
_curr_color = GxEPD_BLACK;
_frameCRC.reset();
_frameCRC.update<bool>(_darkMode);
_frameCRC.update<bool>(_portraitMode);
}
void FastEPDDisplay::setTextSize(int sz) {
if (!_canvas) return;
_frameCRC.update<int>(sz);
// Font mapping for 960×540 display at ~234 DPI
// Toggle between font families via -D MECK_SERIF_FONT build flag
switch(sz) {
case 0: // Body text — reader content, settings rows, messages, footers
#ifdef MECK_SERIF_FONT
_canvas->setFont(&FreeSerif12pt7b);
#else
_canvas->setFont(&FreeSans12pt7b);
#endif
_canvas->setTextSize(1);
break;
case 1: // Headings — screen titles, channel names (bold, same height as body)
_canvas->setFont(&FreeSansBold12pt7b);
_canvas->setTextSize(1);
break;
case 2: // Large bold — MSG count, tile letters
_canvas->setFont(&FreeSansBold18pt7b);
_canvas->setTextSize(1);
break;
case 3: // Extra large — splash screen title
_canvas->setFont(&FreeSansBold24pt7b);
_canvas->setTextSize(1);
break;
case 5: // Clock face — lock screen (FreeSansBold24pt scaled 5×)
_canvas->setFont(&FreeSansBold24pt7b);
_canvas->setTextSize(5);
break;
default:
#ifdef MECK_SERIF_FONT
_canvas->setFont(&FreeSerif12pt7b);
#else
_canvas->setFont(&FreeSans12pt7b);
#endif
_canvas->setTextSize(1);
break;
}
}
void FastEPDDisplay::setColor(Color c) {
if (!_canvas) return;
_frameCRC.update<Color>(c);
// Colours are inverted for e-paper:
// DARK = background colour = WHITE on e-paper
// LIGHT = foreground colour = BLACK on e-paper
if (c == DARK) {
_canvas->setTextColor(1); // White (background)
_curr_color = GxEPD_WHITE;
} else {
_canvas->setTextColor(0); // Black (foreground)
_curr_color = GxEPD_BLACK;
}
}
void FastEPDDisplay::setCursor(int x, int y) {
if (!_canvas) return;
_frameCRC.update<int>(x);
_frameCRC.update<int>(y);
// Scale virtual coordinates to physical, with baseline offset.
// The +5 pushes text baseline down so ascenders at y=0 are visible.
_canvas->setCursor(
(int)((x + offset_x) * scale_x),
(int)((y + offset_y + 5) * scale_y)
);
}
void FastEPDDisplay::print(const char* str) {
if (!_canvas || !str) return;
_frameCRC.update<char>(str, strlen(str));
_canvas->print(str);
}
void FastEPDDisplay::fillRect(int x, int y, int w, int h) {
if (!_canvas) return;
_frameCRC.update<int>(x);
_frameCRC.update<int>(y);
_frameCRC.update<int>(w);
_frameCRC.update<int>(h);
// Canvas uses 1-bit color: convert GxEPD color
uint16_t canvasColor = (_curr_color == GxEPD_BLACK) ? 0 : 1;
_canvas->fillRect(
(int)((x + offset_x) * scale_x),
(int)((y + offset_y) * scale_y),
(int)(w * scale_x),
(int)(h * scale_y),
canvasColor
);
}
void FastEPDDisplay::drawRect(int x, int y, int w, int h) {
if (!_canvas) return;
_frameCRC.update<int>(x);
_frameCRC.update<int>(y);
_frameCRC.update<int>(w);
_frameCRC.update<int>(h);
uint16_t canvasColor = (_curr_color == GxEPD_BLACK) ? 0 : 1;
_canvas->drawRect(
(int)((x + offset_x) * scale_x),
(int)((y + offset_y) * scale_y),
(int)(w * scale_x),
(int)(h * scale_y),
canvasColor
);
}
void FastEPDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
if (!_canvas || !bits) return;
_frameCRC.update<int>(x);
_frameCRC.update<int>(y);
_frameCRC.update<int>(w);
_frameCRC.update<int>(h);
_frameCRC.update<uint8_t>(bits, (w * h + 7) / 8);
uint16_t canvasColor = (_curr_color == GxEPD_BLACK) ? 0 : 1;
uint16_t startX = (int)((x + offset_x) * scale_x);
uint16_t startY = (int)((y + offset_y) * scale_y);
uint16_t widthInBytes = (w + 7) / 8;
for (uint16_t by = 0; by < h; by++) {
int y1 = startY + (int)(by * scale_y);
int y2 = startY + (int)((by + 1) * scale_y);
int block_h = y2 - y1;
for (uint16_t bx = 0; bx < w; bx++) {
int x1 = startX + (int)(bx * scale_x);
int x2 = startX + (int)((bx + 1) * scale_x);
int block_w = x2 - x1;
uint16_t byteOffset = (by * widthInBytes) + (bx / 8);
uint8_t bitMask = 0x80 >> (bx & 7);
bool bitSet = pgm_read_byte(bits + byteOffset) & bitMask;
if (bitSet) {
_canvas->fillRect(x1, y1, block_w, block_h, canvasColor);
}
}
}
}
uint16_t FastEPDDisplay::getTextWidth(const char* str) {
if (!_canvas || !str) return 0;
int16_t x1, y1;
uint16_t w, h;
_canvas->getTextBounds(str, 0, 0, &x1, &y1, &w, &h);
return (uint16_t)ceil((w + 1) / scale_x);
}
void FastEPDDisplay::endFrame() {
if (!_epd || !_canvas) return;
uint32_t crc = _frameCRC.finalize();
if (crc == _lastCRC) {
return; // Frame unchanged, skip display update
}
_lastCRC = crc;
// Copy GFXcanvas1 buffer to FastEPD's current buffer — direct copy.
// Both use same polarity: bit 1 = white, bit 0 = black.
uint8_t* src = _canvas->getBuffer();
uint8_t* dst = _epd->currentBuffer();
size_t bufSize = ((uint32_t)EPD_WIDTH * EPD_HEIGHT) / 8;
if (!src || !dst) return;
memcpy(dst, src, bufSize);
// Dark mode: invert every byte in the buffer (white↔black)
if (_darkMode) {
for (size_t i = 0; i < bufSize; i++) dst[i] = ~dst[i];
}
// Refresh strategy:
// partialUpdate(true) — no flash, differential, keeps previous buffer
// fullUpdate(false) — brief flash, clears ghosting (CLEAR_FAST)
// fullUpdate(true) — full white flash, cleanest (boot only)
//
// Use partial for most frames. Periodic full refresh every N frames
// to clear accumulated ghosting artifacts.
_fullRefreshCount++;
if (_forcePartial) {
// VKB typing mode — no flash, fast differential update
_epd->partialUpdate(true);
_fullRefreshCount = 0; // Reset so next non-partial frame does full refresh
} else if (_fullRefreshCount >= FULL_SLOW_PERIOD) {
_fullRefreshCount = 0;
_epd->fullUpdate(true); // Full clean refresh — clears all ghosting
} else {
_epd->partialUpdate(true); // No flash — differential
}
_epd->backupPlane();
}
void FastEPDDisplay::setDarkMode(bool dark) {
_darkMode = dark;
_lastCRC = 0; // Force redraw
Serial.printf("[FastEPD] Dark mode: %s\n", dark ? "ON" : "OFF");
}
void FastEPDDisplay::setPortraitMode(bool portrait) {
if (_portraitMode == portrait) return;
_portraitMode = portrait;
if (!_canvas) return;
if (portrait) {
_canvas->setRotation(3); // 270° CW — USB-C on right when held portrait
scale_x = (float)EPD_HEIGHT / 128.0f; // 540 / 128 = 4.21875
scale_y = (float)EPD_WIDTH / 128.0f; // 960 / 128 = 7.5
Serial.printf("[FastEPD] Portrait mode: ON (logical %dx%d, scale %.2f x %.2f)\n",
EPD_HEIGHT, EPD_WIDTH, scale_x, scale_y);
} else {
_canvas->setRotation(0); // Normal landscape
scale_x = (float)EPD_WIDTH / 128.0f; // 960 / 128 = 7.5
scale_y = (float)EPD_HEIGHT / 128.0f; // 540 / 128 = 4.21875
Serial.printf("[FastEPD] Portrait mode: OFF (logical %dx%d, scale %.2f x %.2f)\n",
EPD_WIDTH, EPD_HEIGHT, scale_x, scale_y);
}
_lastCRC = 0; // Force redraw
}

View File

@@ -0,0 +1,136 @@
#pragma once
// =============================================================================
// FastEPDDisplay — Parallel e-ink display driver for T5 S3 E-Paper Pro
//
// Architecture:
// - FastEPD handles hardware init, power management, and display refresh
// - Adafruit_GFX GFXcanvas1 handles all drawing/text rendering
// - On endFrame(), canvas buffer is copied to FastEPD and display is updated
//
// This avoids depending on FastEPD's drawing API — only uses its well-tested
// hardware interface (initPanel, fullUpdate, partialUpdate, currentBuffer).
// =============================================================================
#include <Adafruit_GFX.h>
#include "variant.h" // EPD_WIDTH, EPD_HEIGHT (only compiled for T5S3 builds)
#include <Fonts/FreeSans9pt7b.h>
#include <Fonts/FreeSans12pt7b.h>
#include <Fonts/FreeSans18pt7b.h>
#include <Fonts/FreeSans24pt7b.h>
#include <Fonts/FreeSansBold12pt7b.h>
#include <Fonts/FreeSansBold18pt7b.h>
#include <Fonts/FreeSansBold24pt7b.h>
#include <Fonts/FreeSerif12pt7b.h>
#include <Fonts/FreeSerif18pt7b.h>
#include "DisplayDriver.h"
// GxEPD2 color constant compatibility — MapScreen uses these directly
#ifndef GxEPD_BLACK
#define GxEPD_BLACK 0x0000
#endif
#ifndef GxEPD_WHITE
#define GxEPD_WHITE 0xFFFF
#endif
// Forward declare FastEPD class (actual include in .cpp)
class FASTEPD;
// Inline CRC32 for frame change detection
// (Copied from GxEPDDisplay.h — avoids CRC32/PNGdec name collision)
class FrameCRC32 {
uint32_t _crc = 0xFFFFFFFF;
public:
void reset() { _crc = 0xFFFFFFFF; }
template<typename T> void update(T val) {
const uint8_t* p = (const uint8_t*)&val;
for (size_t i = 0; i < sizeof(T); i++) {
_crc ^= p[i];
for (int b = 0; b < 8; b++)
_crc = (_crc >> 1) ^ (0xEDB88320 & -(int32_t)(_crc & 1));
}
}
template<typename T> void update(const T* data, size_t len) {
const uint8_t* p = (const uint8_t*)data;
for (size_t i = 0; i < len * sizeof(T); i++) {
_crc ^= p[i];
for (int b = 0; b < 8; b++)
_crc = (_crc >> 1) ^ (0xEDB88320 & -(int32_t)(_crc & 1));
}
}
uint32_t finalize() { return _crc ^ 0xFFFFFFFF; }
};
class FastEPDDisplay : public DisplayDriver {
FASTEPD* _epd;
GFXcanvas1* _canvas; // Adafruit_GFX 1-bit drawing surface (960×540)
bool _init = false;
bool _isOn = false;
uint16_t _curr_color; // GxEPD_BLACK or GxEPD_WHITE for canvas drawing
FrameCRC32 _frameCRC;
uint32_t _lastCRC = 0;
int _fullRefreshCount = 0; // Track for periodic slow refresh
uint32_t _lastUpdateMs = 0; // Rate limiting — minimum interval between refreshes
bool _forcePartial = false; // When true, use partial updates (VKB typing)
bool _darkMode = false; // Invert all pixels (black bg, white text)
bool _portraitMode = false; // Rotated 90° (540×960 logical)
// Virtual 128×128 → physical canvas mapping (runtime, changes with portrait)
float scale_x = 7.5f; // 960 / 128 (landscape default)
float scale_y = 4.21875f; // 540 / 128 (landscape default)
static constexpr float offset_x = 0.0f;
static constexpr float offset_y = 0.0f;
public:
FastEPDDisplay() : DisplayDriver(128, 128), _epd(nullptr), _canvas(nullptr) {}
~FastEPDDisplay();
bool begin();
bool isOn() override { return _isOn; }
void turnOn() override;
void turnOff() override;
void clear() override;
void startFrame(Color bkg = DARK) override;
void setTextSize(int sz) override;
void setColor(Color c) override;
void setCursor(int x, int y) override;
void print(const char* str) override;
void fillRect(int x, int y, int w, int h) override;
void drawRect(int x, int y, int w, int h) override;
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;
uint16_t getTextWidth(const char* str) override;
void endFrame() override;
// --- Raw pixel access for MapScreen (bypasses scaling) ---
void drawPixelRaw(int16_t x, int16_t y, uint16_t color) {
if (_canvas) _canvas->drawPixel(x, y, color ? 1 : 0);
}
int16_t rawWidth() { return EPD_WIDTH; }
int16_t rawHeight() { return EPD_HEIGHT; }
void drawTextRaw(int16_t x, int16_t y, const char* text, uint16_t color) {
if (!_canvas) return;
_canvas->setFont(NULL);
_canvas->setTextSize(3); // 3× built-in 5×7 = 15×21, readable on 960×540
_canvas->setTextColor(color ? 1 : 0);
_canvas->setCursor(x, y);
_canvas->print(text);
}
void invalidateFrameCRC() { _lastCRC = 0; }
// Temporarily force partial (no-flash) updates — use during VKB typing
void setForcePartial(bool partial) { _forcePartial = partial; }
bool isForcePartial() const { return _forcePartial; }
// Dark mode — invert all pixels in endFrame (black bg, white text)
void setDarkMode(bool dark);
bool isDarkMode() const { return _darkMode; }
// Portrait mode — rotate canvas 90° (540×960 logical), swap scale factors
void setPortraitMode(bool portrait);
bool isPortraitMode() const { return _portraitMode; }
};

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,23 +61,38 @@ 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() {
display.fillScreen(GxEPD_WHITE);
display.setTextColor(GxEPD_BLACK);
if (_darkMode) {
display.fillScreen(GxEPD_BLACK);
display.setTextColor(GxEPD_WHITE);
} else {
display.fillScreen(GxEPD_WHITE);
display.setTextColor(GxEPD_BLACK);
}
display_crc.reset();
}
void GxEPDDisplay::startFrame(Color bkg) {
display.fillScreen(GxEPD_WHITE);
display.setTextColor(_curr_color = GxEPD_BLACK);
if (_darkMode) {
display.fillScreen(GxEPD_BLACK);
display.setTextColor(_curr_color = GxEPD_WHITE);
} else {
display.fillScreen(GxEPD_WHITE);
display.setTextColor(_curr_color = GxEPD_BLACK);
}
display_crc.reset();
}
@@ -90,26 +105,43 @@ 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;
}
}
void GxEPDDisplay::setColor(Color c) {
display_crc.update<Color> (c);
// colours need to be inverted for epaper displays
if (c == DARK) {
display.setTextColor(_curr_color = GxEPD_WHITE);
if (_darkMode) {
// Dark mode: DARK = black (background), LIGHT/GREEN/YELLOW = white (foreground)
if (c == DARK) {
display.setTextColor(_curr_color = GxEPD_BLACK);
} else {
display.setTextColor(_curr_color = GxEPD_WHITE);
}
} else {
display.setTextColor(_curr_color = GxEPD_BLACK);
// Normal e-paper: DARK = white (background), LIGHT/GREEN/YELLOW = black (foreground)
if (c == DARK) {
display.setTextColor(_curr_color = GxEPD_WHITE);
} else {
display.setTextColor(_curr_color = GxEPD_BLACK);
}
}
}

View File

@@ -1,5 +1,11 @@
#pragma once
// T5S3 E-Paper Pro uses parallel e-ink (FastEPD), not SPI (GxEPD2)
#if defined(LilyGo_T5S3_EPaper_Pro)
#include "FastEPDDisplay.h"
using GxEPDDisplay = FastEPDDisplay;
#else
#include <SPI.h>
#include <Wire.h>
@@ -12,7 +18,31 @@
#include <Fonts/FreeSans9pt7b.h>
#include <Fonts/FreeSansBold12pt7b.h>
#include <Fonts/FreeSans18pt7b.h>
#include <CRC32.h>
// Inline CRC32 for frame change detection (replaces bakercp/CRC32
// to avoid naming collision with PNGdec's bundled CRC32.h)
class FrameCRC32 {
uint32_t _crc = 0xFFFFFFFF;
public:
void reset() { _crc = 0xFFFFFFFF; }
template<typename T> void update(T val) {
const uint8_t* p = (const uint8_t*)&val;
for (size_t i = 0; i < sizeof(T); i++) {
_crc ^= p[i];
for (int b = 0; b < 8; b++)
_crc = (_crc >> 1) ^ (0xEDB88320 & -(int32_t)(_crc & 1));
}
}
template<typename T> void update(const T* data, size_t len) {
const uint8_t* p = (const uint8_t*)data;
for (size_t i = 0; i < len * sizeof(T); i++) {
_crc ^= p[i];
for (int b = 0; b < 8; b++)
_crc = (_crc >> 1) ^ (0xEDB88320 & -(int32_t)(_crc & 1));
}
}
uint32_t finalize() { return _crc ^ 0xFFFFFFFF; }
};
#include "DisplayDriver.h"
@@ -33,8 +63,9 @@ class GxEPDDisplay : public DisplayDriver {
#endif
bool _init = false;
bool _isOn = false;
bool _darkMode = false;
uint16_t _curr_color;
CRC32 display_crc;
FrameCRC32 display_crc;
int last_display_crc_value = 0;
public:
@@ -49,6 +80,11 @@ public:
bool isOn() override {return _isOn;};
void turnOn() override;
void turnOff() override;
// Dark mode — inverts background/foreground for e-ink
bool isDarkMode() const { return _darkMode; }
void setDarkMode(bool on) { _darkMode = on; }
void clear() override;
void startFrame(Color bkg = DARK) override;
void setTextSize(int sz) override;
@@ -60,4 +96,26 @@ public:
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;
uint16_t getTextWidth(const char* str) override;
void endFrame() override;
// --- Raw pixel access for MapScreen (bypasses scaling) ---
void drawPixelRaw(int16_t x, int16_t y, uint16_t color) {
display.drawPixel(x, y, color);
}
int16_t rawWidth() { return display.width(); }
int16_t rawHeight() { return display.height(); }
// Draw text at raw (unscaled) physical coordinates using built-in 5x7 font
void drawTextRaw(int16_t x, int16_t y, const char* text, uint16_t color) {
display.setFont(NULL); // Built-in 5x7 font
display.setTextSize(1);
display.setTextColor(color);
display.setCursor(x, y);
display.print(text);
}
// Force endFrame() to push to display even if CRC unchanged
// (needed because drawPixelRaw bypasses CRC tracking)
void invalidateFrameCRC() { last_display_crc_value = 0; }
};
#endif // !LilyGo_T5S3_EPaper_Pro

View File

@@ -0,0 +1,113 @@
#pragma once
#include <Arduino.h>
// CPU Frequency Scaling for ESP32-S3
//
// Typical current draw (CPU only, rough):
// 240 MHz ~70-80 mA
// 160 MHz ~50-60 mA
// 80 MHz ~30-40 mA
// 40 MHz ~15-20 mA (low-power / lock screen mode)
//
// SPI peripherals and UART use their own clock dividers from the APB clock,
// so LoRa, e-ink, and GPS serial all work fine at 80MHz and 40MHz.
#ifdef ESP32
#ifndef CPU_FREQ_IDLE
#define CPU_FREQ_IDLE 80 // MHz — normal mesh listening
#endif
#ifndef CPU_FREQ_BOOST
#define CPU_FREQ_BOOST 240 // MHz — heavy processing
#endif
#ifndef CPU_FREQ_LOW_POWER
#define CPU_FREQ_LOW_POWER 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), _lowPower(false), _boost_started(0) {}
void begin() {
setCpuFrequencyMhz(CPU_FREQ_IDLE);
_boosted = false;
_lowPower = false;
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
}
void loop() {
if (_boosted && (millis() - _boost_started >= CPU_BOOST_TIMEOUT_MS)) {
// Return to low-power if locked, otherwise normal idle
if (_lowPower) {
setCpuFrequencyMhz(CPU_FREQ_LOW_POWER);
MESH_DEBUG_PRINTLN("CPU power: boost expired, returning to low-power %d MHz", CPU_FREQ_LOW_POWER);
} else {
setCpuFrequencyMhz(CPU_FREQ_IDLE);
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
}
_boosted = false;
}
}
void setBoost() {
if (!_boosted) {
setCpuFrequencyMhz(CPU_FREQ_BOOST);
_boosted = true;
MESH_DEBUG_PRINTLN("CPU power: boosted to %d MHz", CPU_FREQ_BOOST);
}
_boost_started = millis();
}
void setIdle() {
if (_boosted) {
setCpuFrequencyMhz(CPU_FREQ_IDLE);
_boosted = false;
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
}
if (_lowPower) {
_lowPower = false;
}
}
// Low-power mode — drops CPU to 40 MHz for lock screen standby.
// If currently boosted, the boost timeout will return to 40 MHz
// instead of 80 MHz.
void setLowPower() {
_lowPower = true;
if (!_boosted) {
setCpuFrequencyMhz(CPU_FREQ_LOW_POWER);
MESH_DEBUG_PRINTLN("CPU power: low-power at %d MHz", CPU_FREQ_LOW_POWER);
}
// If boosted, the loop() timeout will drop to low-power instead of idle
}
// Exit low-power mode — returns to normal idle (80 MHz).
// If currently boosted, the boost timeout will return to idle
// instead of low-power.
void clearLowPower() {
_lowPower = false;
if (!_boosted) {
setCpuFrequencyMhz(CPU_FREQ_IDLE);
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz (low-power cleared)", CPU_FREQ_IDLE);
}
// If boosted, the loop() timeout will drop to idle as normal
}
bool isBoosted() const { return _boosted; }
bool isLowPower() const { return _lowPower; }
uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); }
private:
bool _boosted;
bool _lowPower;
unsigned long _boost_started;
};
#endif // ESP32

View File

@@ -0,0 +1,209 @@
#pragma once
// =============================================================================
// PCF85063Clock — PCF8563/BM8563 RTC driver for T5S3 E-Paper Pro
//
// Time registers at 0x020x08 (PCF8563 layout):
// 0x02 Seconds, 0x03 Minutes, 0x04 Hours,
// 0x05 Days, 0x06 Weekdays, 0x07 Months, 0x08 Years
// =============================================================================
#include <Arduino.h>
#include <Wire.h>
#include <MeshCore.h>
#define PCF8563_ADDR 0x51
#define PCF8563_REG_SECONDS 0x02
// Reject timestamps outside 20242036 (blocks MeshCore contacts garbage)
#define EPOCH_MIN_SANE 1704067200UL
#define EPOCH_MAX_SANE 2082758400UL
class PCF85063Clock : public mesh::RTCClock {
public:
PCF85063Clock() : _wire(nullptr), _millis_offset(0),
_has_hw_time(false), _time_set_this_session(false) {}
bool begin(TwoWire& wire) {
_wire = &wire;
_wire->beginTransmission(PCF8563_ADDR);
if (_wire->endTransmission() != 0) {
Serial.println("[RTC] PCF8563 not found");
return false;
}
// Repair any corrupted registers from prior wrong-offset writes
repairRegisters();
uint32_t t = readHardwareTime();
if (t > EPOCH_MIN_SANE && t < EPOCH_MAX_SANE) {
_has_hw_time = true;
_millis_offset = t - (millis() / 1000);
Serial.printf("[RTC] PCF8563 OK, time=%lu\n", t);
} else {
_has_hw_time = false;
Serial.printf("[RTC] PCF8563 no valid time (%lu), awaiting BLE sync\n", t);
}
return true;
}
uint32_t getCurrentTime() override {
if (_time_set_this_session) {
return _millis_offset + (millis() / 1000);
}
if (_has_hw_time && _wire) {
uint32_t t = readHardwareTime();
if (t > EPOCH_MIN_SANE && t < EPOCH_MAX_SANE) {
_millis_offset = t - (millis() / 1000);
return t;
}
_has_hw_time = false;
}
return _millis_offset + (millis() / 1000);
}
void setCurrentTime(uint32_t time) override {
if (time < EPOCH_MIN_SANE || time > EPOCH_MAX_SANE) {
Serial.printf("[RTC] setCurrentTime(%lu) REJECTED\n", time);
return;
}
_millis_offset = time - (millis() / 1000);
_time_set_this_session = true;
Serial.printf("[RTC] setCurrentTime(%lu) OK\n", time);
if (_wire) writeHardwareTime(time);
}
private:
TwoWire* _wire;
uint32_t _millis_offset;
bool _has_hw_time;
bool _time_set_this_session;
// ---- Register helpers ----
void writeReg(uint8_t reg, uint8_t val) {
_wire->beginTransmission(PCF8563_ADDR);
_wire->write(reg);
_wire->write(val);
_wire->endTransmission();
}
uint8_t readReg(uint8_t reg) {
_wire->beginTransmission(PCF8563_ADDR);
_wire->write(reg);
if (_wire->endTransmission(false) != 0) return 0xFF;
if (_wire->requestFrom((uint8_t)PCF8563_ADDR, (uint8_t)1) != 1) return 0xFF;
return _wire->read();
}
// ---- Fix registers corrupted by prior PCF85063A-mode writes ----
void repairRegisters() {
uint8_t hours = readReg(0x04) & 0x3F;
if (bcd2dec(hours) > 23) {
Serial.printf("[RTC] Repairing hours (0x%02X→0x00)\n", hours);
writeReg(0x04, 0x00);
}
uint8_t days = readReg(0x05) & 0x3F;
if (bcd2dec(days) == 0 || bcd2dec(days) > 31) {
Serial.printf("[RTC] Repairing days (0x%02X→0x01)\n", days);
writeReg(0x05, 0x01);
}
uint8_t month = readReg(0x07) & 0x1F;
if (bcd2dec(month) == 0 || bcd2dec(month) > 12) {
Serial.printf("[RTC] Repairing month (0x%02X→0x01)\n", month);
writeReg(0x07, 0x01);
}
}
// ---- BCD ----
static uint8_t bcd2dec(uint8_t bcd) { return ((bcd >> 4) * 10) + (bcd & 0x0F); }
static uint8_t dec2bcd(uint8_t dec) { return ((dec / 10) << 4) | (dec % 10); }
// ---- Date helpers ----
static bool isLeap(int y) { return (y%4==0 && y%100!=0) || y%400==0; }
static int daysInMonth(int m, int y) {
static const uint8_t d[] = {31,28,31,30,31,30,31,31,30,31,30,31};
return (m==2 && isLeap(y)) ? 29 : d[m-1];
}
static uint32_t toEpoch(int yr, int mo, int dy, int h, int mi, int s) {
uint32_t days = 0;
for (int y = 1970; y < yr; y++) days += isLeap(y) ? 366 : 365;
for (int m = 1; m < mo; m++) days += daysInMonth(m, yr);
days += (dy - 1);
return days * 86400UL + h * 3600UL + mi * 60UL + s;
}
static void fromEpoch(uint32_t ep, int& yr, int& mo, int& dy, int& h, int& mi, int& s) {
s = ep % 60; ep /= 60;
mi = ep % 60; ep /= 60;
h = ep % 24; ep /= 24;
yr = 1970;
while (true) { int d = isLeap(yr)?366:365; if (ep<(uint32_t)d) break; ep-=d; yr++; }
mo = 1;
while (true) { int d = daysInMonth(mo,yr); if (ep<(uint32_t)d) break; ep-=d; mo++; }
dy = ep + 1;
}
// ---- Read time (burst from 0x02) ----
uint32_t readHardwareTime() {
_wire->beginTransmission(PCF8563_ADDR);
_wire->write(PCF8563_REG_SECONDS);
if (_wire->endTransmission(false) != 0) return 0;
if (_wire->requestFrom((uint8_t)PCF8563_ADDR, (uint8_t)7) != 7) return 0;
uint8_t raw[7];
for (int i = 0; i < 7; i++) raw[i] = _wire->read();
if (raw[0] & 0x80) {
Serial.println("[RTC] OS flag set — clearing");
writeReg(PCF8563_REG_SECONDS, raw[0] & 0x7F);
return 0;
}
int second = bcd2dec(raw[0] & 0x7F);
int minute = bcd2dec(raw[1] & 0x7F);
int hour = bcd2dec(raw[2] & 0x3F);
int day = bcd2dec(raw[3] & 0x3F);
int month = bcd2dec(raw[5] & 0x1F);
int year = 2000 + bcd2dec(raw[6]);
if (month<1 || month>12 || day<1 || day>31 || hour>23 || minute>59 || second>59)
return 0;
return toEpoch(year, month, day, hour, minute, second);
}
// ---- Write time (burst to 0x02) ----
void writeHardwareTime(uint32_t epoch) {
int year, month, day, hour, minute, second;
fromEpoch(epoch, year, month, day, hour, minute, second);
static const int dow[] = {0,3,2,5,0,3,5,1,4,6,2,4};
int y = year; if (month < 3) y--;
int wday = (y + y/4 - y/100 + y/400 + dow[month-1] + day) % 7;
int yr = year - 2000;
// Stop clock
writeReg(0x00, 0x20);
delay(5);
// Burst write
_wire->beginTransmission(PCF8563_ADDR);
_wire->write(PCF8563_REG_SECONDS);
_wire->write(dec2bcd(second) & 0x7F);
_wire->write(dec2bcd(minute));
_wire->write(dec2bcd(hour));
_wire->write(dec2bcd(day));
_wire->write(dec2bcd(wday));
_wire->write(dec2bcd(month));
_wire->write(dec2bcd(yr));
_wire->endTransmission();
delay(5);
// Restart clock
writeReg(0x00, 0x00);
Serial.printf("[RTC] Wrote %04d-%02d-%02d %02d:%02d:%02d\n",
year, month, day, hour, minute, second);
}
};

View File

@@ -0,0 +1,305 @@
#include <Arduino.h>
#include "variant.h"
#include "T5S3Board.h"
#include <Mesh.h> // For MESH_DEBUG_PRINTLN
void T5S3Board::begin() {
MESH_DEBUG_PRINTLN("T5S3Board::begin() - starting");
// Initialize I2C with T5S3 V2 pins
// Note: No explicit peripheral power enable needed on T5S3
// (unlike T-Deck Pro's PIN_PERF_POWERON)
Wire.begin(I2C_SDA, I2C_SCL);
Wire.setClock(100000); // 100kHz for reliable fuel gauge communication
MESH_DEBUG_PRINTLN("T5S3Board::begin() - I2C initialized (SDA=%d, SCL=%d)", I2C_SDA, I2C_SCL);
// Call parent class begin (handles CPU freq, etc.)
// Note: ESP32Board::begin() also calls Wire.begin() but with our
// PIN_BOARD_SDA/SCL defines it will use the same pins — harmless.
ESP32Board::begin();
// Configure backlight (off by default — save power)
#ifdef BOARD_BL_EN
pinMode(BOARD_BL_EN, OUTPUT);
digitalWrite(BOARD_BL_EN, LOW);
MESH_DEBUG_PRINTLN("T5S3Board::begin() - backlight pin configured (GPIO%d)", BOARD_BL_EN);
#endif
// Configure user button
pinMode(PIN_USER_BTN, INPUT);
// Configure LoRa SPI MISO pullup
pinMode(P_LORA_MISO, INPUT_PULLUP);
// Handle wake from deep sleep
esp_reset_reason_t reason = esp_reset_reason();
if (reason == ESP_RST_DEEPSLEEP) {
uint64_t wakeup_source = esp_sleep_get_ext1_wakeup_status();
if (wakeup_source & (1ULL << P_LORA_DIO_1)) {
startup_reason = BD_STARTUP_RX_PACKET;
}
rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS);
rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1);
}
// Test BQ27220 communication and configure design capacity
#if HAS_BQ27220
uint16_t voltage = getBattMilliVolts();
MESH_DEBUG_PRINTLN("T5S3Board::begin() - Battery voltage: %d mV", voltage);
configureFuelGauge();
#endif
// Early low-voltage protection
#if HAS_BQ27220 && defined(AUTO_SHUTDOWN_MILLIVOLTS)
{
uint16_t bootMv = getBattMilliVolts();
if (bootMv > 0 && bootMv < AUTO_SHUTDOWN_MILLIVOLTS) {
Serial.printf("CRITICAL: Boot voltage %dmV < %dmV — sleeping immediately\n",
bootMv, AUTO_SHUTDOWN_MILLIVOLTS);
esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
esp_sleep_enable_ext1_wakeup(1ULL << PIN_USER_BTN, ESP_EXT1_WAKEUP_ANY_HIGH);
esp_deep_sleep_start();
}
}
#endif
MESH_DEBUG_PRINTLN("T5S3Board::begin() - complete");
}
// ---- BQ27220 register helpers (static, file-local) ----
#if HAS_BQ27220
static uint16_t bq27220_read16(uint8_t reg) {
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return 0;
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2) != 2) return 0;
uint16_t val = Wire.read();
val |= (Wire.read() << 8);
return val;
}
static uint8_t bq27220_read8(uint8_t reg) {
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return 0;
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)1) != 1) return 0;
return Wire.read();
}
static bool bq27220_writeControl(uint16_t subcmd) {
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x00);
Wire.write(subcmd & 0xFF);
Wire.write((subcmd >> 8) & 0xFF);
return Wire.endTransmission() == 0;
}
#endif
// ---- BQ27220 public interface ----
uint16_t T5S3Board::getBattMilliVolts() {
#if HAS_BQ27220
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(BQ27220_REG_VOLTAGE);
if (Wire.endTransmission(false) != 0) return 0;
uint8_t count = Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2);
if (count != 2) return 0;
uint16_t voltage = Wire.read();
voltage |= (Wire.read() << 8);
return voltage;
#else
return 0;
#endif
}
uint8_t T5S3Board::getBatteryPercent() {
#if HAS_BQ27220
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(BQ27220_REG_SOC);
if (Wire.endTransmission(false) != 0) return 0;
uint8_t count = Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2);
if (count != 2) return 0;
uint16_t soc = Wire.read();
soc |= (Wire.read() << 8);
return (uint8_t)min(soc, (uint16_t)100);
#else
return 0;
#endif
}
int16_t T5S3Board::getAvgCurrent() {
#if HAS_BQ27220
return (int16_t)bq27220_read16(BQ27220_REG_AVG_CURRENT);
#else
return 0;
#endif
}
int16_t T5S3Board::getAvgPower() {
#if HAS_BQ27220
return (int16_t)bq27220_read16(BQ27220_REG_AVG_POWER);
#else
return 0;
#endif
}
uint16_t T5S3Board::getTimeToEmpty() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_TIME_TO_EMPTY);
#else
return 0xFFFF;
#endif
}
uint16_t T5S3Board::getRemainingCapacity() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_REMAIN_CAP);
#else
return 0;
#endif
}
uint16_t T5S3Board::getFullChargeCapacity() {
#if HAS_BQ27220
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
if (fcc > BQ27220_DESIGN_CAPACITY_MAH) fcc = BQ27220_DESIGN_CAPACITY_MAH;
return fcc;
#else
return 0;
#endif
}
uint16_t T5S3Board::getDesignCapacity() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_DESIGN_CAP);
#else
return 0;
#endif
}
int16_t T5S3Board::getBattTemperature() {
#if HAS_BQ27220
uint16_t raw = bq27220_read16(BQ27220_REG_TEMPERATURE);
return (int16_t)(raw - 2731); // 0.1°K to 0.1°C
#else
return 0;
#endif
}
// ---- BQ27220 Design Capacity configuration ----
// Identical procedure to TDeckBoard — sets 1500 mAh for T5S3's larger cell.
// The BQ27220 ships with 3000 mAh default. This writes once on first boot
// and persists in battery-backed RAM.
bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
#if HAS_BQ27220
uint16_t currentDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
Serial.printf("BQ27220: Design Capacity = %d mAh (target %d)\n", currentDC, designCapacity_mAh);
if (currentDC == designCapacity_mAh) {
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
Serial.printf("BQ27220: Design Capacity correct, FCC=%d mAh\n", fcc);
if (fcc < designCapacity_mAh * 3 / 2) {
return true; // FCC is sane, nothing to do
}
// FCC is stale from factory — fall through to reconfigure
Serial.printf("BQ27220: FCC %d >> DC %d, reconfiguring\n", fcc, designCapacity_mAh);
}
// Unseal
bq27220_writeControl(0x0414); delay(2);
bq27220_writeControl(0x3672); delay(2);
// Full Access
bq27220_writeControl(0xFFFF); delay(2);
bq27220_writeControl(0xFFFF); delay(2);
// Enter CFG_UPDATE
bq27220_writeControl(0x0090);
bool cfgReady = false;
for (int i = 0; i < 50; i++) {
delay(20);
uint16_t opStatus = bq27220_read16(BQ27220_REG_OP_STATUS);
if (opStatus & 0x0400) { cfgReady = true; break; }
}
if (!cfgReady) {
Serial.println("BQ27220: Timeout waiting for CFGUPDATE");
bq27220_writeControl(0x0092);
bq27220_writeControl(0x0030);
return false;
}
// Write Design Capacity at 0x929F
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0x9F); Wire.write(0x92);
Wire.endTransmission();
delay(10);
uint8_t oldMSB = bq27220_read8(0x40);
uint8_t oldLSB = bq27220_read8(0x41);
uint8_t oldChk = bq27220_read8(0x60);
uint8_t dataLen = bq27220_read8(0x61);
uint8_t newMSB = (designCapacity_mAh >> 8) & 0xFF;
uint8_t newLSB = designCapacity_mAh & 0xFF;
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0x9F); Wire.write(0x92);
Wire.write(newMSB); Wire.write(newLSB);
Wire.endTransmission();
delay(5);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x60); Wire.write(newChk); Wire.write(dataLen);
Wire.endTransmission();
delay(10);
// Write Design Energy at 0x92A1
{
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
Wire.endTransmission();
delay(10);
uint8_t deOldMSB = bq27220_read8(0x40);
uint8_t deOldLSB = bq27220_read8(0x41);
uint8_t deOldChk = bq27220_read8(0x60);
uint8_t deLen = bq27220_read8(0x61);
uint8_t deNewMSB = (designEnergy >> 8) & 0xFF;
uint8_t deNewLSB = designEnergy & 0xFF;
uint8_t deTemp = (255 - deOldChk - deOldMSB - deOldLSB);
uint8_t deNewChk = 255 - ((deTemp + deNewMSB + deNewLSB) & 0xFF);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
Wire.write(deNewMSB); Wire.write(deNewLSB);
Wire.endTransmission();
delay(5);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x60); Wire.write(deNewChk); Wire.write(deLen);
Wire.endTransmission();
delay(10);
}
// Exit CFG_UPDATE with reinit
bq27220_writeControl(0x0091);
delay(200);
// Seal
bq27220_writeControl(0x0030);
delay(5);
// Force RESET to reinitialize FCC
bq27220_writeControl(0x0041);
delay(1000);
uint16_t verifyDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
uint16_t newFCC = bq27220_read16(BQ27220_REG_FULL_CAP);
Serial.printf("BQ27220: Post-config DC=%d FCC=%d mAh\n", verifyDC, newFCC);
return verifyDC == designCapacity_mAh;
#else
return false;
#endif
}

View File

@@ -0,0 +1,97 @@
#pragma once
#include "variant.h"
#include <Wire.h>
#include <Arduino.h>
#include "helpers/ESP32Board.h"
#include <driver/rtc_io.h>
// BQ27220 Fuel Gauge Registers (shared with TDeckBoard)
#define BQ27220_REG_TEMPERATURE 0x06
#define BQ27220_REG_VOLTAGE 0x08
#define BQ27220_REG_CURRENT 0x0C
#define BQ27220_REG_SOC 0x2C
#define BQ27220_REG_REMAIN_CAP 0x10
#define BQ27220_REG_FULL_CAP 0x12
#define BQ27220_REG_AVG_CURRENT 0x14
#define BQ27220_REG_TIME_TO_EMPTY 0x16
#define BQ27220_REG_AVG_POWER 0x24
#define BQ27220_REG_DESIGN_CAP 0x3C
#define BQ27220_REG_OP_STATUS 0x3A
class T5S3Board : public ESP32Board {
public:
void begin();
void powerOff() override {
btStop();
// Turn off backlight before sleeping
#ifdef BOARD_BL_EN
digitalWrite(BOARD_BL_EN, LOW);
#endif
}
void enterDeepSleep(uint32_t secs, int pin_wake_btn) {
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
// Hold LoRa DIO1 and NSS during deep sleep
rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY);
rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1);
rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS);
if (pin_wake_btn < 0) {
esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH);
} else {
esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1) | (1ULL << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH);
}
if (secs > 0) {
esp_sleep_enable_timer_wakeup(secs * 1000000ULL);
}
esp_deep_sleep_start();
}
// BQ27220 fuel gauge interface (identical register protocol to TDeckBoard)
uint16_t getBattMilliVolts() override;
uint8_t getBatteryPercent();
int16_t getAvgCurrent();
int16_t getAvgPower();
uint16_t getTimeToEmpty();
uint16_t getRemainingCapacity();
uint16_t getFullChargeCapacity();
uint16_t getDesignCapacity();
int16_t getBattTemperature();
bool configureFuelGauge(uint16_t designCapacity_mAh = BQ27220_DESIGN_CAPACITY_MAH);
// Backlight control (GPIO11 — functional warm-tone front-light, PWM capable)
// Brightness 0-255 (0=off, 153=comfortable reading, 255=max)
bool _backlightOn = false;
uint8_t _backlightBrightness = 153; // Same default as Meshtastic
void setBacklight(bool on) {
#ifdef BOARD_BL_EN
_backlightOn = on;
analogWrite(BOARD_BL_EN, on ? _backlightBrightness : 0);
#endif
}
void setBacklightBrightness(uint8_t brightness) {
#ifdef BOARD_BL_EN
_backlightBrightness = brightness;
if (_backlightOn) {
analogWrite(BOARD_BL_EN, brightness);
}
#endif
}
bool isBacklightOn() const { return _backlightOn; }
void toggleBacklight() {
setBacklight(!_backlightOn);
}
const char* getManufacturerName() const {
return "LilyGo T5S3 E-Paper Pro";
}
};

View File

@@ -0,0 +1,19 @@
#ifndef Pins_Arduino_h
#define Pins_Arduino_h
#include <stdint.h>
#define USB_VID 0x303a
#define USB_PID 0x1001
// Default Wire will be mapped to RTC, Touch, PCA9535, BQ25896, BQ27220, TPS65185
static const uint8_t SDA = 39;
static const uint8_t SCL = 40;
// Default SPI will be mapped to LoRa + SD card
static const uint8_t SS = 46; // LoRa CS
static const uint8_t MOSI = 13;
static const uint8_t MISO = 21;
static const uint8_t SCK = 14;
#endif /* Pins_Arduino_h */

View File

@@ -0,0 +1,161 @@
; ===========================================================================
; LilyGo T5 S3 E-Paper Pro (H752-B / V2 hardware)
; 4.7" parallel e-ink (960x540), GT911 touch, SX1262 LoRa, no keyboard
; ===========================================================================
;
; Place t5s3-epaper-pro.json in boards/ directory.
; Place variant files in variants/LilyGo_T5S3_EPaper_Pro/
; Place FastEPDDisplay.h/.cpp in src/helpers/ui/
;
[LilyGo_T5S3_EPaper_Pro]
extends = esp32_base
extra_scripts = post:merge_firmware.py
board = t5s3-epaper-pro
board_build.flash_mode = qio
board_build.f_flash = 80000000L
board_build.arduino.memory_type = qio_opi
board_upload.flash_size = 16MB
build_flags =
${esp32_base.build_flags}
-I variants/LilyGo_T5S3_EPaper_Pro
-D LilyGo_T5S3_EPaper_Pro
-D T5_S3_EPAPER_PRO_V2
-D BOARD_HAS_PSRAM=1
-D CORE_DEBUG_LEVEL=1
-D FORMAT_SPIFFS_IF_FAILED=1
-D FORMAT_LITTLEFS_IF_FAILED=1
-D ARDUINO_USB_CDC_ON_BOOT=1
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
-D LORA_TX_POWER=22
-D SX126X_DIO2_AS_RF_SWITCH
-D SX126X_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1
-D SX126X_DIO3_TCXO_VOLTAGE=2.4f
-D P_LORA_DIO_1=10
-D P_LORA_NSS=46
-D P_LORA_RESET=1
-D P_LORA_BUSY=47
-D P_LORA_SCLK=14
-D P_LORA_MISO=21
-D P_LORA_MOSI=13
-D ENV_INCLUDE_AHTX0=0
-D ENV_INCLUDE_BME280=0
-D ENV_INCLUDE_BMP280=0
-D ENV_INCLUDE_SHTC3=0
-D ENV_INCLUDE_SHT4X=0
-D ENV_INCLUDE_LPS22HB=0
-D ENV_INCLUDE_INA3221=0
-D ENV_INCLUDE_INA219=0
-D ENV_INCLUDE_INA226=0
-D ENV_INCLUDE_INA260=0
-D ENV_INCLUDE_MLX90614=0
-D ENV_INCLUDE_VL53L0X=0
-D ENV_INCLUDE_BME680=0
-D ENV_INCLUDE_BMP085=0
-D HAS_BQ27220=1
-D AUTO_SHUTDOWN_MILLIVOLTS=2800
-D PIN_USER_BTN=0
-D SDCARD_USE_SPI1
-D ARDUINO_LOOP_STACK_SIZE=32768
build_src_filter = ${esp32_base.build_src_filter}
+<../variants/LilyGo_T5S3_EPaper_Pro>
lib_deps =
${esp32_base.lib_deps}
; ---------------------------------------------------------------------------
; T5S3 standalone — touch UI (stub), verify display rendering
; Uses FastEPD for parallel e-ink, Adafruit GFX for drawing
; ---------------------------------------------------------------------------
[env:meck_t5s3_standalone]
extends = LilyGo_T5S3_EPaper_Pro
build_flags =
${LilyGo_T5S3_EPaper_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=1500
-D MAX_GROUP_CHANNELS=20
-D OFFLINE_QUEUE_SIZE=1
-D CHANNEL_MSG_HISTORY_SIZE=800
-D DISPLAY_CLASS=FastEPDDisplay
-D USE_EINK
-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}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<helpers/ui/FastEPDDisplay.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${LilyGo_T5S3_EPaper_Pro.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
; ---------------------------------------------------------------------------
; T5S3 BLE companion — touch UI, BLE phone bridging
; Connect via MeshCore iOS/Android app over Bluetooth
; Flash: pio run -e meck_t5s3_ble -t upload
; ---------------------------------------------------------------------------
[env:meck_t5s3_ble]
extends = LilyGo_T5S3_EPaper_Pro
build_flags =
${LilyGo_T5S3_EPaper_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=500
-D MAX_GROUP_CHANNELS=20
-D BLE_PIN_CODE=123456
-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>
+<helpers/ui/MomentaryButton.cpp>
+<helpers/ui/FastEPDDisplay.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${LilyGo_T5S3_EPaper_Pro.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
; ---------------------------------------------------------------------------
; T5S3 WiFi companion — touch UI, WiFi phone bridging, web browser
; Connect via MeshCore web app or meshcore.js over local network (TCP:5000)
; MECK_WEB_READER: shares WiFi companion connection — no extra setup needed
; Flash: pio run -e meck_t5s3_wifi -t upload
; ---------------------------------------------------------------------------
[env:meck_t5s3_wifi]
extends = LilyGo_T5S3_EPaper_Pro
build_flags =
${LilyGo_T5S3_EPaper_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=1500
-D MAX_GROUP_CHANNELS=20
-D MECK_WIFI_COMPANION=1
-D MECK_WEB_READER=1
-D TCP_PORT=5000
-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>
+<helpers/ui/MomentaryButton.cpp>
+<helpers/ui/FastEPDDisplay.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${LilyGo_T5S3_EPaper_Pro.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

View File

@@ -0,0 +1,91 @@
#include <Arduino.h>
#include "variant.h"
#include "target.h"
T5S3Board board;
// LoRa radio on separate SPI bus
// T5S3 V2 SPI pins: SCLK=14, MISO=21, MOSI=13 (shared with SD card)
#if defined(P_LORA_SCLK)
static SPIClass loraSpi(HSPI);
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, loraSpi);
#else
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY);
#endif
WRAPPER_CLASS radio_driver(radio, board);
PCF85063Clock rtc_clock;
// No GPS on H752-B
#if HAS_GPS
GPSStreamCounter gpsStream(Serial2);
MicroNMEALocationProvider gps(gpsStream, &rtc_clock);
EnvironmentSensorManager sensors(gps);
#else
SensorManager sensors;
#endif
// Phase 2: Display
#ifdef DISPLAY_CLASS
DISPLAY_CLASS display;
MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
#endif
bool radio_init() {
MESH_DEBUG_PRINTLN("radio_init() - starting");
// NOTE: board.begin() is called by main.cpp setup() before radio_init()
// I2C is already initialized there with correct pins
// PCF85063 hardware RTC — reads correct registers (0x040x0A)
// Unlike AutoDiscoverRTCClock which uses RTClib's PCF8563 driver (wrong registers)
rtc_clock.begin(Wire);
MESH_DEBUG_PRINTLN("radio_init() - PCF85063 RTC started");
#if defined(P_LORA_SCLK)
MESH_DEBUG_PRINTLN("radio_init() - initializing LoRa SPI (SCLK=%d, MISO=%d, MOSI=%d, NSS=%d)...",
P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS);
loraSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS);
MESH_DEBUG_PRINTLN("radio_init() - SPI initialized, calling radio.std_init()...");
bool result = radio.std_init(&loraSpi);
if (result) {
radio.setPreambleLength(32);
MESH_DEBUG_PRINTLN("radio_init() - preamble set to 32 symbols");
}
MESH_DEBUG_PRINTLN("radio_init() - radio.std_init() returned: %s", result ? "SUCCESS" : "FAILED");
return result;
#else
MESH_DEBUG_PRINTLN("radio_init() - calling radio.std_init() without custom SPI...");
bool result = radio.std_init();
if (result) {
radio.setPreambleLength(32);
MESH_DEBUG_PRINTLN("radio_init() - preamble set to 32 symbols");
}
return result;
#endif
}
uint32_t radio_get_rng_seed() {
return radio.random(0x7FFFFFFF);
}
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
radio.setFrequency(freq);
radio.setSpreadingFactor(sf);
radio.setBandwidth(bw);
radio.setCodingRate(cr);
}
void radio_set_tx_power(uint8_t dbm) {
radio.setOutputPower(dbm);
}
mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng);
}
void radio_reset_agc() {
radio.setRxBoostedGainMode(true);
}

View File

@@ -0,0 +1,50 @@
#pragma once
// Include variant.h first to ensure all board-specific defines are available
#include "variant.h"
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <T5S3Board.h>
#include "PCF85063Clock.h"
// Display support — FastEPDDisplay for parallel e-ink (not GxEPD2)
#ifdef DISPLAY_CLASS
#include <helpers/ui/FastEPDDisplay.h>
#include <helpers/ui/MomentaryButton.h>
#endif
// No GPS on H752-B (non-GPS variant)
// If porting to H752-01/H752-02 with GPS, enable this:
#if HAS_GPS
#include "helpers/sensors/EnvironmentSensorManager.h"
#include "helpers/sensors/MicroNMEALocationProvider.h"
#include "GPSStreamCounter.h"
#else
#include <helpers/SensorManager.h>
#endif
extern T5S3Board board;
extern WRAPPER_CLASS radio_driver;
extern PCF85063Clock rtc_clock;
#if HAS_GPS
extern GPSStreamCounter gpsStream;
extern EnvironmentSensorManager sensors;
#else
extern SensorManager sensors;
#endif
#ifdef DISPLAY_CLASS
extern DISPLAY_CLASS display;
extern MomentaryButton user_btn;
#endif
bool radio_init();
uint32_t radio_get_rng_seed();
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr);
void radio_set_tx_power(uint8_t dbm);
mesh::LocalIdentity radio_new_identity();
void radio_reset_agc();

View File

@@ -0,0 +1,189 @@
#pragma once
// =============================================================================
// LilyGo T5 S3 E-Paper Pro V2 (H752-01/H752-B) - Pin Definitions for Meck
//
// 4.7" parallel e-ink (ED047TC1, 960x540, 16-grey) — NO SPI display
// GT911 capacitive touch (no physical keyboard)
// SX1262 LoRa, BQ27220+BQ25896 battery, PCF85063 RTC, PCA9535 IO expander
// =============================================================================
// Board identifier
#define LilyGo_T5S3_EPaper_Pro 1
#define T5_S3_EPAPER_PRO_V2 1
// -----------------------------------------------------------------------------
// I2C Bus — shared by GT911, PCF85063, PCA9535, BQ25896, BQ27220, TPS65185
// -----------------------------------------------------------------------------
#define I2C_SDA 39
#define I2C_SCL 40
// Aliases for ESP32Board base class compatibility
#define PIN_BOARD_SDA I2C_SDA
#define PIN_BOARD_SCL I2C_SCL
// I2C Device Addresses
#define I2C_ADDR_GT911 0x5D // Touch controller
#define I2C_ADDR_PCF85063 0x51 // RTC
#define I2C_ADDR_PCA9535 0x20 // IO expander (e-ink power control)
#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
// Different from T-Deck Pro! (T-Deck: 33/47/36, T5S3: 13/21/14)
// -----------------------------------------------------------------------------
#define BOARD_SPI_SCLK 14
#define BOARD_SPI_MISO 21
#define BOARD_SPI_MOSI 13
// -----------------------------------------------------------------------------
// LoRa Radio (SX1262)
// SPI bus shared with SD card, different chip selects
// -----------------------------------------------------------------------------
#define P_LORA_NSS 46
#define P_LORA_DIO_1 10 // IRQ
#define P_LORA_RESET 1
#define P_LORA_BUSY 47
#define P_LORA_SCLK BOARD_SPI_SCLK
#define P_LORA_MISO BOARD_SPI_MISO
#define P_LORA_MOSI BOARD_SPI_MOSI
// Note: No P_LORA_EN on T5S3 — LoRa is always powered
// -----------------------------------------------------------------------------
// E-Ink Display (ED047TC1 — 8-bit parallel, NOT SPI)
// Driven by epdiy/FastEPD library via TPS65185 + PCA9535
// GxEPD2 is NOT used on this board.
// -----------------------------------------------------------------------------
// Parallel data bus (directly wired to ESP32-S3 GPIOs)
#define EP_D0 5
#define EP_D1 6
#define EP_D2 7
#define EP_D3 15
#define EP_D4 16
#define EP_D5 17
#define EP_D6 18
#define EP_D7 8
// Control signals
#define EP_CKV 48 // Clock vertical
#define EP_STH 41 // Start horizontal
#define EP_LEH 42 // Latch enable horizontal
#define EP_STV 45 // Start vertical
#define EP_CKH 4 // Clock horizontal (edge)
// E-ink power is managed by TPS65185 through PCA9535 IO expander:
// PCA9535 IO10 -> EP_OE (output enable, source driver)
// PCA9535 IO11 -> EP_MODE (output mode, gate driver)
// PCA9535 IO13 -> TPS_PWRUP
// PCA9535 IO14 -> VCOM_CTRL
// PCA9535 IO15 -> TPS_WAKEUP
// PCA9535 IO16 -> TPS_PWR_GOOD (input)
// PCA9535 IO17 -> TPS_INT (input)
// Display dimensions — native resolution of ED047TC1
#define EPD_WIDTH 960
#define EPD_HEIGHT 540
// Backlight (warm-tone front-light — functional on V2!)
#define BOARD_BL_EN 11
// We do NOT define DISPLAY_CLASS or EINK_DISPLAY_MODEL here.
// The parallel display uses FastEPD, not GxEPD2.
// DISPLAY_CLASS will be defined in platformio.ini as FastEPDDisplay
// for builds that include display support.
// -----------------------------------------------------------------------------
// Touch Controller (GT911)
// No physical keyboard on this board — touch-only input
// -----------------------------------------------------------------------------
#define HAS_TOUCHSCREEN 1
#define GT911_PIN_INT 3
#define GT911_PIN_RST 9
#define GT911_PIN_SDA I2C_SDA
#define GT911_PIN_SCL I2C_SCL
// No keyboard
// #define HAS_PHYSICAL_KEYBOARD 0
// Compatibility: main.cpp references CST328 touch (T-Deck Pro).
// Map to GT911 equivalents so shared code compiles.
// The actual touch init for T5S3 will use GT911 in Phase 2.
#define CST328_PIN_INT GT911_PIN_INT
#define CST328_PIN_RST GT911_PIN_RST
// -----------------------------------------------------------------------------
// SD Card — shares SPI bus with LoRa
// -----------------------------------------------------------------------------
#define HAS_SDCARD
#define SDCARD_USE_SPI1
#define SDCARD_CS 12
#define SPI_CS SDCARD_CS
// -----------------------------------------------------------------------------
// GPS — Not present on H752-B (non-GPS variant)
// If a GPS model is used (H752-01/H752-02), define HAS_GPS=1
// and uncomment the GPS pins below.
// -----------------------------------------------------------------------------
// #define HAS_GPS 1
// #define GPS_BAUDRATE 38400
// #define GPS_RX_PIN 44
// #define GPS_TX_PIN 43
// Fallback for code that references GPS_BAUDRATE without HAS_GPS guard
// (e.g. MyMesh.cpp CLI rescue command)
#ifndef GPS_BAUDRATE
#define GPS_BAUDRATE 9600
#endif
// -----------------------------------------------------------------------------
// RTC — PCF85063 (proper hardware RTC, battery-backed!)
// This is a significant upgrade over T-Deck Pro which has no RTC.
// -----------------------------------------------------------------------------
#define HAS_PCF85063_RTC 1
#define PCF85063_I2C_ADDR 0x51
#define PCF85063_INT_PIN 2
// -----------------------------------------------------------------------------
// PCA9535 IO Expander
// Controls e-ink power sequencing and has a user button
// -----------------------------------------------------------------------------
#define HAS_PCA9535 1
#define PCA9535_I2C_ADDR 0x20
#define PCA9535_INT_PIN 38
// PCA9535 pin assignments (directly from LilyGo schematic):
// Port 0 (IO0x): IO00-IO07 — mostly unused/reserved
// Port 1 (IO1x):
#define PCA9535_EP_OE 0 // IO10 — EP output enable (source driver)
#define PCA9535_EP_MODE 1 // IO11 — EP mode (gate driver)
#define PCA9535_BUTTON 2 // IO12 — User button via IO expander
#define PCA9535_TPS_PWRUP 3 // IO13 — TPS65185 power up
#define PCA9535_VCOM_CTRL 4 // IO14 — VCOM control
#define PCA9535_TPS_WAKEUP 5 // IO15 — TPS65185 wakeup
#define PCA9535_TPS_PWRGOOD 6 // IO16 — TPS65185 power good (input)
#define PCA9535_TPS_INT 7 // IO17 — TPS65185 interrupt (input)
// -----------------------------------------------------------------------------
// Buttons & Controls
// -----------------------------------------------------------------------------
#define BUTTON_PIN 0 // Boot button (GPIO0)
#define PIN_USER_BTN 0
// -----------------------------------------------------------------------------
// Power Management
// -----------------------------------------------------------------------------
#define HAS_BQ27220 1
#define BQ27220_I2C_ADDR 0x55
// T5S3 E-Paper Pro battery (1500 mAh — larger than T-Deck Pro's 1400 mAh)
#ifndef BQ27220_DESIGN_CAPACITY_MAH
#define BQ27220_DESIGN_CAPACITY_MAH 1500
#endif
#define AUTO_SHUTDOWN_MILLIVOLTS 2800
// No explicit peripheral power pin on T5S3 (unlike T-Deck Pro's PIN_PERF_POWERON)
// Peripherals are always powered when the board is on.

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

@@ -1,185 +0,0 @@
#pragma once
#include <Arduino.h>
#include "variant.h"
#include "GPSStreamCounter.h"
// GPS Duty Cycle Manager
// Controls the hardware GPS enable pin (PIN_GPS_EN) to save power.
// When enabled, cycles between acquiring a fix and sleeping with power cut.
//
// States:
// OFF User has disabled GPS. Hardware power is cut.
// ACQUIRING GPS module powered on, waiting for a fix or timeout.
// SLEEPING GPS module powered off, timer counting down to next cycle.
#if HAS_GPS
// How long to leave GPS powered on while acquiring a fix (ms)
#ifndef GPS_ACQUIRE_TIMEOUT_MS
#define GPS_ACQUIRE_TIMEOUT_MS 180000 // 3 minutes
#endif
// How long to sleep between acquisition cycles (ms)
#ifndef GPS_SLEEP_DURATION_MS
#define GPS_SLEEP_DURATION_MS 900000 // 15 minutes
#endif
// If we get a fix quickly, power off immediately but still respect
// a minimum on-time so the RTC can sync properly
#ifndef GPS_MIN_ON_TIME_MS
#define GPS_MIN_ON_TIME_MS 5000 // 5 seconds after fix
#endif
enum class GPSDutyState : uint8_t {
OFF = 0, // User-disabled, hardware power off
ACQUIRING, // Hardware on, waiting for fix
SLEEPING // Hardware off, timer running
};
class GPSDutyCycle {
public:
GPSDutyCycle() : _state(GPSDutyState::OFF), _state_entered(0),
_last_fix_time(0), _got_fix(false), _time_synced(false),
_stream(nullptr) {}
// Attach the stream counter so we can reset it on power cycles
void setStreamCounter(GPSStreamCounter* stream) { _stream = stream; }
// Call once in setup() after board.begin() and GPS serial init.
void begin(bool initial_enable) {
if (initial_enable) {
_powerOn();
_setState(GPSDutyState::ACQUIRING);
} else {
_powerOff();
_setState(GPSDutyState::OFF);
}
}
// Call every iteration of loop().
// Returns true if GPS hardware is currently powered on.
bool loop() {
switch (_state) {
case GPSDutyState::OFF:
return false;
case GPSDutyState::ACQUIRING: {
unsigned long elapsed = millis() - _state_entered;
if (_got_fix && elapsed >= GPS_MIN_ON_TIME_MS) {
MESH_DEBUG_PRINTLN("GPS duty: fix acquired, powering off for %u min",
(unsigned)(GPS_SLEEP_DURATION_MS / 60000));
_powerOff();
_setState(GPSDutyState::SLEEPING);
return false;
}
if (elapsed >= GPS_ACQUIRE_TIMEOUT_MS) {
MESH_DEBUG_PRINTLN("GPS duty: acquire timeout (%us), sleeping",
(unsigned)(GPS_ACQUIRE_TIMEOUT_MS / 1000));
_powerOff();
_setState(GPSDutyState::SLEEPING);
return false;
}
return true;
}
case GPSDutyState::SLEEPING: {
if (millis() - _state_entered >= GPS_SLEEP_DURATION_MS) {
MESH_DEBUG_PRINTLN("GPS duty: waking up for next acquisition cycle");
_got_fix = false;
_powerOn();
_setState(GPSDutyState::ACQUIRING);
return true;
}
return false;
}
}
return false;
}
void notifyFix() {
if (_state == GPSDutyState::ACQUIRING && !_got_fix) {
_got_fix = true;
_last_fix_time = millis();
MESH_DEBUG_PRINTLN("GPS duty: fix notification received");
}
}
void notifyTimeSync() {
_time_synced = true;
}
void enable() {
if (_state == GPSDutyState::OFF) {
_got_fix = false;
_powerOn();
_setState(GPSDutyState::ACQUIRING);
MESH_DEBUG_PRINTLN("GPS duty: enabled, starting acquisition");
}
}
void disable() {
_powerOff();
_setState(GPSDutyState::OFF);
_got_fix = false;
MESH_DEBUG_PRINTLN("GPS duty: disabled, power off");
}
void forceWake() {
if (_state == GPSDutyState::SLEEPING) {
_got_fix = false;
_powerOn();
_setState(GPSDutyState::ACQUIRING);
MESH_DEBUG_PRINTLN("GPS duty: forced wake for user request");
}
}
GPSDutyState getState() const { return _state; }
bool isHardwareOn() const { return _state == GPSDutyState::ACQUIRING; }
bool hadFix() const { return _got_fix; }
bool hasTimeSynced() const { return _time_synced; }
uint32_t sleepRemainingSecs() const {
if (_state != GPSDutyState::SLEEPING) return 0;
unsigned long elapsed = millis() - _state_entered;
if (elapsed >= GPS_SLEEP_DURATION_MS) return 0;
return (GPS_SLEEP_DURATION_MS - elapsed) / 1000;
}
uint32_t acquireElapsedSecs() const {
if (_state != GPSDutyState::ACQUIRING) return 0;
return (millis() - _state_entered) / 1000;
}
private:
void _powerOn() {
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
delay(10);
#endif
if (_stream) _stream->resetCounters();
}
void _powerOff() {
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
#endif
}
void _setState(GPSDutyState s) {
_state = s;
_state_entered = millis();
}
GPSDutyState _state;
unsigned long _state_entered;
unsigned long _last_fix_time;
bool _got_fix;
bool _time_synced;
GPSStreamCounter* _stream;
};
#endif // HAS_GPS

View File

@@ -78,6 +78,25 @@ void TDeckBoard::begin() {
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - Battery voltage: %d mV", voltage);
configureFuelGauge();
#endif
// --- Early low-voltage protection ---
// If we boot below the shutdown threshold, go straight to deep sleep
// WITHOUT touching the filesystem. This breaks the brown-out reboot
// loop that corrupts contacts when battery is deeply depleted (~2.5V).
#if HAS_BQ27220 && defined(AUTO_SHUTDOWN_MILLIVOLTS)
{
uint16_t bootMv = getBattMilliVolts();
if (bootMv > 0 && bootMv < AUTO_SHUTDOWN_MILLIVOLTS) {
Serial.printf("CRITICAL: Boot voltage %dmV < %dmV — sleeping immediately\n",
bootMv, AUTO_SHUTDOWN_MILLIVOLTS);
// Don't mount SD, don't load contacts, don't pass Go.
// Only wake on user button press (presumably after plugging in charger).
esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
esp_sleep_enable_ext1_wakeup(1ULL << PIN_USER_BTN, ESP_EXT1_WAKEUP_ANY_HIGH);
esp_deep_sleep_start(); // CPU halts here
}
}
#endif
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - complete");
}
@@ -161,7 +180,7 @@ static bool bq27220_writeControl(uint16_t subcmd) {
#endif
// ---- BQ27220 Design Capacity configuration ----
// The BQ27220 ships with a 3000 mAh default. The T-Deck Pro uses a 1400 mAh
// The BQ27220 ships with a 3000 mAh default. The T-Deck Pro uses a 2000 mAh
// cell. This function checks on boot and writes the correct value via the
// MAC Data Memory interface if needed. The value persists in battery-backed
// RAM, so this typically only writes once (or after a full battery disconnect).
@@ -177,7 +196,162 @@ bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
Serial.printf("BQ27220: Design Capacity = %d mAh (target %d)\n", currentDC, designCapacity_mAh);
if (currentDC == designCapacity_mAh) {
Serial.println("BQ27220: Design Capacity already correct, skipping");
// Design Capacity correct, but check if Full Charge Capacity is sane.
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
Serial.printf("BQ27220: Design Capacity already correct, FCC=%d mAh\n", fcc);
if (fcc >= designCapacity_mAh * 3 / 2) {
// FCC is >=150% of design — stale from factory defaults (typically 3000 mAh).
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
Serial.printf("BQ27220: FCC %d >> DC %d, checking Design Energy (target %d mWh)\n",
fcc, designCapacity_mAh, designEnergy);
// Unseal to read data memory and issue RESET
bq27220_writeControl(0x0414); delay(2);
bq27220_writeControl(0x3672); delay(2);
// Full Access
bq27220_writeControl(0xFFFF); delay(2);
bq27220_writeControl(0xFFFF); delay(2);
// Read current Design Energy from data memory to check if it needs writing
// Enter CFG_UPDATE to access data memory
bq27220_writeControl(0x0090);
bool ready = false;
for (int i = 0; i < 50; i++) {
delay(20);
uint16_t opSt = bq27220_read16(BQ27220_REG_OP_STATUS);
if (opSt & 0x0400) { ready = true; break; }
}
if (ready) {
// Read Design Energy at data memory address 0x92A1
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
Wire.endTransmission();
delay(10);
uint8_t oldMSB = bq27220_read8(0x40);
uint8_t oldLSB = bq27220_read8(0x41);
uint16_t currentDE = (oldMSB << 8) | oldLSB;
if (currentDE != designEnergy) {
// Design Energy actually needs updating — write it
uint8_t oldChk = bq27220_read8(0x60);
uint8_t dLen = bq27220_read8(0x61);
uint8_t newMSB = (designEnergy >> 8) & 0xFF;
uint8_t newLSB = designEnergy & 0xFF;
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
Serial.printf("BQ27220: DE old=%d new=%d mWh, writing\n", currentDE, designEnergy);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
Wire.write(newMSB); Wire.write(newLSB);
Wire.endTransmission();
delay(5);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x60); Wire.write(newChk); Wire.write(dLen);
Wire.endTransmission();
delay(10);
// Exit with reinit since we actually changed data
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
delay(200);
Serial.println("BQ27220: Design Energy written, exited CFG_UPDATE");
} else {
// DC=2000, DE=7400, Update Status=0x00, but FCC is stuck at 3000.
// Diagnostic scan found the culprits:
// 0x9106 = Qmax Cell 0 (IT Cfg class) — the raw capacity the
// gauge uses for FCC calculation. Factory default 3000.
// 0x929D = Stored FCC reference (Gas Gauging class, 2 bytes
// before Design Capacity). Also stuck at 3000.
//
// Fix: overwrite both with designCapacity_mAh (2000).
Serial.printf("BQ27220: DE correct (%d mWh) — fixing Qmax + stored FCC\n", currentDE);
// --- Helper lambda for MAC data memory 2-byte write ---
// Reads old value + checksum, computes differential checksum, writes new value.
auto writeDM16 = [](uint16_t addr, uint16_t newVal) -> bool {
// Select address
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E);
Wire.write(addr & 0xFF);
Wire.write((addr >> 8) & 0xFF);
Wire.endTransmission();
delay(10);
uint8_t oldMSB = bq27220_read8(0x40);
uint8_t oldLSB = bq27220_read8(0x41);
uint8_t oldChk = bq27220_read8(0x60);
uint8_t dLen = bq27220_read8(0x61);
uint16_t oldVal = (oldMSB << 8) | oldLSB;
if (oldVal == newVal) {
Serial.printf("BQ27220: [0x%04X] already %d, skip\n", addr, newVal);
return true; // already correct
}
uint8_t newMSB = (newVal >> 8) & 0xFF;
uint8_t newLSB = newVal & 0xFF;
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
Serial.printf("BQ27220: [0x%04X] %d -> %d\n", addr, oldVal, newVal);
// Write new value
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E);
Wire.write(addr & 0xFF);
Wire.write((addr >> 8) & 0xFF);
Wire.write(newMSB);
Wire.write(newLSB);
Wire.endTransmission();
delay(5);
// Write checksum
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x60);
Wire.write(newChk);
Wire.write(dLen);
Wire.endTransmission();
delay(10);
return true;
};
// Overwrite Qmax Cell 0 (IT Cfg) — this is what FCC is derived from
writeDM16(0x9106, designCapacity_mAh);
// Overwrite stored FCC reference (Gas Gauging, 2 bytes before DC)
writeDM16(0x929D, designCapacity_mAh);
// Exit with reinit to apply the new values
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
delay(200);
Serial.println("BQ27220: Qmax + stored FCC updated, exited CFG_UPDATE");
}
} else {
Serial.println("BQ27220: Failed to enter CFG_UPDATE for DE check");
}
// Seal first, then issue RESET.
// RESET forces the gauge to fully reinitialize its Impedance Track
// algorithm and recalculate FCC from the current DC/DE values.
// This is the actual fix when DC and DE are correct but FCC is stuck.
bq27220_writeControl(0x0030); // SEAL
delay(5);
Serial.println("BQ27220: Issuing RESET to force FCC recalculation...");
bq27220_writeControl(0x0041); // RESET
delay(2000); // Full reset needs generous settle time
fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
Serial.printf("BQ27220: FCC after RESET: %d mAh (target <= %d)\n", fcc, designCapacity_mAh);
if (fcc > designCapacity_mAh * 3 / 2) {
// RESET didn't fix FCC — the gauge IT algorithm is stubbornly
// retaining its learned value. This typically resolves after one
// full charge/discharge cycle. Software clamp in
// getFullChargeCapacity() ensures correct display regardless.
Serial.printf("BQ27220: FCC still stale at %d — software clamp active\n", fcc);
}
}
return true;
}
@@ -281,6 +455,39 @@ bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
Serial.printf("BQ27220: Verify in CFGUPDATE: DC bytes=0x%02X 0x%02X (%d mAh)\n",
verMSB, verLSB, (verMSB << 8) | verLSB);
// Step 4g: Also update Design Energy (address 0x92A1) while in CFG_UPDATE.
// Design Energy = capacity × 3.7V (nominal LiPo voltage).
// The gauge uses both DC and DE to compute Full Charge Capacity.
{
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
Wire.endTransmission();
delay(10);
uint8_t deOldMSB = bq27220_read8(0x40);
uint8_t deOldLSB = bq27220_read8(0x41);
uint8_t deOldChk = bq27220_read8(0x60);
uint8_t deLen = bq27220_read8(0x61);
uint8_t deNewMSB = (designEnergy >> 8) & 0xFF;
uint8_t deNewLSB = designEnergy & 0xFF;
uint8_t deTemp = (255 - deOldChk - deOldMSB - deOldLSB);
uint8_t deNewChk = 255 - ((deTemp + deNewMSB + deNewLSB) & 0xFF);
Serial.printf("BQ27220: Design Energy: old=%d new=%d mWh\n",
(deOldMSB << 8) | deOldLSB, designEnergy);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
Wire.write(deNewMSB); Wire.write(deNewLSB);
Wire.endTransmission();
delay(5);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x60); Wire.write(deNewChk); Wire.write(deLen);
Wire.endTransmission();
delay(10);
}
// Step 5: Exit CFG_UPDATE (with reinit to apply changes immediately)
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
Serial.println("BQ27220: Sent EXIT_CFG_UPDATE_REINIT, waiting...");
@@ -291,16 +498,30 @@ bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
Serial.printf("BQ27220: Design Capacity now reads %d mAh (expected %d)\n",
verifyDC, designCapacity_mAh);
uint16_t newFCC = bq27220_read16(BQ27220_REG_FULL_CAP);
Serial.printf("BQ27220: Full Charge Capacity: %d mAh\n", newFCC);
if (verifyDC == designCapacity_mAh) {
Serial.println("BQ27220: Configuration SUCCESS");
} else {
Serial.println("BQ27220: Configuration FAILED");
}
// Step 7: Seal the device
// Step 6: Seal the device
bq27220_writeControl(0x0030);
delay(5);
// Step 7: Force full gauge RESET to reinitialize FCC from new DC/DE.
// Without this, the Impedance Track algorithm retains the old FCC
// (often 3000 mAh from factory) until a full charge/discharge cycle.
bq27220_writeControl(0x0041); // RESET
delay(1000); // Gauge needs time to fully reinitialize
// Re-verify after hard reset
verifyDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
newFCC = bq27220_read16(BQ27220_REG_FULL_CAP);
Serial.printf("BQ27220: Post-RESET DC=%d FCC=%d mAh\n", verifyDC, newFCC);
return verifyDC == designCapacity_mAh;
#else
return false;
@@ -341,7 +562,12 @@ uint16_t TDeckBoard::getRemainingCapacity() {
uint16_t TDeckBoard::getFullChargeCapacity() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_FULL_CAP);
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
// Clamp to design capacity — the gauge may report a stale factory FCC
// (e.g. 3000 mAh) until it completes a full learning cycle. Never let
// the reported FCC exceed what the actual cell can hold.
if (fcc > BQ27220_DESIGN_CAPACITY_MAH) fcc = BQ27220_DESIGN_CAPACITY_MAH;
return fcc;
#else
return 0;
#endif

View File

@@ -256,14 +256,12 @@ public:
return KB_KEY_EMOJI;
}
// Handle Mic key - produces 0 with Sym, otherwise ignore
// Handle Mic key - always produces '0' (silk-screened on key)
// Sym+Mic also produces '0' (consumes sym so it doesn't leak)
if (keyCode == 34) {
if (_symActive) {
_symActive = false;
Serial.println("KB: Sym+Mic -> '0'");
return '0';
}
return 0; // Ignore mic without Sym
_symActive = false;
Serial.println("KB: Mic -> '0'");
return '0';
}
// Get the character

View File

@@ -1,5 +1,6 @@
[LilyGo_TDeck_Pro]
extends = esp32_base
extra_scripts = post:merge_firmware.py
board = t-deck_pro
board_build.flash_mode = qio
board_build.f_flash = 80000000L
@@ -10,6 +11,7 @@ build_flags =
${sensor_base.build_flags}
-I variants/LilyGo_TDeck_Pro
-D LilyGo_TDeck_Pro
-D HAS_GPS=1
-D BOARD_HAS_PSRAM=1
-D CORE_DEBUG_LEVEL=1
-D FORMAT_SPIFFS_IF_FAILED=1
@@ -62,6 +64,8 @@ build_flags =
-D EINK_MOSI=33
-D EINK_BL=45
-D EINK_NOT_HIBERNATE=1
-D HAS_BQ27220=1
-D AUTO_SHUTDOWN_MILLIVOLTS=2800
-D EINK_LIMIT_FASTREFRESH=10
-D EINK_LIMIT_GHOSTING_PX=2000
-D DISPLAY_ROTATION=0
@@ -79,8 +83,9 @@ 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 FIRMWARE_VERSION='"Meck v0.9.3A"'
-D ARDUINO_LOOP_STACK_SIZE=32768
build_src_filter = ${esp32_base.build_src_filter}
+<../variants/LilyGo_TDeck_Pro>
@@ -90,24 +95,58 @@ lib_deps =
${sensor_base.lib_deps}
zinggjm/GxEPD2@^1.5.9
adafruit/Adafruit GFX Library@^1.11.0
bakercp/CRC32@^2.0.0
bitbank2/PNGdec@^1.0.1
; ---------------------------------------------------------------------------
; Meck unified builds — one codebase, three variants via build flags
; Meck unified builds — one codebase, six variants via build flags
; ---------------------------------------------------------------------------
; Audio + BLE companion (audio-player hardware with BLE phone bridging)
; MAX_CONTACTS=500 is near BLE protocol ceiling (MAX_CONTACTS/2 sent as uint8_t, max 510)
[env:meck_audio_ble]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=400
-D MAX_CONTACTS=500
-D MAX_GROUP_CHANNELS=20
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
-D MECK_AUDIO_VARIANT
; -D MECK_WEB_READER=1
-D MECK_WEB_READER=1
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
; Audio + WiFi companion (audio-player hardware with WiFi app bridging)
; No BLE — WiFi companion uses SerialWifiInterface (TCP socket on port 5000).
; Connect via MeshCore web app, meshcore.js, or Python CLI over local network.
; No BLE protocol ceiling on contacts; bumped to 1500 (PSRAM-backed).
; WiFi always on from boot — web reader works without teardown, extra free heap.
; WiFi credentials loaded from SD card at runtime (/web/wifi.cfg).
; Configure via Settings > WiFi Setup, or through the web reader.
[env:meck_audio_wifi]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=1500
-D MAX_GROUP_CHANNELS=20
-D MECK_WIFI_COMPANION=1
-D TCP_PORT=5000
-D WIFI_DEBUG_LOGGING=1
-D OFFLINE_QUEUE_SIZE=256
-D MECK_AUDIO_VARIANT
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.2.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -122,14 +161,15 @@ lib_deps =
; Audio standalone (audio-player hardware, no BLE/WiFi — maximum battery life)
; No MECK_WEB_READER: WiFi power draw conflicts with zero-radio-power design.
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
[env:meck_audio_standalone]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D OFFLINE_QUEUE_SIZE=256
-D MAX_CONTACTS=1500
-D MAX_GROUP_CHANNELS=20
-D OFFLINE_QUEUE_SIZE=1
-D MECK_AUDIO_VARIANT
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
@@ -144,18 +184,75 @@ lib_deps =
bitbank2/JPEGDEC
; 4G + BLE companion (4G modem hardware, no audio — GPIO conflict with PCM5102A)
; MAX_CONTACTS=500 is near BLE protocol ceiling (MAX_CONTACTS/2 sent as uint8_t, max 510)
[env:meck_4g_ble]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=400
-D MAX_CONTACTS=500
-D MAX_GROUP_CHANNELS=20
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
; -D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v0.9.3-4G"'
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.2.4G"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
; 4G + WiFi companion (4G modem hardware with WiFi app bridging, no audio)
; No BLE — WiFi companion uses SerialWifiInterface (TCP socket on port 5000).
; Connect via MeshCore web app, meshcore.js, or Python CLI over local network.
; WiFi credentials loaded from SD card at runtime (/web/wifi.cfg).
; Configure via Settings > WiFi Setup, or through the web reader.
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
[env:meck_4g_wifi]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=1500
-D MAX_GROUP_CHANNELS=20
-D MECK_WIFI_COMPANION=1
-D TCP_PORT=5000
-D WIFI_DEBUG_LOGGING=1
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.2.4G.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
; 4G standalone (4G modem hardware, no BLE — maximum battery + cellular features)
; No BLE_PIN_CODE: BLE never initializes, saving ~30KB heap + radio power.
; MECK_WEB_READER enabled: works better without BLE (no teardown dance needed,
; more free heap from boot). WiFi-first with cellular PPP fallback (future).
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
[env:meck_4g_standalone]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=1500
-D MAX_GROUP_CHANNELS=20
-D OFFLINE_QUEUE_SIZE=1
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.2.4G.SA"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>

View File

@@ -49,19 +49,11 @@ bool radio_init() {
loraSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS);
MESH_DEBUG_PRINTLN("radio_init() - SPI initialized, calling radio.std_init()...");
bool result = radio.std_init(&loraSpi);
if (result) {
radio.setPreambleLength(32);
MESH_DEBUG_PRINTLN("radio_init() - preamble set to 32 symbols");
}
MESH_DEBUG_PRINTLN("radio_init() - radio.std_init() returned: %s", result ? "SUCCESS" : "FAILED");
return result;
#else
MESH_DEBUG_PRINTLN("radio_init() - calling radio.std_init() without custom SPI...");
bool result = radio.std_init();
if (result) {
radio.setPreambleLength(32);
MESH_DEBUG_PRINTLN("radio_init() - preamble set to 32 symbols");
}
return result;
#endif
}
@@ -75,6 +67,14 @@ void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
radio.setSpreadingFactor(sf);
radio.setBandwidth(bw);
radio.setCodingRate(cr);
// Longer preamble for low SF improves reliability — each symbol is shorter
// at low SF, so more symbols are needed for reliable detection.
// SF <= 8 gets 32 symbols (~65ms at SF7/62.5kHz); SF >= 9 keeps 16 (already ~131ms+).
// See: https://github.com/meshcore-dev/MeshCore/pull/1954
uint16_t preamble = (sf <= 8) ? 32 : 16;
radio.setPreambleLength(preamble);
MESH_DEBUG_PRINTLN("radio_set_params() - bw=%.1f sf=%u preamble=%u", bw, sf, preamble);
}
void radio_set_tx_power(uint8_t dbm) {