1
0
forked from iarv/Meck

Compare commits

..

178 Commits

Author SHA1 Message Date
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
pelgraine
7f8f70655d Added battery temperature to battery gauge page display. Updated firmware date 2026-02-23 08:48:06 +11:00
pelgraine
6e417d1f3e removed reference to pin 45 goal in readme as now backlight won't be happening until TD Pro Max 2026-02-22 17:36:56 +11:00
pelgraine
38eb4b854b updated roadmap and future planning details in readme 2026-02-22 17:36:04 +11:00
pelgraine
e64011112e fix for intermitted sd card failure bug - "patch explicitly deselects all three SPI bus peers (e-ink, LoRa, SD) before init, adds a 100ms stabilization delay, and retries up to 3 times with 250ms settle between attempts" 2026-02-22 17:29:21 +11:00
pelgraine
97f9fc9eee revising firmware version and commenting out meck_web_reader in platformio until I fix the innumerable bugs for it in dev branch 2026-02-22 17:14:27 +11:00
pelgraine
4a1fe3b190 updated firmware version in platformio; added ble pin display to ble home page in ui; updates to try fixing form login functionality in web reader 2026-02-22 16:47:13 +11:00
pelgraine
2024dc2a1b fixed hibernate screen ui display bug 2026-02-22 00:15:44 +11:00
pelgraine
27b8ea603f preliminary html web reader app stage 1 2026-02-22 00:10:02 +11:00
pelgraine
b812ff75a9 edited sms app guide 2026-02-20 22:29:27 +11:00
pelgraine
4477d5c812 updated slight eink refresh lag; minor nav bar ui fixes to sms app; added sms app guide 2026-02-20 22:27:59 +11:00
pelgraine
f06a1f5499 Sms app implementation phase 2 - add contact in message view screen; time of message displayed fix using 4G modem network sync - need to wait about 10-ish seconds after boot for auto network clock sync 2026-02-20 22:03:06 +11:00
pelgraine
458db8d4c4 implement sms app v1 attempt 1 4g variant only 2026-02-20 08:07:47 +11:00
pelgraine
2576a6590b codebase into one branch consolidation 2026-02-20 06:23:59 +11:00
pelgraine
5cc9feb3e9 fix ble send message buffer handling 2026-02-20 05:53:39 +11:00
pelgraine
d76fa04613 updated firmware version and date; same changes as main branch implementing repeater hop path view for last most recent channel msg rcd 2026-02-20 05:47:00 +11:00
pelgraine
5473f29eec scroll bar in channel message view - W or S for up down 2026-02-20 05:01:20 +11:00
pelgraine
b85172bcc4 fix ble message history bug app to device 2026-02-20 04:44:06 +11:00
pelgraine
3a32555add changed wording of light flash on off to make it slightly clearer 2026-02-17 20:14:12 +11:00
pelgraine
034cc64f8c update uitasks to enable keyboard pulse light notifcation 2026-02-17 19:53:50 +11:00
pelgraine
16bc0ed69d update settingscreen to enable keyboard pulse light 2026-02-17 19:51:51 +11:00
pelgraine
644eb432b5 update node prefs to enable keyboard pulse light 2026-02-17 19:50:06 +11:00
pelgraine
f2956e9d26 changed firmware version date; changed render battery indicator back to meshcore app display method 2026-02-17 18:55:10 +11:00
pelgraine
8e83155698 45 m sleep timer; track queing from sub folders; better wav file name data extraction 2026-02-16 18:28:18 +11:00
pelgraine
cd594c4116 updated firmware version and date 2026-02-16 18:10:59 +11:00
pelgraine
b43ffe9578 msgread fix and newmsg alert suppression when in repeater admin login page 2026-02-16 17:58:46 +11:00
pelgraine
d4b1824b1c using different HAS_BQ27220 for renderbatteryindicator 2026-02-15 19:33:19 +11:00
pelgraine
9809f47d29 battery charge estimated runtime fix - 2 to 3 charge discharge cycles needed for full calibration to 1400mah 2026-02-15 09:19:43 +11:00
pelgraine
bf89da0eb5 incorporated battery gauge view on home screen including estimated run time duration 2026-02-15 07:48:48 +11:00
pelgraine
aa2e1af999 improvements to bookmark and cache optimisation for load times super fast and reduced serial output 2026-02-15 01:39:14 +11:00
pelgraine
472b0ee662 fixed background audio play and >> icon regression 2026-02-15 01:27:47 +11:00
pelgraine
1f5cbbd4db same repeater admin fixes as main 2026-02-15 01:02:35 +11:00
pelgraine
f451b49226 minor text alignment fix 2026-02-14 16:21:38 +11:00
pelgraine
d10aa2c571 fixed home view so that pin isn't shown when ble off. Add instructions for keyboard nav to home screen 2026-02-14 16:16:17 +11:00
pelgraine
a2e099f095 fixed home view so that pin isn't shown when ble off. Add instructions for keyboard nav to home screen 2026-02-14 16:06:39 +11:00
pelgraine
e5e41ff50b ui fixes for audiobook player, firmware version number updates, subdirectory support for both ereader and audiobook player file lists 2026-02-14 15:43:13 +11:00
pelgraine
2dc6977c20 updated audiobook player guide 2026-02-14 14:32:06 +11:00
pelgraine
5c540e9092 fixed settings page so corrected radio preset list options available and custom frequency edits refined. 2026-02-14 14:13:43 +11:00
pelgraine
670efa75b0 Fixed heap allocation order to sort out ble pairing for audiobook player version. Expanded char in uitask to allow firmware version suffix to display in splash screen. 2026-02-14 11:25:21 +11:00
pelgraine
3a486832c8 Merge branch 'main' into audio-player 2026-02-14 10:41:33 +11:00
pelgraine
0a892f2dad changed to hybrid render battery indicator method 2026-02-14 10:41:03 +11:00
pelgraine
7f75ea8309 Merge branch 'main' into audio-player 2026-02-14 10:17:58 +11:00
pelgraine
b1e3f2ac28 Back to original serialbleinterface to start afresh 2026-02-14 10:17:11 +11:00
pelgraine
ddfe05ad20 m4b incompatibility workaround by renaming file extension to m4a when playing 2026-02-14 01:49:25 +11:00
pelgraine
d51ca6db0b "Updated home screen 'background audio playing' icon to >> for clarity" 2026-02-14 01:05:01 +11:00
pelgraine
3ab8191d19 amened ble connection parameter (battery-saving) fix that was causing performance issues; updated firrmware date in mymesh; ui update for audiobook player; audiobook player now lkeeps playing on exit unless you pause it so background audio is enabled 2026-02-14 00:17:44 +11:00
pelgraine
546ce55c2b Gave up on trying to extract cover art from mp3 files and removed debug logs. Updated firmware version to match variant type 2026-02-13 23:27:32 +11:00
pelgraine
1f46bc1970 updated simple audio player ui to make control/nav more evident and i2s reset to try accommodate sample rate diffs between files added 2026-02-13 22:53:35 +11:00
pelgraine
db8a73004e audiobook function redo - initial success - attempt 1 2026-02-13 22:38:37 +11:00
pelgraine
209a2f1693 updated firmware version 2026-02-13 21:02:33 +11:00
pelgraine
4683711877 added firmware version build flag to stop device unbonding on new firmware version flash via vscode 2026-02-13 18:43:23 +11:00
pelgraine
9610277b83 ble battery life extension improvements and firmware bond ble pairing bug fix:
- Bond clearing on firmware version change (lines 38-67) — stores the firmware version string in SPIFFS at /ble_ver. On boot, if it doesn't match, all stored bonds are wiped. This fixes the forget/re-pair issue after flashing. Normal reboots keep pairing intact.
- TX power -3 dBm (line 73).
Connection parameter negotiation (lines 137-147) — latency=4 for power saving when connected.
- Advertising intervals 300ms/600ms (three places) — compromise between discovery speed and power.
- No controller power-down — the header file is unchanged from stock.
2026-02-13 18:40:51 +11:00
pelgraine
745efc4cc1 updated firmware version and date 2026-02-13 18:37:34 +11:00
pelgraine
7223395740 Improved device ui battery rendering for more accurate battery indicator 2026-02-13 18:36:17 +11:00
pelgraine
9ef1fa4f1b moved cpu.begin to earlier to reduce risk of brownout boot stuck at low voltage 2026-02-13 18:29:06 +11:00
pelgraine
2dd5c4f59f Slight nav bar ui change in notes mode and improved rename function responsiveness 2026-02-12 21:04:05 +11:00
pelgraine
ee2a27258b improved cursor tracking and rename file handling implented with r key in Notes file list view 2026-02-12 20:45:41 +11:00
pelgraine
5b868d51ca improved responsiveness and cursor tracking in notes function. updated firmware version in mymesh. 2026-02-12 20:32:21 +11:00
pelgraine
220006c229 "Increased buffer size. Shift+WASD → cursor nav in editing mode.
Shift+Backspace → save (editing), delete (reading/file list).
Shift+Enter → rename from file list.
RTC time passed via setTimestamp() when opening notes."
2026-02-12 20:18:57 +11:00
pelgraine
a60f4146d5 Create, edit, save and delete txt notes from the N menu from any home view screen 2026-02-12 19:41:59 +11:00
pelgraine
017b170e81 Moved epubprocessor files to better tree location and fixed txt file accented character rendering 2026-02-11 20:07:38 +11:00
pelgraine
9b0c13fd4c fixed redundant uppercase key handling and updated firmware date in mymesh 2026-02-11 11:42:59 +11:00
pelgraine
5e3a252748 fixed backupSettingsToSD bug 2026-02-11 11:36:24 +11:00
pelgraine
6c3fb569f4 Fixed accidental regressions caused by commit f0dc218 and minor bug fixes 2026-02-11 11:34:12 +11:00
pelgraine
fa747bfce2 increased gps duty cycle timing to 3 minutes awake 2026-02-10 22:40:05 +11:00
pelgraine
f0dc218a57 GPS duty cycle and cpu power management for extended battery life implemented 2026-02-10 22:26:59 +11:00
pelgraine
a23b65730a Merge branch 'main' of https://github.com/pelgraine/Meck 2026-02-10 20:51:11 +11:00
pelgraine
569794d2fe updated readme with new changes and quicklinks 2026-02-10 20:49:35 +11:00
pelgraine
ea1ca315b8 Update key mapping for opening text reader 2026-02-10 20:45:19 +11:00
pelgraine
83b3ea6275 increase on-device message history buffer from 20 to 300 messages 2026-02-10 20:33:50 +11:00
pelgraine
9c6d5138b0 fixed domino emoji sprite utf codepoint 2026-02-10 20:27:20 +11:00
pelgraine
15165bb429 New settings screen and key remapping for menu screens 2026-02-10 20:18:13 +11:00
pelgraine
c4b9952d95 updated contacts all view to display max contacts etc 2026-02-10 19:06:37 +11:00
pelgraine
ce37bf6b90 settings persistance backup to sd after saveprefs 2026-02-10 19:02:58 +11:00
pelgraine
8e98132506 Channel message view retains history after reboot 2026-02-10 18:56:53 +11:00
pelgraine
33c2758a87 updated readme 2026-02-10 16:04:03 +11:00
pelgraine
f644892b07 standalone device phase 1 complete - utc offset from gps homepage and gps timesync without ble enabled 2026-02-10 15:59:34 +11:00
pelgraine
8f558b130f repeater admin password persistence after successful login enabled 2026-02-10 15:12:29 +11:00
pelgraine
04462b93bc Removed unnecessary t-echo lite variant 2026-02-10 15:10:59 +11:00
pelgraine
d42c283fb4 fixed repeater admin password entry display mode and updated readme 2026-02-10 15:06:48 +11:00
pelgraine
87a5f185d3 fix message popup accidental navigation bug 2026-02-10 14:59:30 +11:00
pelgraine
2972d1ffb4 updated mymesh to v0.8.1 now repeater admin stage 1 implemented 2026-02-10 14:56:31 +11:00
pelgraine
fe1c1931ab Limited repeater admin function stage 1 implemented - login, clock sync, advert, neighbors list 2026-02-10 14:55:47 +11:00
pelgraine
3af2770af2 updated readme 2026-02-10 14:11:43 +11:00
pelgraine
e030a61244 DMs now available - select contact in contacts list view by pressing Enter 2026-02-10 13:59:15 +11:00
pelgraine
f630cf3a5a Contacts now in - press N to access 2026-02-10 13:45:41 +11:00
pelgraine
ac3fb337e2 updated readme to incorporate v0.8 changes 2026-02-10 12:56:08 +11:00
pelgraine
1d4555a064 increased preamble length from 16 to 32 2026-02-10 12:51:13 +11:00
pelgraine
3d716605dc made agc.reset.interval 500 the default 2026-02-10 12:42:26 +11:00
pelgraine
500f59abca fixed buffer flutter overflow issue 2026-02-10 03:23:20 +11:00
pelgraine
6e60c56d48 fixed apparent $ regression 2026-02-10 03:09:28 +11:00
pelgraine
b9a68f0f99 Amended so sym plus $ prints the dollar sign now 2026-02-10 02:28:34 +11:00
pelgraine
a8675ceda9 updated emoji picker 2026-02-10 02:18:22 +11:00
pelgraine
f20435329b updated Claude AI drawn emoji sprites list 2026-02-10 02:10:53 +11:00
pelgraine
33304c7bec updated emoji sprites, ui word wrapping and formatting 2026-02-10 01:44:54 +11:00
pelgraine
cca984be08 First limited emoji support! 2026-02-10 00:30:03 +11:00
pelgraine
a0fef8a970 fixed epub processor so it renders " ' correctly 2026-02-09 13:57:51 +11:00
pelgraine
9e70630727 Updated ereader guide now that we've got epub conversion functionality 2026-02-09 09:25:57 +11:00
pelgraine
54e74caa96 updated mymesh version and date 2026-02-09 09:09:26 +11:00
pelgraine
69e73440db Epub and Epub3 converter now working 2026-02-09 09:08:48 +11:00
pelgraine
4c4a218b32 adusted line spacing in channel view so that timestamp and hop count are in line with message 2026-02-08 01:53:39 +11:00
pelgraine
c719df5737 fixed mymsesh cpp error 2026-02-08 01:04:54 +11:00
pelgraine
57e13ecfa8 Implementing sent message ack functionality into txt-reader branch 2026-02-08 01:00:12 +11:00
pelgraine
39b43bde11 Much faster pre-indexing of txt files 2026-02-07 22:09:44 +11:00
pelgraine
89d24662ff Fixed battery indicator so it uses same linear mapping as BLE app 2026-02-07 20:41:29 +11:00
pelgraine
abafefb3f7 Updated guide to txt reader incorporation 2026-02-07 18:24:34 +11:00
pelgraine
0b94a56fae Fix indexing screen 2026-02-07 18:09:09 +11:00
pelgraine
8f1a936c39 updated version in mymesh 2026-02-07 17:46:34 +11:00
pelgraine
9eadb0a3fe First functioning text reader and guide added 2026-02-07 17:41:11 +11:00
pelgraine
6f23cd612c tiny text view testing 2026-02-07 16:35:02 +11:00
pelgraine
af9f41a541 Updated version and date in mymesh 2026-02-07 16:24:10 +11:00
pelgraine
0a746cdca5 Merge branch 'main' into dev 2026-02-07 16:22:51 +11:00
pelgraine
3a5c48f440 "Battery UI changes - percentage display and icon size" 2026-02-07 16:20:33 +11:00
pelgraine
e40d9ced4a Merge branch 'dev' 2026-02-04 12:45:42 +11:00
pelgraine
b8de2d0d16 "updated mymesh h with firmware version details" 2026-02-04 12:45:06 +11:00
pelgraine
9fbc3202f6 "fixed reocurring BLE queue bug that popped up in v0.6.1. Improved keyboard responsiveness" 2026-02-04 12:44:17 +11:00
pelgraine
9d91f48797 Merge branch 'dev' 2026-02-02 21:28:48 +11:00
pelgraine
21eb385763 "Updated version date on mymesh.h and fixed modem_power_EN so 4G modem made inactive and annoying red LED Status light disabled when using firmware via Launcher mode" 2026-02-02 21:28:08 +11:00
pelgraine
4b81e596d2 "Fixed the queueSentChannelMessage BLE history ommission" 2026-02-01 20:26:27 +11:00
pelgraine
a5f2e8d055 "updated readme.md roadmap details" 2026-02-01 19:52:42 +11:00
pelgraine
462b1cb642 "Removed Preview Message overlay in favour of short popup that shows you which channel you've received a new message in" 2026-02-01 19:49:30 +11:00
pelgraine
0b270c0e1a "Changed word wrapping in channel view screen to boundary wrapping" 2026-02-01 19:38:26 +11:00
pelgraine
2730c05329 "Updated readme and firmware version" 2026-02-01 18:18:21 +11:00
pelgraine
02d2fb08fb "Added symbol capability" 2026-02-01 18:09:24 +11:00
pelgraine
b0003e1896 "Added additional channel compose functionality - can switch channels now. Minor ui changes for nav bar" 2026-02-01 17:51:17 +11:00
pelgraine
0be77ef759 "updated firmware version on mymesh" 2026-01-29 22:06:41 +11:00
pelgraine
c5df40cefd Added basic Public channel only view message history and compose - bugs still present 2026-01-29 21:33:14 +11:00
pelgraine
5bdcbb25b6 "Fix fix BLE shutdown on hibernate, update version in mymesh" 2026-01-29 19:01:38 +11:00
pelgraine
53fe89b216 "updated mymesh.h version type and date details" 2026-01-29 18:31:15 +11:00
pelgraine
e194c2c48c Replace Serial.print with MESH_DEBUG macros for cleaner debug output in main cpp, tdeckboard cpp and target cpp 2026-01-29 18:28:19 +11:00
pelgraine
9d401f76d3 Updated readme.md
Revised Roadmap / To-Do to goals specific to this repo
2026-01-28 21:43:41 +11:00
pelgraine
15f392c80e Update readme.md
Removed specific references to v1.1 now that I've confirmed it works on my T-Deck v1.0 (audio only) as well
2026-01-28 21:13:31 +11:00
pelgraine
621f9f9568 "added line to readme about purchase date of v1.1" 2026-01-28 20:18:34 +11:00
pelgraine
8c9106ca86 "update readme" 2026-01-28 20:16:57 +11:00
pelgraine
f4b9c89d9f "added and removed emoji variously to readme" 2026-01-28 20:15:44 +11:00
pelgraine
a4f5328113 "Updated readme" 2026-01-28 20:12:53 +11:00
pelgraine
2ad02f49e6 “Fixed the max contacts 400, max channels 20, pin back to 123456 for randomisation that somehow got lost in the merge from dev to main” 2026-01-28 20:05:19 +11:00
61 changed files with 28744 additions and 2095 deletions

View File

@@ -1,4 +1,6 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"pioarduino.pioarduino-ide",
"platformio.platformio-ide"

78
Audiobook Player Guide.md Normal file
View File

@@ -0,0 +1,78 @@
## Audiobook Player (Audio variant only)
Press **P** from the home screen to open the audiobook player.
Place `.mp3`, `.m4b`, `.m4a`, or `.wav` files in `/audiobooks/` on the SD card.
Files can be organised into subfolders (e.g. by author) — use **Enter** to
browse into folders and **.. (up)** to go back.
| Key | Action |
|-----|--------|
| W / S | Scroll file list / Volume up-down |
| Enter | Select book or folder / Play-Pause |
| A | Seek back 30 seconds |
| D | Seek forward 30 seconds |
| [ | Previous chapter (M4B only) |
| ] | Next chapter (M4B only) |
| Q | Leave player (audio continues) / Close book (when paused) / Exit (from file list) |
### Recommended Format
**MP3 is the recommended format.** M4B/M4A files are supported but currently
have playback issues with the ESP32-audioI2S library — some files may fail to
decode or produce silence. MP3 files play reliably and are the safest choice.
MP3 files should be encoded at a **44100 Hz sample rate**. Lower sample rates
(e.g. 22050 Hz) can cause distortion or playback failure due to ESP32-S3 I2S
hardware limitations.
**Bookmarks** are saved automatically every 30 seconds during playback and when
you stop or exit. Reopening a book resumes from your last position.
**Cover art** from M4B files is displayed as dithered monochrome on the e-ink
screen, along with title, author, and chapter information.
**Metadata caching** — the first time you open the audiobook player, it reads
title and author tags from each file (which can take a few seconds with many
files). This metadata is cached to the SD card so subsequent visits load
near-instantly. If you add or remove files the cache updates automatically.
### Background Playback
Audio continues playing when you leave the audiobook player screen. Press **Q**
while audio is playing to return to the home screen — a **>>** indicator will
appear in the status bar next to the battery icon to show that audio is active
in the background. Press **P** at any time to return to the player screen and
resume control.
If you pause or stop playback first and then press **Q**, the book is closed
and you're returned to the file list instead.
### Audio Hardware
The audiobook player uses the PCM5102A I2S DAC on the audio variant of the
T-Deck Pro (I2S pins: BCLK=7, DOUT=8, LRC=9). Audio is output via the 3.5mm
headphone jack.
> **Note:** The audiobook player is not available on the 4G modem variant
> due to I2S pin conflicts.
### SD Card Folder Structure
```
SD Card
├── audiobooks/
│ ├── .bookmarks/ (auto-created, stores resume positions)
│ │ ├── mybook.bmk
│ │ └── another.bmk
│ ├── .metacache (auto-created, speeds up file list loading)
│ ├── Ann Leckie/
│ │ ├── Ancillary Justice.mp3
│ │ └── Ancillary Sword.mp3
│ ├── Iain M. Banks/
│ │ └── The Algebraist.mp3
│ ├── mybook.mp3
│ └── podcast.mp3
├── books/ (existing — text reader)
│ └── ...
└── ...
```

328
README.md
View File

@@ -1,24 +1,250 @@
## 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.
### Contents
- [T-Deck Pro Keyboard Controls](#t-deck-pro-keyboard-controls)
- [Navigation (Home Screen)](#navigation-home-screen)
- [Bluetooth (BLE)](#bluetooth-ble)
- [Clock & Timezone](#clock--timezone)
- [Channel Message Screen](#channel-message-screen)
- [Contacts Screen](#contacts-screen)
- [Sending a Direct Message](#sending-a-direct-message)
- [Repeater Admin Screen](#repeater-admin-screen)
- [Settings Screen](#settings-screen)
- [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)
- [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)
- [Road-Map / To-Do](#road-map--to-do)
- [Get Support](#-get-support)
- [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.
### Navigation (Home Screen)
| Key | Action |
|-----|--------|
| W / A | Previous page |
| D | Next page |
| Enter | Select / Confirm |
| 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) |
| Q | Back to home screen |
### Bluetooth (BLE)
BLE is **disabled by default** at boot to support standalone-first operation. The device is fully functional without a phone — you can send and receive messages, browse contacts, read e-books, and set your timezone directly from the keyboard.
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.
### 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:
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.
**Setting your timezone:**
The UTC offset can be set from the **Settings** screen (press **S** from the home screen), or from the **GPS** home page by pressing **U** to open the UTC offset editor.
| Key | Action |
|-----|--------|
| W | Increase offset (+1 hour) |
| S | Decrease offset (-1 hour) |
| Enter | Save and exit |
| Q | Cancel and exit |
The UTC offset is persisted to flash and survives reboots — you only need to set it once. The valid range is UTC-12 to UTC+14. For example, AEST is UTC+10 and AEDT is UTC+11.
The GPS page also shows the current time, satellite count, position, altitude, and your configured UTC offset for reference.
### Channel Message Screen
| Key | Action |
|-----|--------|
| 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
Press **C** from the home screen to open the contacts list. All known mesh contacts are shown sorted by most recently seen, with their type (Chat, Repeater, Room, Sensor), hop count, and time since last advert.
| Key | Action |
|-----|--------|
| W / S | Scroll up / down through contacts |
| 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 variants support up to 1,500 contacts (stored in PSRAM). BLE variants (both Audio-BLE and 4G-BLE) are limited to 500 contacts due to BLE protocol constraints.
### 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.
### Repeater Admin Screen
Select a **Repeater** contact in the contacts list and press **Enter** to open the repeater admin screen. You'll be prompted for the repeater's admin password. Characters briefly appear as you type them before being masked, making it easier to enter symbols and numbers on the T-Deck Pro keyboard.
After a successful login, you'll see a menu with the following remote administration commands:
| Menu Item | Description |
|-----------|-------------|
| Clock Sync | Push your device's clock time to the repeater |
| Send Advert | Trigger the repeater to broadcast an advertisement |
| Neighbors | View other repeaters heard via zero-hop adverts |
| Get Clock | Read the repeater's current clock value |
| Version | Query the repeater's firmware version |
| Get Status | Retrieve repeater status information |
| Key | Action |
|-----|--------|
| W / S | Navigate menu items |
| Enter | Execute selected command |
| Q | Back to contacts (from menu) or cancel login |
Command responses are displayed in a scrollable view. Use **W / S** to scroll long responses and **Q** to return to the menu.
### Settings Screen
Press **S** from the home screen to open settings. On first boot (when the device name is still the default hex ID), the settings screen launches automatically as an onboarding wizard to set your device name and radio preset.
| Key | Action |
|-----|--------|
| W / S | Navigate up / down through settings |
| Enter | Edit selected setting |
| Q | Back to home screen |
**Available settings:**
| Setting | Edit Method |
|---------|-------------|
| 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 |
| 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) |
| Device Info | Public key and firmware version (read-only) |
The bottom of the settings screen also displays your node ID and firmware version. On the 4G variant, IMEI, carrier name, and APN details are shown here as well.
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.
### Compose Mode
| Key | Action |
|-----|--------|
| A / D | Switch destination channel (when message is empty, channel compose only) |
| Enter | Send message |
| Backspace | Delete last character |
| Shift + Backspace | Cancel and exit compose mode |
### Symbol Entry (Sym Key)
Press the **Sym** key then the letter key to enter numbers and symbols:
| Key | Sym+ | | Key | Sym+ | | Key | Sym+ |
|-----|------|-|-----|------|-|-----|------|
| Q | # | | A | * | | Z | 7 |
| W | 1 | | S | 4 | | X | 8 |
| E | 2 | | D | 5 | | C | 9 |
| R | 3 | | F | 6 | | V | ? |
| T | ( | | G | / | | B | ! |
| Y | ) | | H | : | | N | , |
| U | _ | | J | ; | | M | . |
| I | - | | K | ' | | Mic | 0 |
| O | + | | L | " | | $ | Emoji picker (Sym+$ for literal $) |
| P | @ | | | | | | |
### Other Keys
| Key | Action |
|-----|--------|
| Shift | Uppercase next letter |
| Alt | Same as Sym (for numbers/symbols) |
| Space | Space character / Next in navigation |
### Emoji Picker
While in compose mode, press the **$** key to open the emoji picker. A scrollable grid of 47 emoji is displayed in a 5-column layout.
| Key | Action |
|-----|--------|
| W / S | Navigate up / down |
| A / D | Navigate left / right |
| 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%20%26%20Phone%20App%20Guide.md).
### Web Browser & IRC
Press **B** from the home screen to open the web reader. This is available on the BLE and 4G variants (not the standalone audio variant, which excludes WiFi to preserve lowest-battery-usage design).
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%20App%20Guide.md).
## 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?
## 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.
## Key Features
## Key Features
* Multi-Hop Packet Routing
* Devices can forward messages across multiple nodes, extending range beyond a single radio's reach.
* Supports up to a configurable number of hops to balance network efficiency and prevent excessive traffic.
* Nodes use fixed roles where "Companion" nodes are not repeating messages at all to prevent adverse routing paths from being used.
* Supports LoRa Radios Works with Heltec, RAK Wireless, and other LoRa-based hardware.
* Decentralized & Resilient No central server or internet required; the network is self-healing.
* Low Power Consumption Ideal for battery-powered or solar-powered devices.
* Simple to Deploy Pre-built example applications make it easy to get started.
* Supports LoRa Radios Works with Heltec, RAK Wireless, and other LoRa-based hardware.
* Decentralized & Resilient No central server or internet required; the network is self-healing.
* Low Power Consumption Ideal for battery-powered or solar-powered devices.
* Simple to Deploy Pre-built example applications make it easy to get started.
## 🎯 What Can You Use MeshCore For?
## What Can You Use MeshCore For?
* Off-Grid Communication: Stay connected even in remote areas.
* Emergency Response & Disaster Recovery: Set up instant networks where infrastructure is down.
@@ -26,7 +252,7 @@ MeshCore provides the ability to create wireless mesh networks, similar to Mesht
* Tactical & Security Applications: Military, law enforcement, and private security use cases.
* IoT & Sensor Networks: Collect data from remote sensors and relay it back to a central location.
## 🚀 How to Get Started
## How to Get Started
- 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.
@@ -39,27 +265,24 @@ For developers;
- 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.
- [Simple Repeater](./examples/simple_repeater) - Extends network coverage by relaying messages.
- [Simple Room Server](./examples/simple_room_server) - A simple BBS server for shared Posts.
- [Simple Secure Chat](./examples/simple_secure_chat) - Secure terminal based text communication between devices.
The Simple Secure Chat example can be interacted with through the Serial Monitor in Visual Studio Code, or with a Serial USB Terminal on Android.
## MeshCore Flasher
## ⚡️ MeshCore Flasher
We have prebuilt firmware ready to flash on supported devices.
Download a copy of the Meck firmware bin from https://github.com/pelgraine/Meck/releases, then:
- Launch https://flasher.meshcore.co.uk
- Select a supported device
- Flash one of the firmware types:
- Companion, Repeater or Room Server
- 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.
## 📱 MeshCore Clients
## MeshCore Clients
**Companion Firmware**
The companion firmware can be connected to via BLE, USB or WiFi depending on the firmware type you flashed.
The companion firmware can be connected to via BLE.
> **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.
- Web: https://app.meshcore.nz
- Android: https://play.google.com/store/apps/details?id=com.liamcottle.meshcore.android
@@ -67,22 +290,10 @@ The companion firmware can be connected to via BLE, USB or WiFi depending on the
- NodeJS: https://github.com/liamcottle/meshcore.js
- Python: https://github.com/fdlamotte/meshcore-cli
**Repeater and Room Server Firmware**
The repeater and room server firmwares can be setup via USB in the web config tool.
- https://config.meshcore.dev
They can also be managed via LoRa in the mobile app by using the Remote Management feature.
## 🛠 Hardware Compatibility
MeshCore is designed for devices listed in the [MeshCore Flasher](https://flasher.meshcore.co.uk)
## 📜 License
MeshCore is open-source software released under the MIT License. You are free to use, modify, and distribute it for personal and commercial projects.
## Contributing
Please submit PR's using 'dev' as the base branch!
@@ -95,30 +306,45 @@ 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 very rough chronological order:
- [X] Companion radio: UI redesign
- [ ] Repeater + Room Server: add ACL's (like Sensor Node has)
- [ ] Standardise Bridge mode for repeaters
- [ ] Repeater/Bridge: Standardise the Transport Codes for zoning/filtering
- [ ] Core + Repeater: enhanced zero-hop neighbour discovery
- [ ] Core: round-trip manual path support
- [ ] Companion + Apps: support for multiple sub-meshes (and 'off-grid' client repeat mode)
- [ ] Core + Apps: support for LZW message compression
- [ ] Core: dynamic CR (Coding Rate) for weak vs strong hops
- [ ] Core: new framework for hosting multiple virtual nodes on one physical device
- [ ] V2 protocol spec: discussion and consensus around V2 packet protocol, including path hashes, new encryption specs, etc
There are a number of fairly major features in the pipeline, with no particular time-frames attached yet. In partly chronological order:
- [X] Companion radio: BLE
- [X] Text entry for Public channel messages Companion BLE firmware
- [X] View and compose all channel messages Companion BLE firmware
- [X] Standalone DM functionality for Companion BLE firmware
- [X] Contacts list with filtering for Companion BLE firmware
- [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 for text-centric websites
- [ ] Fix M4B rendering to enable chaptered audiobook playback
- [ ] Better JPEG and PNG decoding
- [ ] Improve EPUB rendering and EPUB format handling
- [ ] Map support with GPS
- [ ] WiFi companion environment
## 📞 Get Support
- Report bugs and request features on the [GitHub Issues](https://github.com/ripplebiz/MeshCore/issues) page.
- Find additional guides and components on [my site](https://buymeacoffee.com/ripplebiz).
- Join [MeshCore Discord](https://discord.gg/BMwCtwHj5V) to chat with the developers and get help from the community.
## RAK Wireless Board Support in PlatformIO
## 📜 License
Before building/flashing the RAK4631 targets in this project, there is, unfortunately, some patching you have to do to your platformIO packages to make it work. There is a guide here on the process:
[RAK Wireless: How to Perform Installation of Board Support Package in PlatformIO](https://learn.rakwireless.com/hc/en-us/articles/26687276346775-How-To-Perform-Installation-of-Board-Support-Package-in-PlatformIO)
The upstream [MeshCore](https://github.com/meshcore-dev/MeshCore) library is released under the **MIT License** (Copyright © 2025 Scott Powell / rippleradios.com). Meck-specific code (UI screens, display helpers, device integration) is also provided under the MIT License.
After building, you will need to convert the output firmware.hex file into a .uf2 file you can copy over to your RAK4631 device (after doing a full erase) by using the command `uf2conv.py -f 0xADA52840 -c firmware.hex` with the python script available from:
[GitHub: Microsoft - uf2](https://github.com/Microsoft/uf2/blob/master/utils/uf2conv.py)
However, this firmware links against libraries with different license terms. Because two dependencies use the **GPL-3.0** copyleft license, the combined firmware binary is effectively subject to GPL-3.0 obligations when distributed. Please review the individual licenses below if you intend to redistribute or modify this firmware.
### Third-Party Libraries
| Library | License | Author / Source |
|---------|---------|-----------------|
| [MeshCore](https://github.com/meshcore-dev/MeshCore) | MIT | Scott Powell / rippleradios.com |
| [GxEPD2](https://github.com/ZinggJM/GxEPD2) | GPL-3.0 | Jean-Marc Zingg |
| [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š |
| [JPEGDEC](https://github.com/bitbank2/JPEGDEC) | 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 |
Full license texts for each dependency are available in their respective repositories linked above.

206
SMS & Phone App Guide.md Normal file
View File

@@ -0,0 +1,206 @@
## 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
SD card formatted as FAT32. The modem registers on the cellular network
automatically at boot — the red LED on the board indicates the modem is
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 app menu |
| Conversation | W / S | Scroll messages |
| Conversation | C | Reply to this conversation |
| Conversation | F | Call this number |
| Conversation | A | Add or edit contact name for this number |
| Conversation | Q | Back to inbox |
| Compose | Enter | Send SMS (from body) / Confirm phone number (from phone input) |
| Compose | Shift+Del | Cancel and return |
| Contacts | W / S | Scroll contact list |
| Contacts | Enter | Compose SMS to selected contact |
| Contacts | F | Call selected contact |
| 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 |
| In Call | Enter or Q | Hang up |
| In Call | W / S | Volume up / down (05) |
| In Call | 09, *, # | Send DTMF tone |
### Sending an SMS
There are three ways to start a new message:
1. **From inbox** — press **C**, type the destination phone number, press
**Enter**, then type your message and press **Enter** to send.
2. **From a conversation** — press **C** to reply. The recipient is
pre-filled so you go straight to typing the message body.
3. **From the contacts directory** — press **D** from the inbox, scroll to a
contact, and press **Enter**. The compose screen opens with the number
pre-filled.
Messages are limited to 160 characters (standard SMS). A character counter is
shown in the footer while composing.
### Making a Phone Call
There are three ways to start a 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.
3. **From the contacts directory** — press **D** from the inbox, scroll to a
contact, and press **F**.
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
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
call screen regardless of which view is active. A short alert and buzzer
notification are triggered. The caller's name is shown if saved in contacts,
otherwise the raw phone number is displayed.
Press **Enter** to answer or **Q** to reject the call. If the call is not
answered it is logged as a missed call and a "Missed: ..." alert is shown
briefly.
### Contacts
The contacts directory lets you assign display names to phone numbers.
Names appear in the inbox list, conversation headers, call screens, and
compose screen instead of raw numbers.
To add or edit a contact, open a conversation with that number and press **A**.
Type the display name and press **Enter** to save. Names can be up to 23
characters long.
Contacts are stored as a plain text file at `/sms/contacts.txt` on the SD card
in `phone=Display Name` format — one per line, human-editable. Up to 30
contacts are supported.
### Conversation History
Messages are saved to the SD card automatically and persist across reboots.
Each phone number gets its own file under `/sms/` on the SD card. The inbox
shows the most recent 20 conversations sorted by last activity. Within a
conversation, the most recent 30 messages are loaded with the newest at the
bottom (chat-style). Sent messages are shown with `>>>` and received messages
with `<<<`.
Message timestamps use the cellular network clock (synced via NITZ roughly 15
seconds after each modem startup) and display as relative times (e.g. 5m, 2h,
1d). If the modem is toggled off and back on, the clock re-syncs automatically.
### Modem Power Control
The 4G modem can be toggled on or off from the settings screen. Scroll to
**4G Modem: ON/OFF** and press **Enter** to toggle. Switching the modem off
kills its red status LED and stops all cellular activity. The setting persists
to SD card and is respected on subsequent boots — if disabled, the modem and
LED stay off until re-enabled. The SMS & Phone app remains accessible when the
modem is off but will not be able to send or receive messages or calls.
### Signal Indicator
A signal strength indicator is shown in the top-right corner of all SMS and
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
```
SD Card
├── sms/
│ ├── contacts.txt (plain text, phone=Name format)
│ ├── modem.cfg (0 or 1, modem enable state)
│ ├── 0412345678.sms (binary message log per phone number)
│ └── 0498765432.sms
├── books/ (text reader)
├── audiobooks/ (audio variant only)
└── ...
```
### Troubleshooting
| Symptom | Likely Cause |
|---------|-------------|
| Modem icon stays at REG / never reaches READY | SIM not inserted, no signal, or SIM requires PIN unlock (not currently supported) |
| Timestamps show `---` | Modem clock hasn't synced yet (wait ~15 seconds after modem startup), or messages were saved before clock sync was available |
| Red LED stays on after disabling modem | Toggle the setting off, then reboot — the boot sequence ensures power is cut when disabled |
| 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 |
> **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
> to shared GPIO pin conflicts between the A7682E modem and PCM5102A DAC.

116
TXT & EPUB Reader Guide.md Normal file
View File

@@ -0,0 +1,116 @@
# Text & EPUB Reader Integration for Meck Firmware
## Overview
This adds a text reader accessible via the **R** key from the home screen.
**Features:**
- Browse `.txt` and `.epub` files from `/books/` folder on SD card
- Automatic EPUB-to-text conversion on first open (cached for instant re-opens)
- Word-wrapped text rendering using tiny font (maximum text density)
- Page navigation with W/S/A/D keys
- Automatic reading position resume (persisted to SD card)
- Index files cached to SD for instant re-opens
- Bookmark indicator (`*`) on files with saved positions
**Key Mapping:**
| Context | Key | Action |
|---------|-----|--------|
| Home screen | E | Open text reader |
| File list | W/S | Navigate up/down |
| File list | Enter | Open selected file |
| File list | Q | Back to home screen |
| Reading | W/A | Previous page |
| Reading | S/D/Space/Enter | Next page |
| Reading | Q | Close book → file list |
| Reading | C | Enter compose mode |
---
## SD Card Setup
Place `.txt` or `.epub` files in a `/books/` folder on the SD card root. The reader will:
- Auto-create `/books/` if it doesn't exist
- Auto-create `/.indexes/` for page index cache files
- Auto-create `/books/.epub_cache/` for converted EPUB text
- Skip macOS hidden files (`._*`, `.DS_Store`)
- Support up to 50 files
**Index format** is compatible with the standalone reader (version 4), so if you've used the standalone reader previously, bookmarks and indexes will carry over.
---
## EPUB Support
### How It Works
EPUB files are transparently converted to plain text on first open. The conversion pipeline is:
1. **File list**`scanFiles()` picks up both `.txt` and `.epub` files from `/books/`
2. **First open**`openBook()` detects the `.epub` extension and triggers conversion:
- Shows a "Converting EPUB..." splash screen
- Extracts the ZIP structure using ESP32-S3's built-in ROM `tinfl` decompressor (no external library needed)
- Parses `META-INF/container.xml` → finds the OPF file
- Parses the OPF manifest and spine to get chapters in reading order
- Extracts each XHTML chapter, strips tags, decodes HTML entities
- Writes concatenated plain text to `/books/.epub_cache/<filename>.txt`
3. **Subsequent opens** — the cached `.txt` is found immediately and opened like any regular text file
### Cache Structure
```
/books/
MyBook.epub ← original EPUB (untouched)
SomeStory.txt ← regular text file
.epub_cache/
MyBook.txt ← auto-generated from MyBook.epub
/.indexes/
MyBook.txt.idx ← page index for the converted text
```
- The original `.epub` file is never modified
- Deleting a cached `.txt` from `.epub_cache/` forces re-conversion on next open
- Index files (`.idx`) work identically for both regular and EPUB-derived text files
- Boot scan picks up previously cached EPUB text files so they appear in the file list even before the EPUB is re-opened
### EPUB Processing Details
The conversion is handled by three components:
| Component | Role |
|-----------|------|
| `EpubZipReader.h` | ZIP central directory parsing + `tinfl` decompression (supports Store and Deflate) |
| `EpubProcessor.h` | EPUB structure parsing (container.xml → OPF → spine) and XHTML tag stripping |
| `TextReaderScreen.h` | Integration: detects `.epub`, triggers conversion, redirects to cached `.txt` |
**XHTML stripping handles:**
- Tag removal with block-element newlines (`<p>`, `<br>`, `<div>`, `<h1>``<h6>`, `<li>`, etc.)
- `<head>`, `<style>`, `<script>` content skipped entirely
- HTML entity decoding: named (`&amp;`, `&mdash;`, `&ldquo;`, etc.) and numeric (`&#8212;`, `&#x2014;`)
- Smart quote / em-dash / ellipsis → ASCII equivalents (e-ink font is ASCII-only)
- Whitespace collapsing and cleanup
**Limits:**
- Max 200 chapters in spine (`EPUB_MAX_CHAPTERS`)
- Max 256 manifest items (`EPUB_MAX_MANIFEST`)
- Manifest and chapter data are heap-allocated in PSRAM where available
- Typical conversion time: 210 seconds depending on book size
### Troubleshooting
| Symptom | Likely Cause |
|---------|-------------|
| "Convert failed!" splash | EPUB may be DRM-protected, corrupted, or use an unusual structure |
| EPUB appears in list but opens as blank | Check serial output for `EpubProc:` messages; chapter count may be 0 |
| Stale content after replacing an EPUB | Delete the matching `.txt` from `/books/.epub_cache/` to force re-conversion |
---
## Architecture Notes
- The reader renders through the standard `UIScreen::render()` framework, so no special bypass is needed in the main loop (unlike compose mode)
- SD card uses the same HSPI bus as e-ink display and LoRa radio — CS pin management handles contention
- 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

181
Web App Guide.md Normal file
View File

@@ -0,0 +1,181 @@
# Web Reader & IRC - Meck v0.9.5
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.
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.
## 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.
---
## Key Bindings
### From Home Screen
| Key | Action |
|-----|--------|
| `b` | Open web reader |
### Web Reader - Home View
| Key | Action |
|-----|--------|
| `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 |
### Web Reader - Reading View
| Key | Action |
|-----|--------|
| `w` / `a` | Previous page |
| `s` / `d` / `Space` | Next page |
| `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
| Key | Action |
|-----|--------|
| `w` / `s` | Navigate SSID list |
| `Enter` | Select SSID / submit password / retry |
| 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
```
/web/
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

@@ -40,7 +40,19 @@ public:
void enableSerial() { _serial->enable(); }
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) = 0;
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
const uint8_t* path = nullptr) = 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

@@ -228,6 +228,7 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
file.read((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85
file.read((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86
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
file.close();
}
@@ -263,6 +264,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85
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.close();
}
@@ -598,4 +600,4 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src
}
return false; // error
}
#endif
#endif

View File

@@ -439,16 +439,45 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
// we only want to show text messages on display, not cli data
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
if (should_display && _ui) {
_ui->newMsg(path_len, from.name, text, offline_queue_len);
const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr;
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path);
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::contactMessage); //buzz if enabled
}
#endif
}
bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
// REVISIT: try to determine which Region (from transport_codes[1]) that Sender is indicating for replies/responses
// if unknown, fallback to finding Region from transport_codes[0], the 'scope' used by Sender
return false;
// Check if this incoming flood packet is a repeat of a message we recently sent
if (packet->payload_len >= SENT_FINGERPRINT_SIZE) {
unsigned long now = millis();
for (int i = 0; i < SENT_TRACK_SIZE; i++) {
SentMsgTrack* t = &_sent_track[i];
if (!t->active) continue;
// Expire old entries
if ((now - t->sent_millis) > SENT_TRACK_EXPIRY_MS) {
t->active = false;
continue;
}
// Compare payload fingerprint
if (memcmp(packet->payload, t->fingerprint, SENT_FINGERPRINT_SIZE) == 0) {
t->repeat_count++;
MESH_DEBUG_PRINTLN("SentTrack: heard repeat #%d (SNR=%.1f)", t->repeat_count, packet->getSNR());
#ifdef DISPLAY_CLASS
if (_ui) {
char buf[40];
snprintf(buf, sizeof(buf), "Sent! (%d)", t->repeat_count);
_ui->showAlert(buf, 2000); // show/extend alert with updated count
}
#endif
break; // found match, no need to check other entries
}
}
}
return false; // never filter — let normal processing continue
}
void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
@@ -463,6 +492,17 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui
}
}
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
// Capture payload fingerprint for repeat tracking before sending
if (pkt->payload_len >= SENT_FINGERPRINT_SIZE) {
SentMsgTrack* t = &_sent_track[_sent_track_idx];
memcpy(t->fingerprint, pkt->payload, SENT_FINGERPRINT_SIZE);
t->repeat_count = 0;
t->sent_millis = millis();
t->active = true;
_sent_track_idx = (_sent_track_idx + 1) % SENT_TRACK_SIZE;
MESH_DEBUG_PRINTLN("SentTrack: captured fingerprint for channel msg");
}
// TODO: have per-channel send_scope
if (send_scope.isNull()) {
sendFlood(pkt, delay_millis);
@@ -484,6 +524,13 @@ void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint3
const char *text) {
markConnectionActive(from); // in case this is from a server, and we have a connection
queueMessage(from, TXT_TYPE_CLI_DATA, pkt, sender_timestamp, NULL, 0, text);
// Forward CLI response to UI admin screen if admin session is active
#ifdef DISPLAY_CLASS
if (_admin_contact_idx >= 0 && _ui) {
_ui->onAdminCliResponse(from.name, text);
}
#endif
}
void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
@@ -535,12 +582,153 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
channel_name = channel_details.name;
}
if (_ui) {
_ui->newMsg(path_len, channel_name, text, offline_queue_len);
const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr;
_ui->newMsg(path_len, channel_name, text, offline_queue_len, msg_path);
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::channelMessage); //buzz if enabled
}
#endif
}
void MyMesh::queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text) {
// Format message the same way as onChannelMessageRecv for BLE app sync
// This allows sent messages from device keyboard to appear in the app
int i = 0;
if (app_target_ver >= 3) {
out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV_V3;
out_frame[i++] = 0; // SNR not applicable for sent messages
out_frame[i++] = 0; // reserved1
out_frame[i++] = 0; // reserved2
} else {
out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV;
}
out_frame[i++] = channel_idx;
out_frame[i++] = 0; // path_len = 0 indicates local/sent message
out_frame[i++] = TXT_TYPE_PLAIN;
memcpy(&out_frame[i], &timestamp, 4);
i += 4;
// Format as "sender: text" like the app expects
char formatted[MAX_FRAME_SIZE];
snprintf(formatted, sizeof(formatted), "%s: %s", sender, text);
int tlen = strlen(formatted);
if (i + tlen > MAX_FRAME_SIZE) {
tlen = MAX_FRAME_SIZE - i;
}
memcpy(&out_frame[i], formatted, tlen);
i += tlen;
addToOfflineQueue(out_frame, i);
// If app is connected, send push notification
if (_serial->isConnected()) {
uint8_t frame[1];
frame[0] = PUSH_CODE_MSG_WAITING;
_serial->writeFrame(frame, 1);
}
}
bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) {
ContactInfo contact;
if (!getContactByIdx(contact_idx, contact)) return false;
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (!recipient) return false;
uint32_t timestamp = getRTCClock()->getCurrentTimeUnique();
uint32_t expected_ack, est_timeout;
int result = sendMessage(*recipient, timestamp, 0, text, expected_ack, est_timeout);
if (result == MSG_SEND_FAILED) {
MESH_DEBUG_PRINTLN("UI: DM send failed to %s", recipient->name);
return false;
}
// Track expected ACK for delivery confirmation
if (expected_ack) {
expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis();
expected_ack_table[next_ack_idx].ack = expected_ack;
expected_ack_table[next_ack_idx].contact = recipient;
next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE;
}
MESH_DEBUG_PRINTLN("UI: DM sent to %s (%s), ack=0x%08X timeout=%dms",
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
expected_ack, est_timeout);
return true;
}
bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password) {
ContactInfo contact;
if (!getContactByIdx(contact_idx, contact)) return false;
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (!recipient) return false;
uint32_t est_timeout;
int result = sendLogin(*recipient, password, est_timeout);
if (result == MSG_SEND_FAILED) {
MESH_DEBUG_PRINTLN("UI: Admin login send failed to %s", recipient->name);
return false;
}
clearPendingReqs();
memcpy(&pending_login, recipient->id.pub_key, 4);
_admin_contact_idx = contact_idx;
MESH_DEBUG_PRINTLN("UI: Admin login sent to %s (%s), timeout=%dms",
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
est_timeout);
return true;
}
bool MyMesh::uiSendCliCommand(uint32_t contact_idx, const char* command) {
ContactInfo contact;
if (!getContactByIdx(contact_idx, contact)) return false;
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (!recipient) return false;
uint32_t timestamp = getRTCClock()->getCurrentTimeUnique();
uint32_t est_timeout;
int result = sendCommandData(*recipient, timestamp, 0, command, est_timeout);
if (result == MSG_SEND_FAILED) {
MESH_DEBUG_PRINTLN("UI: CLI command send failed to %s: %s", recipient->name, command);
return false;
}
_admin_contact_idx = contact_idx;
MESH_DEBUG_PRINTLN("UI: CLI command sent to %s (%s): %s, timeout=%dms",
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
command, est_timeout);
return true;
}
bool MyMesh::uiSendTelemetryRequest(uint32_t contact_idx) {
ContactInfo contact;
if (!getContactByIdx(contact_idx, contact)) return false;
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (!recipient) return false;
uint32_t tag, est_timeout;
int result = sendRequest(*recipient, REQ_TYPE_GET_TELEMETRY_DATA, tag, est_timeout);
if (result == MSG_SEND_FAILED) {
MESH_DEBUG_PRINTLN("UI: Telemetry request send failed to %s", recipient->name);
return false;
}
clearPendingReqs();
pending_telemetry = tag;
MESH_DEBUG_PRINTLN("UI: Telemetry request sent to %s (%s), timeout=%dms",
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
est_timeout);
return true;
}
uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
uint8_t len, uint8_t *reply) {
if (data[0] == REQ_TYPE_GET_TELEMETRY_DATA) {
@@ -599,6 +787,11 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
out_frame[i++] = 0; // legacy: is_admin = false
memcpy(&out_frame[i], contact.id.pub_key, 6);
i += 6; // pub_key_prefix
#ifdef DISPLAY_CLASS
// Notify UI of successful legacy login
if (_ui) _ui->onAdminLoginResult(true, 0, tag);
#endif
} else if (data[4] == RESP_SERVER_LOGIN_OK) { // new login response
uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16;
if (keep_alive_secs > 0) {
@@ -612,11 +805,21 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
i += 4; // NEW: include server timestamp
out_frame[i++] = data[7]; // NEW (v7): ACL permissions
out_frame[i++] = data[12]; // FIRMWARE_VER_LEVEL
#ifdef DISPLAY_CLASS
// Notify UI of successful login
if (_ui) _ui->onAdminLoginResult(true, data[6], tag);
#endif
} else {
out_frame[i++] = PUSH_CODE_LOGIN_FAIL;
out_frame[i++] = 0; // reserved
memcpy(&out_frame[i], contact.id.pub_key, 6);
i += 6; // pub_key_prefix
#ifdef DISPLAY_CLASS
// Notify UI of login failure
if (_ui) _ui->onAdminLoginResult(false, 0, 0);
#endif
}
_serial->writeFrame(out_frame, i);
} else if (len > 4 && // check for status response
@@ -636,6 +839,7 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
_serial->writeFrame(out_frame, i);
} else if (len > 4 && tag == pending_telemetry) { // check for matching response tag
pending_telemetry = 0;
MESH_DEBUG_PRINTLN("Telemetry response received from %s, len=%d", contact.name, len);
int i = 0;
out_frame[i++] = PUSH_CODE_TELEMETRY_RESPONSE;
@@ -645,6 +849,11 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
memcpy(&out_frame[i], &data[4], len - 4);
i += (len - 4);
_serial->writeFrame(out_frame, i);
#ifdef DISPLAY_CLASS
// Route telemetry data to UI (LPP buffer after the 4-byte tag)
if (_ui) _ui->onAdminTelemetryResult(&data[4], len - 4);
#endif
} else if (len > 4 && tag == pending_req) { // check for matching response tag
pending_req = 0;
@@ -786,6 +995,9 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
dirty_contacts_expiry = 0;
memset(advert_paths, 0, sizeof(advert_paths));
memset(send_scope.key, 0, sizeof(send_scope.key));
memset(_sent_track, 0, sizeof(_sent_track));
_sent_track_idx = 0;
_admin_contact_idx = -1;
// defaults
memset(&_prefs, 0, sizeof(_prefs));
@@ -839,6 +1051,7 @@ void MyMesh::begin(bool has_display) {
_prefs.buzzer_quiet = constrain(_prefs.buzzer_quiet, 0, 1); // Ensure boolean 0 or 1
_prefs.gps_enabled = constrain(_prefs.gps_enabled, 0, 1); // Ensure boolean 0 or 1
_prefs.gps_interval = constrain(_prefs.gps_interval, 0, 86400); // Max 24 hours
_prefs.utc_offset_hours = constrain(_prefs.utc_offset_hours, -12, 14); // Valid timezone range
#ifdef BLE_PIN_CODE // 123456 by default
if (_prefs.ble_pin == 0) {
@@ -859,6 +1072,7 @@ void MyMesh::begin(bool has_display) {
_active_ble_pin = 0;
#endif
initContacts(); // allocate contacts array from PSRAM (deferred from constructor)
resetContacts();
_store->loadContacts(this);
bootstrapRTCfromContacts();
@@ -999,6 +1213,8 @@ void MyMesh::handleCmdFrame(size_t len) {
memcpy(&msg_timestamp, &cmd_frame[i], 4);
i += 4;
const char *text = (char *)&cmd_frame[i];
int text_len = len - i;
cmd_frame[len] = '\0'; // Null-terminate for C string use
if (txt_type != TXT_TYPE_PLAIN) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
@@ -1007,6 +1223,11 @@ void MyMesh::handleCmdFrame(size_t len) {
bool success = getChannel(channel_idx, channel);
if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) {
writeOKFrame();
#ifdef DISPLAY_CLASS
if (_ui) {
_ui->addSentChannelMessage(channel_idx, _prefs.node_name, text);
}
#endif
} else {
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
}
@@ -1189,7 +1410,19 @@ void MyMesh::handleCmdFrame(size_t len) {
if ((out_len = getFromOfflineQueue(out_frame)) > 0) {
_serial->writeFrame(out_frame, out_len);
#ifdef DISPLAY_CLASS
if (_ui) _ui->msgRead(offline_queue_len);
if (_ui) {
_ui->msgRead(offline_queue_len);
// Mark channel as read when BLE companion app syncs the message.
// Frame layout V3: [resp_code][snr][res1][res2][channel_idx][path_len]...
// Frame layout V1: [resp_code][channel_idx][path_len]...
bool is_v3_ch = (out_frame[0] == RESP_CODE_CHANNEL_MSG_RECV_V3);
bool is_old_ch = (out_frame[0] == RESP_CODE_CHANNEL_MSG_RECV);
if (is_v3_ch || is_old_ch) {
uint8_t ch_idx = is_v3_ch ? out_frame[4] : out_frame[1];
_ui->markChannelReadFromBLE(ch_idx);
}
}
#endif
} else {
out_frame[0] = RESP_CODE_NO_MORE_MESSAGES;
@@ -1622,6 +1855,12 @@ void MyMesh::handleCmdFrame(size_t len) {
savePrefs();
}
#endif
// UTC offset for local clock display (works regardless of GPS)
if (strcmp(sp, "utc_offset") == 0) {
int offset = atoi(np);
_prefs.utc_offset_hours = constrain(offset, -12, 14);
savePrefs();
}
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
@@ -1979,4 +2218,4 @@ bool MyMesh::advert() {
} else {
return false;
}
}
}

View File

@@ -8,11 +8,11 @@
#define FIRMWARE_VER_CODE 8
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "28 Jan 2026"
#define FIRMWARE_BUILD_DATE "27 Feb 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.3"
#define FIRMWARE_VERSION "Meck v0.9.5"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -101,6 +101,19 @@ public:
void enterCLIRescue();
int getRecentlyHeard(AdvertPath dest[], int max_num);
// Queue a sent channel message for BLE app sync
void queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text);
// Send a direct message from the UI (no BLE dependency)
bool uiSendDirectMessage(uint32_t contact_idx, const char* text);
// Repeater admin - UI-initiated operations
bool uiLoginToRepeater(uint32_t contact_idx, const char* password);
bool uiSendCliCommand(uint32_t contact_idx, const char* command);
bool uiSendTelemetryRequest(uint32_t contact_idx);
int getAdminContactIdx() const { return _admin_contact_idx; }
protected:
float getAirtimeBudgetFactor() const override;
@@ -158,6 +171,12 @@ protected:
public:
void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
void saveChannels() {
_store->saveChannels(this);
}
void saveContacts() {
_store->saveContacts(this);
}
private:
void writeOKFrame();
@@ -177,10 +196,6 @@ private:
void checkCLIRescueCmd();
void checkSerialInterface();
// helpers, short-cuts
void saveChannels() { _store->saveChannels(this); }
void saveContacts() { _store->saveContacts(this); }
DataStore* _store;
NodePrefs _prefs;
uint32_t pending_login;
@@ -228,6 +243,20 @@ private:
#define ADVERT_PATH_TABLE_SIZE 16
AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table
// Sent message repeat tracking
#define SENT_TRACK_SIZE 4
#define SENT_FINGERPRINT_SIZE 12
#define SENT_TRACK_EXPIRY_MS 30000 // stop tracking after 30 seconds
struct SentMsgTrack {
uint8_t fingerprint[SENT_FINGERPRINT_SIZE];
uint8_t repeat_count;
unsigned long sent_millis;
bool active;
};
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)
};
extern MyMesh the_mesh;
extern MyMesh the_mesh;

View File

@@ -28,4 +28,6 @@ struct NodePrefs { // persisted to file
uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled)
uint32_t gps_interval; // GPS read interval in seconds
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)
};

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,359 @@
#pragma once
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <MeshCore.h>
// Forward declarations
class UITask;
class MyMesh;
extern MyMesh the_mesh;
class ContactsScreen : public UIScreen {
public:
// Filter modes for contact type
enum FilterMode {
FILTER_ALL = 0,
FILTER_CHAT, // Companions / Chat nodes
FILTER_REPEATER,
FILTER_ROOM, // Room servers
FILTER_SENSOR,
FILTER_FAVOURITE, // Contacts marked as favourite (any type)
FILTER_COUNT // keep last
};
private:
UITask* _task;
mesh::RTCClock* _rtc;
int _scrollPos; // Index into filtered list (top visible row)
FilterMode _filter; // Current filter mode
// Cached filtered contact indices for efficient scrolling
// We rebuild this on filter change or when entering the screen
// 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;
// How many rows fit on screen (computed during render)
int _rowsPerPage;
// --- helpers ---
static const char* filterLabel(FilterMode f) {
switch (f) {
case FILTER_ALL: return "All";
case FILTER_CHAT: return "Chat";
case FILTER_REPEATER: return "Rptr";
case FILTER_ROOM: return "Room";
case FILTER_SENSOR: return "Sens";
case FILTER_FAVOURITE: return "Fav";
default: return "?";
}
}
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'; // Server
default: return '?';
}
}
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;
case FILTER_REPEATER: return adv_type == ADV_TYPE_REPEATER;
case FILTER_ROOM: return adv_type == ADV_TYPE_ROOM;
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;
}
}
void rebuildCache() {
_filteredCount = 0;
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo contact;
for (uint32_t i = 0; i < numContacts && _filteredCount < MAX_CONTACTS; i++) {
if (the_mesh.getContactByIdx(i, contact)) {
if (matchesFilter(contact.type, contact.flags)) {
_filteredIdx[_filteredCount] = (uint16_t)i;
_filteredTs[_filteredCount] = contact.last_advert_timestamp;
_filteredCount++;
}
}
}
// Sort by last_advert_timestamp descending (most recently seen first)
// 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];
int j = i - 1;
while (j >= 0 && _filteredTs[j] < tmpTs) {
_filteredIdx[j + 1] = _filteredIdx[j];
_filteredTs[j + 1] = _filteredTs[j];
j--;
}
_filteredIdx[j + 1] = tmpIdx;
_filteredTs[j + 1] = tmpTs;
}
_cacheValid = true;
// Clamp scroll position
if (_scrollPos >= _filteredCount) {
_scrollPos = (_filteredCount > 0) ? _filteredCount - 1 : 0;
}
}
// Format seconds-ago as compact string: "3s" "5m" "2h" "4d" "??"
static void formatAge(char* buf, size_t bufLen, uint32_t now, uint32_t timestamp) {
if (timestamp == 0) {
strncpy(buf, "--", bufLen);
return;
}
int secs = (int)(now - timestamp);
if (secs < 0) secs = 0;
if (secs < 60) {
snprintf(buf, bufLen, "%ds", secs);
} else if (secs < 3600) {
snprintf(buf, bufLen, "%dm", secs / 60);
} else if (secs < 86400) {
snprintf(buf, bufLen, "%dh", secs / 3600);
} else {
snprintf(buf, bufLen, "%dd", secs / 86400);
}
}
public:
ContactsScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _scrollPos(0), _filter(FILTER_ALL),
_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; }
void resetScroll() {
_scrollPos = 0;
_cacheValid = false;
}
FilterMode getFilter() const { return _filter; }
// Get the raw contact table index for the currently highlighted item
// Returns -1 if no valid selection
int getSelectedContactIdx() const {
if (_filteredCount == 0) return -1;
return _filteredIdx[_scrollPos];
}
// Get the adv_type of the currently highlighted contact
// Returns 0xFF if no valid selection
uint8_t getSelectedContactType() const {
if (_filteredCount == 0) return 0xFF;
ContactInfo contact;
if (!the_mesh.getContactByIdx(_filteredIdx[_scrollPos], contact)) return 0xFF;
return contact.type;
}
// Copy the name of the currently highlighted contact into buf
// Returns false if no valid selection
bool getSelectedContactName(char* buf, size_t bufLen) const {
if (_filteredCount == 0) return false;
ContactInfo contact;
if (!the_mesh.getContactByIdx(_filteredIdx[_scrollPos], contact)) return false;
strncpy(buf, contact.name, bufLen);
buf[bufLen - 1] = '\0';
return true;
}
int render(DisplayDriver& display) override {
if (!_cacheValid) rebuildCache();
char tmp[48];
// === Header ===
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter));
display.print(tmp);
// Count on right: All → total/max, filtered → matched/total
if (_filter == FILTER_ALL) {
snprintf(tmp, sizeof(tmp), "%d/%d", (int)the_mesh.getNumContacts(), MAX_CONTACTS);
} else {
snprintf(tmp, sizeof(tmp), "%d/%d", _filteredCount, (int)the_mesh.getNumContacts());
}
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
display.print(tmp);
// Divider
display.drawRect(0, 11, display.width(), 1);
// === Body - contact rows ===
display.setTextSize(0); // tiny font for compact rows
int lineHeight = 9; // 8px font + 1px gap
int headerHeight = 14;
int footerHeight = 14;
int maxY = display.height() - footerHeight;
int y = headerHeight;
uint32_t now = _rtc->getCurrentTime();
int rowsDrawn = 0;
if (_filteredCount == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, y);
display.print("No contacts");
display.setCursor(0, y + lineHeight);
display.print("A/D: Change filter");
} else {
// Center visible window around selected item (TextReaderScreen pattern)
int maxVisible = (maxY - headerHeight) / lineHeight;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_scrollPos - maxVisible / 2,
_filteredCount - maxVisible));
int endIdx = min(_filteredCount, startIdx + maxVisible);
for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) {
ContactInfo contact;
if (!the_mesh.getContactByIdx(_filteredIdx[i], contact)) continue;
bool selected = (i == _scrollPos);
// Highlight: fill LIGHT rect first, then draw DARK text on top
if (selected) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y + 5, display.width(), lineHeight);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
// Set cursor AFTER fillRect so text draws on top of highlight
display.setCursor(0, y);
// Prefix: "> " for selected, type char + space for others
char prefix[4];
if (selected) {
snprintf(prefix, sizeof(prefix), ">%c", typeChar(contact.type));
} else {
snprintf(prefix, sizeof(prefix), " %c", typeChar(contact.type));
}
display.print(prefix);
// Contact name (truncated to fit)
char filteredName[32];
display.translateUTF8ToBlocks(filteredName, contact.name, sizeof(filteredName));
// Reserve space for hops + age on right side
char hopStr[6];
if (contact.out_path_len == 0xFF || contact.out_path_len == 0) {
strcpy(hopStr, "D"); // direct
} else {
snprintf(hopStr, sizeof(hopStr), "%d", contact.out_path_len);
}
char ageStr[6];
formatAge(ageStr, sizeof(ageStr), now, contact.last_advert_timestamp);
// Build right-side string: "hops age"
char rightStr[14];
snprintf(rightStr, sizeof(rightStr), "%sh %s", hopStr, ageStr);
int rightWidth = display.getTextWidth(rightStr) + 2;
// Name region: after prefix + small gap, before right info
int nameX = display.getTextWidth(prefix) + 2;
int nameMaxW = display.width() - nameX - rightWidth - 2;
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
// Right-aligned: hops + age
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);
// Left: Q:Back
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.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
return 5000; // e-ink: next render after 5s
}
bool handleInput(char c) override {
// W - scroll up (previous contact)
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_scrollPos > 0) {
_scrollPos--;
return true;
}
}
// S - scroll down (next contact)
if (c == 's' || c == 'S' || c == 0xF1) {
if (_scrollPos < _filteredCount - 1) {
_scrollPos++;
return true;
}
}
// A - previous filter
if (c == 'a' || c == 'A') {
_filter = (FilterMode)(((int)_filter + FILTER_COUNT - 1) % FILTER_COUNT);
_scrollPos = 0;
_cacheValid = false;
return true;
}
// D - next filter
if (c == 'd' || c == 'D') {
_filter = (FilterMode)(((int)_filter + 1) % FILTER_COUNT);
_scrollPos = 0;
_cacheValid = false;
return true;
}
// Enter - select contact (future: open RepeaterAdmin for repeaters)
if (c == 13 || c == KEY_ENTER) {
// TODO Phase 3: if selected contact is a repeater, open RepeaterAdminScreen
// For now, just acknowledge the selection
return true;
}
return false;
}
};

View File

@@ -0,0 +1,748 @@
#pragma once
// Emoji sprites for e-ink display - dual size
// Large (12x12) for compose/picker, Small (10x10) for channel view
// MSB-first, 2 bytes per row
// 65 total emoji: joy/thumbsup/frown first, then 43 original, then 19 new
#include <stdint.h>
#ifdef ESP32
#include <pgmspace.h>
#endif
#define EMOJI_LG_W 12
#define EMOJI_LG_H 12
#define EMOJI_SM_W 10
#define EMOJI_SM_H 10
#define EMOJI_COUNT 65
// Escape codes in 0x80+ range - safe from keyboard ASCII (32-126)
#define EMOJI_ESCAPE_START 0x80
#define EMOJI_ESCAPE_END 0xC0 // 0x80 + 64
#define EMOJI_PAD_BYTE 0x7F // DEL, not typeable (key < 127 guard)
// ======== LARGE 12x12 SPRITES ========
// [0] joy (most common mesh emoji)
static const uint8_t emoji_lg_joy[] PROGMEM = {
0x1F,0x80, 0x20,0x40, 0x59,0xA0, 0x59,0xA0, 0x80,0x10, 0xA0,0x50, 0x9F,0x90, 0x40,0x20, 0x20,0x40, 0x1F,0x80, 0x00,0x00, 0x00,0x00,
};
// [1] thumbsup
static const uint8_t emoji_lg_thumbsup[] PROGMEM = {
0x00,0x00, 0x70,0x00, 0x70,0x00, 0x70,0x00, 0x7F,0x80, 0xFF,0x80, 0xFF,0x80, 0x7F,0x80, 0x3F,0x80, 0x1F,0x00, 0x00,0x00, 0x00,0x00,
};
// [2] frown
static const uint8_t emoji_lg_frown[] PROGMEM = {
0x1F,0x80, 0x20,0x40, 0x59,0xA0, 0x59,0xA0, 0x80,0x10, 0x9F,0x90, 0xA0,0x50, 0x40,0x20, 0x20,0x40, 0x1F,0x80, 0x00,0x00, 0x00,0x00,
};
// [3] wireless
static const uint8_t emoji_lg_wireless[] PROGMEM = {
0x00,0x00, 0x3F,0xC0, 0x60,0x60, 0xC0,0x30, 0x0F,0x00, 0x19,0x80, 0x30,0xC0, 0x00,0x00, 0x06,0x00, 0x0F,0x00, 0x06,0x00, 0x00,0x00,
};
// [4] infinity
static const uint8_t emoji_lg_infinity[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0x61,0x80, 0x92,0x40, 0x8C,0x40, 0x8C,0x40, 0x92,0x40, 0x61,0x80, 0x00,0x00, 0x00,0x00, 0x00,0x00, 0x00,0x00,
};
// [5] trex
static const uint8_t emoji_lg_trex[] PROGMEM = {
0x03,0xE0, 0x06,0xA0, 0x07,0xE0, 0x0C,0x00, 0x5C,0x00, 0x7C,0x00, 0x3C,0x00, 0x38,0x00, 0x3C,0x00, 0x36,0x00, 0x22,0x00, 0x33,0x00,
};
// [6] skull
static const uint8_t emoji_lg_skull[] PROGMEM = {
0x1F,0x80, 0x20,0x40, 0x59,0xA0, 0x59,0xA0, 0x40,0x20, 0x49,0x20, 0x2F,0x40, 0x1F,0x80, 0x96,0x90, 0x66,0x60, 0x36,0xC0, 0x96,0x90,
};
// [7] cross
static const uint8_t emoji_lg_cross[] PROGMEM = {
0x0F,0x00, 0x0F,0x00, 0x0F,0x00, 0x3F,0xC0, 0x3F,0xC0, 0x3F,0xC0, 0x0F,0x00, 0x0F,0x00, 0x0F,0x00, 0x0F,0x00, 0x0F,0x00, 0x0F,0x00,
};
// [8] lightning
static const uint8_t emoji_lg_lightning[] PROGMEM = {
0x03,0x00, 0x07,0x00, 0x0E,0x00, 0x1C,0x00, 0x3F,0x80, 0x01,0x80, 0x03,0x00, 0x06,0x00, 0x0C,0x00, 0x18,0x00, 0x30,0x00, 0x00,0x00,
};
// [9] tophat
static const uint8_t emoji_lg_tophat[] PROGMEM = {
0x00,0x00, 0x1F,0x80, 0x3F,0xC0, 0x3F,0xC0, 0x3F,0xC0, 0x3F,0xC0, 0x3F,0xC0, 0x20,0x40, 0x7F,0xE0, 0xFF,0xF0, 0xFF,0xF0, 0x00,0x00,
};
// [10] motorcycle
static const uint8_t emoji_lg_motorcycle[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0x0F,0x00, 0x1F,0x80, 0x7F,0xE0, 0xDF,0xB0, 0xDF,0xB0, 0xDF,0xB0, 0xDF,0xB0, 0x60,0x60, 0x00,0x00, 0x00,0x00,
};
// [11] seedling
static const uint8_t emoji_lg_seedling[] PROGMEM = {
0x00,0x00, 0x30,0x00, 0x79,0x80, 0x7B,0xC0, 0x33,0xC0, 0x1F,0x80, 0x06,0x00, 0x06,0x00, 0x06,0x00, 0x06,0x00, 0x00,0x00, 0x00,0x00,
};
// [12] flag_au
static const uint8_t emoji_lg_flag_au[] PROGMEM = {
0x00,0x00, 0x32,0x40, 0x4A,0x40, 0x4A,0x40, 0x7A,0x40, 0x4A,0x40, 0x49,0x80, 0x00,0x00, 0xFF,0xF0, 0x00,0x00, 0xFF,0xF0, 0x00,0x00,
};
// [13] umbrella
static const uint8_t emoji_lg_umbrella[] PROGMEM = {
0x06,0x00, 0x1F,0x80, 0x3F,0xC0, 0x7F,0xE0, 0xFF,0xF0, 0xDB,0x70, 0x06,0x00, 0x06,0x00, 0x06,0x00, 0x06,0x00, 0x46,0x00, 0x3C,0x00,
};
// [14] nazar
static const uint8_t emoji_lg_nazar[] PROGMEM = {
0x1F,0x80, 0x20,0x40, 0x4F,0x20, 0x99,0x90, 0xB6,0xD0, 0xB6,0xD0, 0xB6,0xD0, 0x99,0x90, 0x4F,0x20, 0x20,0x40, 0x1F,0x80, 0x00,0x00,
};
// [15] globe
static const uint8_t emoji_lg_globe[] PROGMEM = {
0x1F,0x80, 0x34,0xC0, 0x66,0x60, 0x4F,0x20, 0x8E,0x10, 0x86,0x10, 0x80,0x30, 0x46,0x60, 0x43,0xE0, 0x30,0xC0, 0x1F,0x80, 0x00,0x00,
};
// [16] radioactive
static const uint8_t emoji_lg_radioactive[] PROGMEM = {
0x00,0x00, 0x22,0x40, 0x32,0xC0, 0x32,0xC0, 0x1B,0x40, 0x00,0x00, 0x0F,0x00, 0x0F,0x00, 0x00,0x00, 0x60,0x20, 0x39,0xC0, 0x0F,0x00,
};
// [17] cow
static const uint8_t emoji_lg_cow[] PROGMEM = {
0x00,0x00, 0xC0,0x60, 0x6E,0xC0, 0x3F,0x80, 0x2A,0x80, 0x3F,0x80, 0x3F,0x80, 0x7F,0xC0, 0x5F,0x40, 0x5F,0x40, 0x11,0x00, 0x31,0x80,
};
// [18] alien
static const uint8_t emoji_lg_alien[] PROGMEM = {
0x1F,0x80, 0x3F,0xC0, 0x7F,0xE0, 0x76,0xE0, 0xF6,0xF0, 0x96,0x90, 0x7F,0xE0, 0x36,0xC0, 0x3F,0xC0, 0x16,0x80, 0x0F,0x00, 0x06,0x00,
};
// [19] invader
static const uint8_t emoji_lg_invader[] PROGMEM = {
0x10,0x80, 0x09,0x00, 0x1F,0x80, 0x36,0xC0, 0x7F,0xE0, 0x5F,0xA0, 0x50,0xA0, 0x50,0xA0, 0x19,0x80, 0x19,0x80, 0x30,0xC0, 0x00,0x00,
};
// [20] dagger
static const uint8_t emoji_lg_dagger[] PROGMEM = {
0x01,0x80, 0x01,0x40, 0x01,0xA0, 0x01,0xC0, 0x01,0x80, 0x03,0x00, 0x06,0x00, 0x0C,0x00, 0x18,0x00, 0x30,0x00, 0x60,0x00, 0x40,0x00,
};
// [21] grimace
static const uint8_t emoji_lg_grimace[] PROGMEM = {
0x1F,0x80, 0x20,0x40, 0x59,0xA0, 0x59,0xA0, 0x40,0x20, 0x40,0x20, 0x5F,0xA0, 0x55,0x40, 0x5F,0xA0, 0x20,0x40, 0x1F,0x80, 0x00,0x00,
};
// [22] mountain
static const uint8_t emoji_lg_mountain[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0x06,0x00, 0x0F,0x00, 0x19,0x80, 0x30,0xC0, 0x66,0x60, 0xCF,0x30, 0x9F,0x90, 0xFF,0xF0, 0xFF,0xF0, 0x00,0x00,
};
// [23] end_arrow
static const uint8_t emoji_lg_end_arrow[] PROGMEM = {
0x00,0x00, 0x7B,0x60, 0x43,0x60, 0x42,0xA0, 0x72,0xA0, 0x43,0x60, 0x43,0x60, 0x7B,0x60, 0x00,0x00, 0x06,0x00, 0x0F,0x00, 0x06,0x00,
};
// [24] hollow_circle
static const uint8_t emoji_lg_hollow_circle[] PROGMEM = {
0x1F,0x80, 0x20,0x40, 0x40,0x20, 0x80,0x10, 0x80,0x10, 0x80,0x10, 0x80,0x10, 0x80,0x10, 0x40,0x20, 0x20,0x40, 0x1F,0x80, 0x00,0x00,
};
// [25] dragon
static const uint8_t emoji_lg_dragon[] PROGMEM = {
0x60,0x00, 0xF0,0x00, 0x76,0x00, 0x3F,0x00, 0x1F,0x00, 0x0F,0x00, 0x1F,0x80, 0x3F,0xC0, 0x79,0xE0, 0x30,0xC0, 0x20,0x40, 0x30,0xC0,
};
// [26] globe_meridians
static const uint8_t emoji_lg_globe_meridians[] PROGMEM = {
0x1F,0x80, 0x26,0x40, 0x46,0x20, 0x86,0x10, 0xFF,0xF0, 0x86,0x10, 0x86,0x10, 0x46,0x20, 0x26,0x40, 0x1F,0x80, 0x00,0x00, 0x00,0x00,
};
// [27] eggplant
static const uint8_t emoji_lg_eggplant[] PROGMEM = {
0x01,0x80, 0x03,0x00, 0x07,0x00, 0x0F,0x00, 0x1F,0x00, 0x3F,0x00, 0x3F,0x00, 0x7E,0x00, 0x7C,0x00, 0x78,0x00, 0x30,0x00, 0x00,0x00,
};
// [28] shield
static const uint8_t emoji_lg_shield[] PROGMEM = {
0x00,0x00, 0x7F,0xE0, 0x7F,0xE0, 0x6F,0x60, 0x6F,0x60, 0x6F,0x60, 0x36,0xC0, 0x3F,0xC0, 0x1F,0x80, 0x0F,0x00, 0x06,0x00, 0x00,0x00,
};
// [29] goggles
static const uint8_t emoji_lg_goggles[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0x00,0x00, 0x79,0xE0, 0xCF,0x30, 0x86,0x10, 0x86,0x10, 0xCF,0x30, 0x79,0xE0, 0x00,0x00, 0x00,0x00, 0x00,0x00,
};
// [30] lizard
static const uint8_t emoji_lg_lizard[] PROGMEM = {
0x00,0x00, 0x03,0x80, 0x07,0xC0, 0x8F,0x00, 0x7F,0x00, 0x3E,0x00, 0x3F,0x80, 0x23,0xC0, 0x41,0xC0, 0x00,0xC0, 0x00,0x60, 0x00,0x20,
};
// [31] zany_face
static const uint8_t emoji_lg_zany_face[] PROGMEM = {
0x1F,0x80, 0x20,0x40, 0x59,0x20, 0x58,0xA0, 0x40,0x20, 0x40,0x20, 0x4F,0x20, 0x50,0xA0, 0x20,0x40, 0x1F,0x80, 0x00,0x00, 0x00,0x00,
};
// [32] kangaroo
static const uint8_t emoji_lg_kangaroo[] PROGMEM = {
0x0E,0x00, 0x1F,0x00, 0x1F,0x00, 0x0E,0x00, 0x0F,0x00, 0x07,0x80, 0x47,0x80, 0x65,0x80, 0x3C,0x80, 0x18,0x80, 0x10,0xC0, 0x18,0xF0,
};
// [33] feather
static const uint8_t emoji_lg_feather[] PROGMEM = {
0x00,0x20, 0x00,0x60, 0x00,0xC0, 0x01,0x80, 0x03,0x00, 0x06,0x00, 0x0C,0x00, 0x18,0x00, 0x30,0x00, 0x60,0x00, 0x70,0x00, 0x00,0x00,
};
// [34] bright
static const uint8_t emoji_lg_bright[] PROGMEM = {
0x06,0x00, 0x26,0x40, 0x16,0x80, 0x0F,0x00, 0x6F,0x60, 0x6F,0x60, 0x0F,0x00, 0x16,0x80, 0x26,0x40, 0x06,0x00, 0x00,0x00, 0x00,0x00,
};
// [35] part_alt
static const uint8_t emoji_lg_part_alt[] PROGMEM = {
0xC0,0xC0, 0xE1,0xC0, 0xF3,0xC0, 0xDE,0xC0, 0xCC,0xC0, 0xCC,0xC0, 0xC0,0xC0, 0xC0,0xC0, 0xC0,0xC0, 0xC0,0xC0, 0x00,0x00, 0x00,0x00,
};
// [36] motorboat
static const uint8_t emoji_lg_motorboat[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0x02,0x00, 0x07,0x00, 0x0F,0x80, 0x1F,0xC0, 0xFF,0xF0, 0x7F,0xE0, 0x3F,0xC0, 0x1F,0x80, 0x00,0x00, 0x00,0x00,
};
// [37] domino
static const uint8_t emoji_lg_domino[] PROGMEM = {
0xFF,0xF0, 0x99,0x90, 0x80,0x10, 0x99,0x90, 0x80,0x10, 0x99,0x90, 0xFF,0xF0, 0x80,0x10, 0x80,0x10, 0x86,0x10, 0x80,0x10, 0xFF,0xF0,
};
// [38] satellite
static const uint8_t emoji_lg_satellite[] PROGMEM = {
0x78,0x00, 0xCC,0x00, 0x84,0x00, 0xCD,0x00, 0x7B,0x00, 0x03,0x80, 0x01,0xC0, 0x00,0xE0, 0x00,0x60, 0x00,0x20, 0x00,0x00, 0x00,0x00,
};
// [39] customs
static const uint8_t emoji_lg_customs[] PROGMEM = {
0x1F,0x80, 0x20,0x40, 0x40,0x20, 0x4F,0x20, 0x50,0xA0, 0x50,0xA0, 0x4F,0x20, 0x42,0x20, 0x22,0x40, 0x1F,0x80, 0x00,0x00, 0x00,0x00,
};
// [40] cowboy
static const uint8_t emoji_lg_cowboy[] PROGMEM = {
0x0F,0x00, 0x0F,0x00, 0x7F,0xE0, 0xFF,0xF0, 0x00,0x00, 0x3F,0xC0, 0x59,0xA0, 0x40,0x20, 0x4F,0x20, 0x20,0x40, 0x1F,0x80, 0x00,0x00,
};
// [41] wheel
static const uint8_t emoji_lg_wheel[] PROGMEM = {
0x1F,0x80, 0x26,0x40, 0x46,0x20, 0x9F,0x90, 0xB6,0xD0, 0xFF,0xF0, 0xB6,0xD0, 0x9F,0x90, 0x46,0x20, 0x26,0x40, 0x1F,0x80, 0x00,0x00,
};
// [42] koala
static const uint8_t emoji_lg_koala[] PROGMEM = {
0x60,0x60, 0xF0,0xF0, 0xF0,0xF0, 0x76,0xE0, 0x26,0x40, 0x2F,0x40, 0x26,0x40, 0x30,0xC0, 0x1F,0x80, 0x00,0x00, 0x00,0x00, 0x00,0x00,
};
// [43] control_knobs
static const uint8_t emoji_lg_control_knobs[] PROGMEM = {
0x00,0x00, 0x33,0x30, 0x33,0x30, 0x33,0x30, 0x33,0x30, 0x33,0x30, 0x33,0x30, 0x7B,0x30, 0x37,0xB0, 0x33,0x70, 0x33,0x30, 0x00,0x00,
};
// [44] peach
static const uint8_t emoji_lg_peach[] PROGMEM = {
0x06,0x00, 0x0C,0x00, 0x1E,0x00, 0x3F,0x00, 0x7F,0x80, 0x7B,0xC0, 0x7B,0xC0, 0x7B,0xC0, 0x3F,0xC0, 0x1F,0x80, 0x0F,0x00, 0x00,0x00,
};
// [45] racing_car
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,
emoji_lg_wireless, emoji_lg_infinity, emoji_lg_trex, emoji_lg_skull, emoji_lg_cross,
emoji_lg_lightning, emoji_lg_tophat, emoji_lg_motorcycle, emoji_lg_seedling, emoji_lg_flag_au,
emoji_lg_umbrella, emoji_lg_nazar, emoji_lg_globe, emoji_lg_radioactive, emoji_lg_cow,
emoji_lg_alien, emoji_lg_invader, emoji_lg_dagger, emoji_lg_grimace,
emoji_lg_mountain, emoji_lg_end_arrow, emoji_lg_hollow_circle, emoji_lg_dragon, emoji_lg_globe_meridians,
emoji_lg_eggplant, emoji_lg_shield, emoji_lg_goggles, emoji_lg_lizard, emoji_lg_zany_face,
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 ========
static const uint8_t emoji_sm_joy[] PROGMEM = {
0x3F,0x00, 0x61,0x80, 0xF3,0xC0, 0x80,0x40, 0xA1,0x40, 0x9E,0x40, 0x40,0x80, 0x3F,0x00, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_thumbsup[] PROGMEM = {
0x70,0x00, 0x70,0x00, 0x70,0x00, 0x7F,0x00, 0xFF,0x00, 0xFF,0x00, 0x7F,0x00, 0x3E,0x00, 0x1C,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_frown[] PROGMEM = {
0x3F,0x00, 0x61,0x80, 0xF3,0xC0, 0x80,0x40, 0x9E,0x40, 0xA1,0x40, 0x40,0x80, 0x3F,0x00, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_wireless[] PROGMEM = {
0x00,0x00, 0x7F,0x80, 0xC0,0xC0, 0x1E,0x00, 0x33,0x00, 0x21,0x00, 0x00,0x00, 0x0C,0x00, 0x0C,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_infinity[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0xE7,0x00, 0x99,0x00, 0x99,0x00, 0xA5,0x00, 0x42,0x00, 0x00,0x00, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_trex[] PROGMEM = {
0x07,0x80, 0x0F,0x80, 0x0F,0x80, 0x58,0x00, 0x78,0x00, 0x38,0x00, 0x38,0x00, 0x3C,0x00, 0x24,0x00, 0x26,0x00,
};
static const uint8_t emoji_sm_skull[] PROGMEM = {
0x3F,0x00, 0x61,0x80, 0x73,0x80, 0x40,0x80, 0x52,0x80, 0x3F,0x00, 0x3F,0x00, 0xED,0xC0, 0x6D,0x80, 0xAD,0x40,
};
static const uint8_t emoji_sm_cross[] PROGMEM = {
0x1E,0x00, 0x1E,0x00, 0x3F,0x00, 0x3F,0x00, 0x3F,0x00, 0x1E,0x00, 0x1E,0x00, 0x1E,0x00, 0x1E,0x00, 0x1E,0x00,
};
static const uint8_t emoji_sm_lightning[] PROGMEM = {
0x06,0x00, 0x0E,0x00, 0x1C,0x00, 0x3E,0x00, 0x03,0x00, 0x06,0x00, 0x0C,0x00, 0x18,0x00, 0x30,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_tophat[] PROGMEM = {
0x00,0x00, 0x3F,0x00, 0x3F,0x00, 0x3F,0x00, 0x3F,0x00, 0x21,0x00, 0x7F,0x80, 0xFF,0xC0, 0xFF,0xC0, 0x00,0x00,
};
static const uint8_t emoji_sm_motorcycle[] PROGMEM = {
0x00,0x00, 0x1E,0x00, 0x7F,0x80, 0xDE,0xC0, 0xDE,0xC0, 0xDE,0xC0, 0xDE,0xC0, 0x61,0x80, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_seedling[] PROGMEM = {
0x00,0x00, 0x70,0x00, 0x77,0x00, 0x77,0x00, 0x3F,0x00, 0x0C,0x00, 0x0C,0x00, 0x0C,0x00, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_flag_au[] PROGMEM = {
0x00,0x00, 0x75,0x00, 0x55,0x00, 0x75,0x00, 0x55,0x00, 0x53,0x00, 0x00,0x00, 0xFF,0xC0, 0xFF,0xC0, 0x00,0x00,
};
static const uint8_t emoji_sm_umbrella[] PROGMEM = {
0x0C,0x00, 0x3F,0x00, 0x7F,0x80, 0xFF,0xC0, 0xF7,0xC0, 0x0C,0x00, 0x0C,0x00, 0x0C,0x00, 0x4C,0x00, 0x78,0x00,
};
static const uint8_t emoji_sm_nazar[] PROGMEM = {
0x3F,0x00, 0x40,0x80, 0x9E,0x40, 0xBF,0x40, 0xAD,0x40, 0xBF,0x40, 0x9E,0x40, 0x4C,0x80, 0x3F,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_globe[] PROGMEM = {
0x3F,0x00, 0x69,0x80, 0x4C,0x80, 0x9C,0x40, 0x8C,0x40, 0x80,0xC0, 0x4D,0x80, 0x67,0x80, 0x3F,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_radioactive[] PROGMEM = {
0x00,0x00, 0x25,0x00, 0x25,0x00, 0x37,0x00, 0x00,0x00, 0x1E,0x00, 0x1E,0x00, 0x40,0x00, 0x73,0x80, 0x1E,0x00,
};
static const uint8_t emoji_sm_cow[] PROGMEM = {
0x00,0x00, 0xC1,0x80, 0x7F,0x00, 0x3F,0x00, 0x3F,0x00, 0x7F,0x00, 0x7F,0x00, 0x7F,0x00, 0x36,0x00, 0x23,0x00,
};
static const uint8_t emoji_sm_alien[] PROGMEM = {
0x3F,0x00, 0x7F,0x80, 0x7F,0x80, 0xED,0xC0, 0xAD,0x40, 0x7F,0x80, 0x3F,0x00, 0x3F,0x00, 0x1E,0x00, 0x0C,0x00,
};
static const uint8_t emoji_sm_invader[] PROGMEM = {
0x33,0x00, 0x1E,0x00, 0x3F,0x00, 0x7F,0x80, 0x7F,0x80, 0x61,0x80, 0x73,0x80, 0x33,0x00, 0x33,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_dagger[] PROGMEM = {
0x03,0x00, 0x03,0x80, 0x03,0x80, 0x03,0x00, 0x06,0x00, 0x0C,0x00, 0x18,0x00, 0x30,0x00, 0x60,0x00, 0x40,0x00,
};
static const uint8_t emoji_sm_grimace[] PROGMEM = {
0x3F,0x00, 0x61,0x80, 0x73,0x80, 0x40,0x80, 0x40,0x80, 0x7F,0x80, 0x55,0x00, 0x7F,0x80, 0x3F,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_mountain[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0x0C,0x00, 0x1E,0x00, 0x33,0x00, 0x6D,0x80, 0xDE,0xC0, 0xFF,0xC0, 0xFF,0xC0, 0x00,0x00,
};
static const uint8_t emoji_sm_end_arrow[] PROGMEM = {
0x00,0x00, 0x77,0x80, 0x47,0x80, 0x65,0x80, 0x47,0x80, 0x47,0x80, 0x76,0x80, 0x0C,0x00, 0x1E,0x00, 0x0C,0x00,
};
static const uint8_t emoji_sm_hollow_circle[] PROGMEM = {
0x3F,0x00, 0x40,0x80, 0x80,0x40, 0x80,0x40, 0x80,0x40, 0x80,0x40, 0x80,0x40, 0x40,0x80, 0x3F,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_dragon[] PROGMEM = {
0x60,0x00, 0xE0,0x00, 0x7C,0x00, 0x3E,0x00, 0x1E,0x00, 0x3F,0x00, 0x7F,0x80, 0x73,0x80, 0x21,0x00, 0x21,0x00,
};
static const uint8_t emoji_sm_globe_meridians[] PROGMEM = {
0x3F,0x00, 0x4C,0x80, 0x8C,0x40, 0xFF,0xC0, 0x8C,0x40, 0x8C,0x40, 0x4C,0x80, 0x3F,0x00, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_eggplant[] PROGMEM = {
0x03,0x00, 0x06,0x00, 0x0E,0x00, 0x1E,0x00, 0x3E,0x00, 0x7E,0x00, 0x7C,0x00, 0x78,0x00, 0x70,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_shield[] PROGMEM = {
0x00,0x00, 0xFF,0xC0, 0xFF,0xC0, 0xDE,0xC0, 0xDE,0xC0, 0x6D,0x80, 0x7F,0x80, 0x3F,0x00, 0x1E,0x00, 0x0C,0x00,
};
static const uint8_t emoji_sm_goggles[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0x73,0x80, 0xDE,0xC0, 0x8C,0x40, 0x8C,0x40, 0xDE,0xC0, 0x73,0x80, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_lizard[] PROGMEM = {
0x00,0x00, 0x07,0x00, 0x9E,0x00, 0x7E,0x00, 0x3E,0x00, 0x27,0x80, 0x43,0x00, 0x01,0x80, 0x00,0x80, 0x00,0x00,
};
static const uint8_t emoji_sm_zany_face[] PROGMEM = {
0x3F,0x00, 0x60,0x80, 0x72,0x80, 0x40,0x80, 0x40,0x80, 0x5E,0x80, 0x61,0x80, 0x3F,0x00, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_kangaroo[] PROGMEM = {
0x1C,0x00, 0x3E,0x00, 0x1C,0x00, 0x1E,0x00, 0x0F,0x00, 0x4F,0x00, 0x6B,0x00, 0x39,0x00, 0x31,0x00, 0x31,0xC0,
};
static const uint8_t emoji_sm_feather[] PROGMEM = {
0x00,0x80, 0x01,0x80, 0x03,0x00, 0x06,0x00, 0x0C,0x00, 0x18,0x00, 0x30,0x00, 0x60,0x00, 0x60,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_bright[] PROGMEM = {
0x0C,0x00, 0x2D,0x00, 0x1E,0x00, 0x5E,0x80, 0x7F,0x80, 0x1E,0x00, 0x2D,0x00, 0x0C,0x00, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_part_alt[] PROGMEM = {
0xC3,0x00, 0xE7,0x00, 0xDB,0x00, 0xDB,0x00, 0xC3,0x00, 0xC3,0x00, 0xC3,0x00, 0xC3,0x00, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_motorboat[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0x0C,0x00, 0x1E,0x00, 0x3F,0x00, 0xFF,0xC0, 0x7F,0x80, 0x3F,0x00, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_domino[] PROGMEM = {
0xFF,0xC0, 0xB6,0x40, 0xB6,0x40, 0xB6,0x40, 0xFF,0xC0, 0x80,0x40, 0x8C,0x40, 0x80,0x40, 0xFF,0xC0, 0x00,0x00,
};
static const uint8_t emoji_sm_satellite[] PROGMEM = {
0x70,0x00, 0xD8,0x00, 0x88,0x00, 0xFE,0x00, 0x07,0x00, 0x03,0x80, 0x01,0x80, 0x00,0x80, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_customs[] PROGMEM = {
0x3F,0x00, 0x40,0x80, 0x4C,0x80, 0x52,0x80, 0x61,0x80, 0x5E,0x80, 0x44,0x80, 0x3F,0x00, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_cowboy[] PROGMEM = {
0x1E,0x00, 0x1E,0x00, 0xFF,0xC0, 0x00,0x00, 0x3F,0x00, 0x73,0x80, 0x40,0x80, 0x4C,0x80, 0x3F,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_wheel[] PROGMEM = {
0x3F,0x00, 0x4C,0x80, 0x9E,0x40, 0xBF,0x40, 0xFF,0xC0, 0xBF,0x40, 0x9E,0x40, 0x4C,0x80, 0x3F,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_koala[] PROGMEM = {
0x61,0x80, 0xE1,0xC0, 0xED,0xC0, 0x6D,0x80, 0x3F,0x00, 0x2D,0x00, 0x33,0x00, 0x1E,0x00, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_control_knobs[] PROGMEM = {
0x00,0x00, 0x26,0xC0, 0x26,0xC0, 0x26,0xC0, 0x26,0xC0, 0x76,0xC0, 0x7E,0xC0, 0x2F,0xC0, 0x26,0xC0, 0x00,0x00,
};
static const uint8_t emoji_sm_peach[] PROGMEM = {
0x0C,0x00, 0x18,0x00, 0x3C,0x00, 0x7E,0x00, 0x77,0x00, 0x77,0x00, 0x7F,0x00, 0x3F,0x00, 0x1E,0x00, 0x00,0x00,
};
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,
emoji_sm_wireless, emoji_sm_infinity, emoji_sm_trex, emoji_sm_skull, emoji_sm_cross,
emoji_sm_lightning, emoji_sm_tophat, emoji_sm_motorcycle, emoji_sm_seedling, emoji_sm_flag_au,
emoji_sm_umbrella, emoji_sm_nazar, emoji_sm_globe, emoji_sm_radioactive, emoji_sm_cow,
emoji_sm_alien, emoji_sm_invader, emoji_sm_dagger, emoji_sm_grimace,
emoji_sm_mountain, emoji_sm_end_arrow, emoji_sm_hollow_circle, emoji_sm_dragon, emoji_sm_globe_meridians,
emoji_sm_eggplant, emoji_sm_shield, emoji_sm_goggles, emoji_sm_lizard, emoji_sm_zany_face,
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 ----
struct EmojiCodepoint { uint32_t cp; uint32_t cp2; uint8_t escape; };
static const EmojiCodepoint EMOJI_CODEPOINTS[EMOJI_COUNT] = {
{ 0x1F602, 0x0000, 0x80 }, // joy
{ 0x1F44D, 0x0000, 0x81 }, // thumbsup
{ 0x2639, 0x0000, 0x82 }, // frown
{ 0x1F6DC, 0x0000, 0x83 }, // wireless
{ 0x267E, 0x0000, 0x84 }, // infinity
{ 0x1F996, 0x0000, 0x85 }, // trex
{ 0x2620, 0x0000, 0x86 }, // skull
{ 0x271D, 0x0000, 0x87 }, // cross
{ 0x26A1, 0x0000, 0x88 }, // lightning
{ 0x1F3A9, 0x0000, 0x89 }, // tophat
{ 0x1F3CD, 0x0000, 0x8A }, // motorcycle
{ 0x1F331, 0x0000, 0x8B }, // seedling
{ 0x1F1E6, 0x1F1FA, 0x8C }, // flag_au
{ 0x2602, 0x0000, 0x8D }, // umbrella
{ 0x1F9FF, 0x0000, 0x8E }, // nazar
{ 0x1F30F, 0x0000, 0x8F }, // globe
{ 0x2622, 0x0000, 0x90 }, // radioactive
{ 0x1F404, 0x0000, 0x91 }, // cow
{ 0x1F47D, 0x0000, 0x92 }, // alien
{ 0x1F47E, 0x0000, 0x93 }, // invader
{ 0x1F5E1, 0x0000, 0x94 }, // dagger
{ 0x1F62C, 0x0000, 0x95 }, // grimace
{ 0x26F0, 0x0000, 0x96 }, // mountain
{ 0x1F51A, 0x0000, 0x97 }, // end_arrow
{ 0x2B55, 0x0000, 0x98 }, // hollow_circle
{ 0x1F409, 0x0000, 0x99 }, // dragon
{ 0x1F310, 0x0000, 0x9A }, // globe_meridians
{ 0x1F346, 0x0000, 0x9B }, // eggplant
{ 0x1F6E1, 0x0000, 0x9C }, // shield
{ 0x1F97D, 0x0000, 0x9D }, // goggles
{ 0x1F98E, 0x0000, 0x9E }, // lizard
{ 0x1F92A, 0x0000, 0x9F }, // zany_face
{ 0x1F998, 0x0000, 0xA0 }, // kangaroo
{ 0x1FAB6, 0x0000, 0xA1 }, // feather
{ 0x1F506, 0x0000, 0xA2 }, // bright
{ 0x303D, 0x0000, 0xA3 }, // part_alt
{ 0x1F6E5, 0x0000, 0xA4 }, // motorboat
{ 0x1F030, 0x0000, 0xA5 }, // domino
{ 0x1F4E1, 0x0000, 0xA6 }, // satellite
{ 0x1F6C3, 0x0000, 0xA7 }, // customs
{ 0x1F920, 0x0000, 0xA8 }, // cowboy
{ 0x1F6DE, 0x0000, 0xA9 }, // wheel
{ 0x1F428, 0x0000, 0xAA }, // koala
{ 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; }
if ((b0 & 0xE0) == 0xC0 && remaining >= 2) {
*bytes_consumed = 2;
return ((uint32_t)(b0 & 0x1F) << 6) | (s[1] & 0x3F);
}
if ((b0 & 0xF0) == 0xE0 && remaining >= 3) {
*bytes_consumed = 3;
return ((uint32_t)(b0 & 0x0F) << 12) | ((uint32_t)(s[1] & 0x3F) << 6) | (s[2] & 0x3F);
}
if ((b0 & 0xF8) == 0xF0 && remaining >= 4) {
*bytes_consumed = 4;
return ((uint32_t)(b0 & 0x07) << 18) | ((uint32_t)(s[1] & 0x3F) << 12) | ((uint32_t)(s[2] & 0x3F) << 6) | (s[3] & 0x3F);
}
*bytes_consumed = 1;
return 0xFFFD;
}
// Convert UTF-8 text to internal format (emoji codepoints -> escape bytes)
// Now handles ALL multi-byte UTF-8 (>= 0x80) to prevent raw high bytes in buffer
static void emojiSanitize(const char* src, char* dst, int dstLen) {
const uint8_t* s = (const uint8_t*)src;
int si = 0, di = 0;
int srcLen = strlen(src);
while (si < srcLen && di < dstLen - 1) {
uint8_t b = s[si];
if (b >= 0x80) {
int consumed;
uint32_t cp = emojiDecodeUtf8(s + si, srcLen - si, &consumed);
if (cp == 0xFE0F) { si += consumed; continue; }
bool found = false;
for (int e = 0; e < EMOJI_COUNT; e++) {
if (EMOJI_CODEPOINTS[e].cp == cp) {
if (EMOJI_CODEPOINTS[e].cp2 != 0) {
int consumed2;
if (si + consumed < srcLen) {
uint32_t cp2 = emojiDecodeUtf8(s + si + consumed, srcLen - si - consumed, &consumed2);
if (cp2 == EMOJI_CODEPOINTS[e].cp2) {
dst[di++] = EMOJI_CODEPOINTS[e].escape;
si += consumed + consumed2;
found = true; break;
}
}
continue;
}
dst[di++] = EMOJI_CODEPOINTS[e].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) {
// 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;
si++;
}
}
dst[di] = '\0';
}
static inline bool isEmojiEscape(uint8_t b) {
return b >= EMOJI_ESCAPE_START && b <= EMOJI_ESCAPE_END;
}
static int emojiEncodeUtf8(uint32_t cp, uint8_t* dst) {
if (cp < 0x80) { dst[0] = (uint8_t)cp; return 1; }
if (cp < 0x800) { dst[0] = 0xC0|(cp>>6); dst[1] = 0x80|(cp&0x3F); return 2; }
if (cp < 0x10000) { dst[0] = 0xE0|(cp>>12); dst[1] = 0x80|((cp>>6)&0x3F); dst[2] = 0x80|(cp&0x3F); return 3; }
dst[0] = 0xF0|(cp>>18); dst[1] = 0x80|((cp>>12)&0x3F); dst[2] = 0x80|((cp>>6)&0x3F); dst[3] = 0x80|(cp&0x3F); return 4;
}
static void emojiUnescape(const char* src, char* dst, int dstLen) {
int si = 0, di = 0;
int srcLen = strlen(src);
while (si < srcLen && di < dstLen - 1) {
uint8_t b = (uint8_t)src[si];
if (b == EMOJI_PAD_BYTE) { si++; continue; }
if (isEmojiEscape(b)) {
int idx = b - EMOJI_ESCAPE_START;
if (idx < EMOJI_COUNT) {
uint8_t utf8[8];
int len = emojiEncodeUtf8(EMOJI_CODEPOINTS[idx].cp, utf8);
if (EMOJI_CODEPOINTS[idx].cp2 != 0)
len += emojiEncodeUtf8(EMOJI_CODEPOINTS[idx].cp2, utf8 + len);
if (di + len < dstLen) { memcpy(dst + di, utf8, len); di += len; } else break;
}
si++;
} else { dst[di++] = src[si++]; }
}
dst[di] = '\0';
}
static inline const uint8_t* getEmojiSpriteLg(uint8_t escape_byte) {
if (!isEmojiEscape(escape_byte)) return nullptr;
return (const uint8_t*)pgm_read_ptr(&EMOJI_SPRITES_LG[escape_byte - EMOJI_ESCAPE_START]);
}
static inline const uint8_t* getEmojiSpriteSm(uint8_t escape_byte) {
if (!isEmojiEscape(escape_byte)) return nullptr;
return (const uint8_t*)pgm_read_ptr(&EMOJI_SPRITES_SM[escape_byte - EMOJI_ESCAPE_START]);
}
static inline int emojiUtf8Cost(uint8_t escape_byte) {
if (!isEmojiEscape(escape_byte)) return 1;
int idx = escape_byte - EMOJI_ESCAPE_START;
uint32_t cp = EMOJI_CODEPOINTS[idx].cp;
int cost = (cp < 0x80) ? 1 : (cp < 0x800) ? 2 : (cp < 0x10000) ? 3 : 4;
if (EMOJI_CODEPOINTS[idx].cp2 != 0) {
uint32_t cp2 = EMOJI_CODEPOINTS[idx].cp2;
cost += (cp2 < 0x80) ? 1 : (cp2 < 0x800) ? 2 : (cp2 < 0x10000) ? 3 : 4;
}
return cost;
}

View File

@@ -0,0 +1,538 @@
#pragma once
// =============================================================================
// EpubZipReader.h - Minimal ZIP reader for EPUB files on ESP32-S3
//
// Parses ZIP archives directly from SD card File objects.
// Uses the ESP32 ROM's built-in tinfl decompressor for DEFLATE.
// No external library dependencies.
//
// Supports:
// - STORED (method 0) entries - direct copy
// - DEFLATED (method 8) entries - ROM tinfl decompression
// - ZIP64 is NOT supported (EPUBs don't need it)
//
// Memory: Allocates decompression buffers from PSRAM when available.
// Typical EPUB chapter is 5-50KB, well within ESP32-S3's 8MB PSRAM.
// =============================================================================
#include <SD.h>
#include <FS.h>
// ROM tinfl decompressor - built into ESP32/ESP32-S3 ROM
// If this include fails on your platform, see the fallback note at bottom
#if __has_include(<rom/miniz.h>)
#include <rom/miniz.h>
#define HAS_ROM_TINFL 1
#elif __has_include(<esp32s3/rom/miniz.h>)
#include <esp32s3/rom/miniz.h>
#define HAS_ROM_TINFL 1
#elif __has_include(<esp32/rom/miniz.h>)
#include <esp32/rom/miniz.h>
#define HAS_ROM_TINFL 1
#else
#warning "ROM miniz not found - DEFLATED entries will not be supported"
#define HAS_ROM_TINFL 0
#endif
// ---- ZIP format constants ----
#define ZIP_LOCAL_FILE_HEADER_SIG 0x04034b50
#define ZIP_CENTRAL_DIR_SIG 0x02014b50
#define ZIP_END_OF_CENTRAL_DIR_SIG 0x06054b50
#define ZIP_METHOD_STORED 0
#define ZIP_METHOD_DEFLATED 8
// Maximum files we track in a ZIP (EPUBs typically have 20-100 files)
#define ZIP_MAX_ENTRIES 128
// Maximum filename length within the ZIP
#define ZIP_MAX_FILENAME 128
// ---- Data structures ----
struct ZipEntry {
char filename[ZIP_MAX_FILENAME];
uint16_t compressionMethod; // 0=STORED, 8=DEFLATED
uint32_t compressedSize;
uint32_t uncompressedSize;
uint32_t localHeaderOffset; // Offset to local file header in ZIP
uint32_t crc32;
};
// ---- Helper: read little-endian values from a byte buffer ----
static inline uint16_t zipRead16(const uint8_t* p) {
return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
}
static inline uint32_t zipRead32(const uint8_t* p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) |
((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
// =============================================================================
// EpubZipReader class
// =============================================================================
class EpubZipReader {
public:
EpubZipReader() : _entryCount(0), _isOpen(false), _entries(nullptr) {
// Allocate entries array from PSRAM to avoid stack overflow
// (128 entries × ~146 bytes = ~19KB — too large for 8KB loopTask stack)
#ifdef BOARD_HAS_PSRAM
_entries = (ZipEntry*)ps_malloc(ZIP_MAX_ENTRIES * sizeof(ZipEntry));
#endif
if (!_entries) {
_entries = (ZipEntry*)malloc(ZIP_MAX_ENTRIES * sizeof(ZipEntry));
}
if (!_entries) {
Serial.println("ZipReader: FATAL - failed to allocate entry table");
}
}
~EpubZipReader() {
if (_entries) {
free(_entries);
_entries = nullptr;
}
}
// ----------------------------------------------------------
// Open a ZIP file and parse its central directory.
// Returns true on success, false on error.
// After open(), entries are available via getEntryCount()/getEntry().
// ----------------------------------------------------------
bool open(File& zipFile) {
_isOpen = false;
_entryCount = 0;
if (!_entries) {
Serial.println("ZipReader: entry table not allocated");
return false;
}
if (!zipFile || !zipFile.available()) {
Serial.println("ZipReader: file not valid");
return false;
}
_file = zipFile;
uint32_t fileSize = _file.size();
if (fileSize < 22) {
Serial.println("ZipReader: file too small for ZIP");
return false;
}
// ---- Step 1: Find the End of Central Directory record ----
// EOCD is at least 22 bytes, at end of file.
// Search backwards from end for the EOCD signature.
// Comment can be up to 65535 bytes, but EPUBs typically have none.
uint32_t searchStart = (fileSize > 65557) ? (fileSize - 65557) : 0;
uint32_t eocdOffset = 0;
bool foundEocd = false;
// Read the last chunk into a buffer to search for EOCD signature
uint32_t searchLen = fileSize - searchStart;
// Cap search buffer to a reasonable size
if (searchLen > 1024) {
searchStart = fileSize - 1024;
searchLen = 1024;
}
uint8_t* searchBuf = (uint8_t*)_allocBuffer(searchLen);
if (!searchBuf) {
Serial.println("ZipReader: failed to alloc search buffer");
return false;
}
_file.seek(searchStart);
if (_file.read(searchBuf, searchLen) != (int)searchLen) {
free(searchBuf);
Serial.println("ZipReader: failed to read EOCD area");
return false;
}
// Scan backwards for EOCD signature (0x06054b50)
for (int i = (int)searchLen - 22; i >= 0; i--) {
if (zipRead32(&searchBuf[i]) == ZIP_END_OF_CENTRAL_DIR_SIG) {
eocdOffset = searchStart + i;
// Parse EOCD fields
uint16_t totalEntries = zipRead16(&searchBuf[i + 10]);
uint32_t cdSize = zipRead32(&searchBuf[i + 12]);
uint32_t cdOffset = zipRead32(&searchBuf[i + 16]);
_cdOffset = cdOffset;
_cdSize = cdSize;
_totalEntries = totalEntries;
foundEocd = true;
break;
}
}
free(searchBuf);
if (!foundEocd) {
Serial.println("ZipReader: EOCD not found - not a valid ZIP");
return false;
}
Serial.printf("ZipReader: EOCD found at %u, %u entries, CD at %u (%u bytes)\n",
eocdOffset, _totalEntries, _cdOffset, _cdSize);
// ---- Step 2: Parse Central Directory entries ----
if (_cdSize == 0 || _cdSize > 512 * 1024) {
Serial.println("ZipReader: central directory size unreasonable");
return false;
}
uint8_t* cdBuf = (uint8_t*)_allocBuffer(_cdSize);
if (!cdBuf) {
Serial.printf("ZipReader: failed to alloc %u bytes for central directory\n", _cdSize);
return false;
}
_file.seek(_cdOffset);
if (_file.read(cdBuf, _cdSize) != (int)_cdSize) {
free(cdBuf);
Serial.println("ZipReader: failed to read central directory");
return false;
}
uint32_t pos = 0;
_entryCount = 0;
while (pos + 46 <= _cdSize && _entryCount < ZIP_MAX_ENTRIES) {
if (zipRead32(&cdBuf[pos]) != ZIP_CENTRAL_DIR_SIG) {
break; // No more central directory entries
}
uint16_t method = zipRead16(&cdBuf[pos + 10]);
uint32_t crc = zipRead32(&cdBuf[pos + 16]);
uint32_t compSize = zipRead32(&cdBuf[pos + 20]);
uint32_t uncompSize = zipRead32(&cdBuf[pos + 24]);
uint16_t fnLen = zipRead16(&cdBuf[pos + 28]);
uint16_t extraLen = zipRead16(&cdBuf[pos + 30]);
uint16_t commentLen = zipRead16(&cdBuf[pos + 32]);
uint32_t localOffset = zipRead32(&cdBuf[pos + 42]);
// Copy filename (truncate if necessary)
int copyLen = (fnLen < ZIP_MAX_FILENAME - 1) ? fnLen : ZIP_MAX_FILENAME - 1;
memcpy(_entries[_entryCount].filename, &cdBuf[pos + 46], copyLen);
_entries[_entryCount].filename[copyLen] = '\0';
_entries[_entryCount].compressionMethod = method;
_entries[_entryCount].compressedSize = compSize;
_entries[_entryCount].uncompressedSize = uncompSize;
_entries[_entryCount].localHeaderOffset = localOffset;
_entries[_entryCount].crc32 = crc;
// Skip directories (filenames ending with '/')
if (copyLen > 0 && _entries[_entryCount].filename[copyLen - 1] != '/') {
_entryCount++;
}
// Advance past this central directory entry
pos += 46 + fnLen + extraLen + commentLen;
}
free(cdBuf);
Serial.printf("ZipReader: parsed %d file entries\n", _entryCount);
_isOpen = true;
return true;
}
// ----------------------------------------------------------
// Close the reader (does not close the underlying File).
// ----------------------------------------------------------
void close() {
_isOpen = false;
_entryCount = 0;
}
// ----------------------------------------------------------
// Get entry count and entries
// ----------------------------------------------------------
int getEntryCount() const { return _entryCount; }
const ZipEntry* getEntry(int index) const {
if (index < 0 || index >= _entryCount) return nullptr;
return &_entries[index];
}
// ----------------------------------------------------------
// Find an entry by filename (case-sensitive).
// Returns index, or -1 if not found.
// ----------------------------------------------------------
int findEntry(const char* filename) const {
for (int i = 0; i < _entryCount; i++) {
if (strcmp(_entries[i].filename, filename) == 0) {
return i;
}
}
return -1;
}
// ----------------------------------------------------------
// Find an entry by filename suffix (e.g., ".opf", ".ncx").
// Returns index of first match, or -1 if not found.
// ----------------------------------------------------------
int findEntryBySuffix(const char* suffix) const {
int suffixLen = strlen(suffix);
for (int i = 0; i < _entryCount; i++) {
int fnLen = strlen(_entries[i].filename);
if (fnLen >= suffixLen &&
strcasecmp(&_entries[i].filename[fnLen - suffixLen], suffix) == 0) {
return i;
}
}
return -1;
}
// ----------------------------------------------------------
// Find entries matching a path prefix (e.g., "OEBPS/").
// Fills matchIndices[] up to maxMatches. Returns count found.
// ----------------------------------------------------------
int findEntriesByPrefix(const char* prefix, int* matchIndices, int maxMatches) const {
int count = 0;
int prefixLen = strlen(prefix);
for (int i = 0; i < _entryCount && count < maxMatches; i++) {
if (strncmp(_entries[i].filename, prefix, prefixLen) == 0) {
matchIndices[count++] = i;
}
}
return count;
}
// ----------------------------------------------------------
// Extract a file entry to a newly allocated buffer.
//
// On success, returns a malloc'd buffer (caller must free!)
// and sets *outSize to the uncompressed size.
//
// On failure, returns nullptr.
//
// The buffer is allocated from PSRAM if available.
// ----------------------------------------------------------
uint8_t* extractEntry(int index, uint32_t* outSize) {
if (!_isOpen || index < 0 || index >= _entryCount) {
return nullptr;
}
const ZipEntry& entry = _entries[index];
// ---- Read the local file header to get actual data offset ----
// Local header: 30 bytes fixed + variable filename + extra field
uint8_t localHeader[30];
_file.seek(entry.localHeaderOffset);
if (_file.read(localHeader, 30) != 30) {
Serial.println("ZipReader: failed to read local header");
return nullptr;
}
if (zipRead32(localHeader) != ZIP_LOCAL_FILE_HEADER_SIG) {
Serial.println("ZipReader: bad local header signature");
return nullptr;
}
uint16_t localFnLen = zipRead16(&localHeader[26]);
uint16_t localExtraLen = zipRead16(&localHeader[28]);
uint32_t dataOffset = entry.localHeaderOffset + 30 + localFnLen + localExtraLen;
// ---- Handle based on compression method ----
if (entry.compressionMethod == ZIP_METHOD_STORED) {
return _extractStored(dataOffset, entry.uncompressedSize, outSize);
}
else if (entry.compressionMethod == ZIP_METHOD_DEFLATED) {
return _extractDeflated(dataOffset, entry.compressedSize,
entry.uncompressedSize, outSize);
}
else {
Serial.printf("ZipReader: unsupported compression method %d for %s\n",
entry.compressionMethod, entry.filename);
return nullptr;
}
}
// ----------------------------------------------------------
// Extract a file entry by filename.
// Convenience wrapper around findEntry() + extractEntry().
// ----------------------------------------------------------
uint8_t* extractByName(const char* filename, uint32_t* outSize) {
int idx = findEntry(filename);
if (idx < 0) return nullptr;
return extractEntry(idx, outSize);
}
// ----------------------------------------------------------
// Check if reader is open and valid
// ----------------------------------------------------------
bool isOpen() const { return _isOpen; }
// ----------------------------------------------------------
// Debug: print all entries
// ----------------------------------------------------------
void printEntries() const {
Serial.printf("ZIP contains %d files:\n", _entryCount);
for (int i = 0; i < _entryCount; i++) {
const ZipEntry& e = _entries[i];
Serial.printf(" [%d] %s (%s, %u -> %u bytes)\n",
i, e.filename,
e.compressionMethod == 0 ? "STORED" : "DEFLATED",
e.compressedSize, e.uncompressedSize);
}
}
private:
File _file;
ZipEntry* _entries; // Heap-allocated (PSRAM) entry table
int _entryCount;
bool _isOpen;
uint32_t _cdOffset;
uint32_t _cdSize;
uint16_t _totalEntries;
// ----------------------------------------------------------
// Allocate buffer, preferring PSRAM if available
// ----------------------------------------------------------
void* _allocBuffer(size_t size) {
void* buf = nullptr;
#ifdef BOARD_HAS_PSRAM
buf = ps_malloc(size);
#endif
if (!buf) {
buf = malloc(size);
}
return buf;
}
// ----------------------------------------------------------
// Extract a STORED (uncompressed) entry
// ----------------------------------------------------------
uint8_t* _extractStored(uint32_t dataOffset, uint32_t size, uint32_t* outSize) {
uint8_t* buf = (uint8_t*)_allocBuffer(size + 1); // +1 for null terminator
if (!buf) {
Serial.printf("ZipReader: failed to alloc %u bytes for stored entry\n", size);
return nullptr;
}
_file.seek(dataOffset);
uint32_t bytesRead = _file.read(buf, size);
if (bytesRead != size) {
Serial.printf("ZipReader: short read (got %u, expected %u)\n", bytesRead, size);
free(buf);
return nullptr;
}
buf[size] = '\0'; // Null-terminate for text files
*outSize = size;
// Release SD CS pin for other SPI users
digitalWrite(SDCARD_CS, HIGH);
return buf;
}
// ----------------------------------------------------------
// Extract a DEFLATED entry using ROM tinfl
// ----------------------------------------------------------
uint8_t* _extractDeflated(uint32_t dataOffset, uint32_t compSize,
uint32_t uncompSize, uint32_t* outSize) {
#if HAS_ROM_TINFL
// Allocate compressed data buffer (from PSRAM)
uint8_t* compBuf = (uint8_t*)_allocBuffer(compSize);
if (!compBuf) {
Serial.printf("ZipReader: failed to alloc %u bytes for compressed data\n", compSize);
return nullptr;
}
// Allocate output buffer (+1 for null terminator)
uint8_t* outBuf = (uint8_t*)_allocBuffer(uncompSize + 1);
if (!outBuf) {
Serial.printf("ZipReader: failed to alloc %u bytes for decompressed data\n", uncompSize);
free(compBuf);
return nullptr;
}
// Heap-allocate the decompressor (~11KB struct - too large for 8KB loopTask stack!)
tinfl_decompressor* decomp = (tinfl_decompressor*)_allocBuffer(sizeof(tinfl_decompressor));
if (!decomp) {
Serial.printf("ZipReader: failed to alloc tinfl_decompressor (%u bytes)\n",
(uint32_t)sizeof(tinfl_decompressor));
free(compBuf);
free(outBuf);
return nullptr;
}
// Read compressed data from file
_file.seek(dataOffset);
if (_file.read(compBuf, compSize) != (int)compSize) {
Serial.println("ZipReader: failed to read compressed data");
free(decomp);
free(compBuf);
free(outBuf);
return nullptr;
}
// Release SD CS pin for other SPI users
digitalWrite(SDCARD_CS, HIGH);
// Decompress using ROM tinfl (low-level API to avoid stack allocation)
// ZIP DEFLATE is raw deflate (no zlib header).
tinfl_init(decomp);
size_t inBytes = compSize;
size_t outBytes = uncompSize;
tinfl_status status = tinfl_decompress(
decomp,
(const mz_uint8*)compBuf, // compressed input
&inBytes, // in: available, out: consumed
outBuf, // output buffer base
outBuf, // current output position
&outBytes, // in: available, out: produced
TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF // raw deflate, single-shot
);
free(decomp);
free(compBuf);
if (status != TINFL_STATUS_DONE) {
Serial.printf("ZipReader: DEFLATE failed (status %d)\n", (int)status);
free(outBuf);
return nullptr;
}
outBuf[outBytes] = '\0'; // Null-terminate for text files
*outSize = (uint32_t)outBytes;
if (outBytes != uncompSize) {
Serial.printf("ZipReader: decompressed %u bytes, expected %u\n",
(uint32_t)outBytes, uncompSize);
}
return outBuf;
#else
// No ROM tinfl available
Serial.println("ZipReader: DEFLATE not supported (no ROM tinfl)");
*outSize = 0;
return nullptr;
#endif
}
};
// =============================================================================
// FALLBACK NOTE:
//
// If the ROM tinfl includes fail to compile on your ESP32 variant, you have
// two options:
//
// 1. Install lbernstone/miniz-esp32 from PlatformIO:
// lib_deps = https://github.com/lbernstone/miniz-esp32.git
// Then change the includes above to: #include <miniz.h>
//
// 2. Copy just the tinfl source (~550 lines) from:
// https://github.com/richgel999/miniz/blob/master/miniz_tinfl.c
// into your project. Only tinfl_decompress_mem_to_mem() is needed.
//
// =============================================================================

View File

@@ -0,0 +1,888 @@
#pragma once
// =============================================================================
// EpubProcessor.h - Convert EPUB files to plain text for TextReaderScreen
//
// Pipeline: EPUB (ZIP) → container.xml → OPF spine → extract chapters →
// strip XHTML tags → concatenated plain text → cached .txt on SD
//
// The resulting .txt file is placed in /books/ and picked up automatically
// by TextReaderScreen's existing pagination, indexing, and bookmarking.
//
// Dependencies: EpubZipReader.h (for ZIP extraction)
// =============================================================================
#include <SD.h>
#include <FS.h>
#include "EpubZipReader.h"
#include "Utf8CP437.h"
// Maximum chapters in spine (most novels have 20-80)
#define EPUB_MAX_CHAPTERS 200
// Maximum manifest items we track
#define EPUB_MAX_MANIFEST 256
// Buffer size for reading OPF/container XML
// (These are small files, typically 1-20KB)
#define EPUB_XML_BUF_SIZE 64
class EpubProcessor {
public:
// ----------------------------------------------------------
// Process an EPUB file: extract text and write to SD cache.
//
// epubPath: source, e.g. "/books/The Iliad.epub"
// txtPath: output, e.g. "/books/The Iliad by Homer.txt"
//
// Returns true if the .txt file was written successfully.
// If txtPath already exists, returns true immediately (cached).
// ----------------------------------------------------------
static bool processToText(const char* epubPath, const char* txtPath) {
// Check if already cached
if (SD.exists(txtPath)) {
Serial.printf("EpubProc: '%s' already cached\n", txtPath);
return true;
}
Serial.printf("EpubProc: Processing '%s'\n", epubPath);
unsigned long t0 = millis();
// Open the EPUB (ZIP archive)
File epubFile = SD.open(epubPath, FILE_READ);
if (!epubFile) {
Serial.println("EpubProc: Cannot open EPUB file");
return false;
}
// Heap-allocate zip reader (entries table is ~19KB)
EpubZipReader* zip = new EpubZipReader();
if (!zip) {
epubFile.close();
Serial.println("EpubProc: Cannot allocate ZipReader");
return false;
}
if (!zip->open(epubFile)) {
delete zip;
epubFile.close();
Serial.println("EpubProc: Cannot parse ZIP structure");
return false;
}
// Step 1: Find OPF path from container.xml
char opfPath[EPUB_XML_BUF_SIZE];
opfPath[0] = '\0';
if (!_findOpfPath(zip, opfPath, sizeof(opfPath))) {
delete zip;
epubFile.close();
Serial.println("EpubProc: Cannot find OPF path");
return false;
}
Serial.printf("EpubProc: OPF at '%s'\n", opfPath);
// Determine the content base directory (e.g., "OEBPS/")
char baseDir[EPUB_XML_BUF_SIZE];
_getDirectory(opfPath, baseDir, sizeof(baseDir));
// Step 2: Parse OPF to get title and spine chapter order
char title[128];
title[0] = '\0';
// Chapter paths in spine order
char** chapterPaths = nullptr;
int chapterCount = 0;
if (!_parseOpf(zip, opfPath, baseDir, title, sizeof(title),
&chapterPaths, &chapterCount)) {
delete zip;
epubFile.close();
Serial.println("EpubProc: Cannot parse OPF");
return false;
}
Serial.printf("EpubProc: Title='%s', %d chapters\n", title, chapterCount);
// Step 3: Extract each chapter, strip XHTML, write to output .txt
File outFile = SD.open(txtPath, FILE_WRITE);
if (!outFile) {
_freeChapterPaths(chapterPaths, chapterCount);
delete zip;
epubFile.close();
Serial.printf("EpubProc: Cannot create '%s'\n", txtPath);
return false;
}
// Write title as first line
if (title[0]) {
outFile.println(title);
outFile.println();
}
int chaptersWritten = 0;
uint32_t totalBytes = 0;
for (int i = 0; i < chapterCount; i++) {
int entryIdx = zip->findEntry(chapterPaths[i]);
if (entryIdx < 0) {
Serial.printf("EpubProc: Chapter not found: '%s'\n", chapterPaths[i]);
continue;
}
uint32_t rawSize = 0;
uint8_t* rawData = zip->extractEntry(entryIdx, &rawSize);
if (!rawData || rawSize == 0) {
Serial.printf("EpubProc: Failed to extract chapter %d\n", i);
if (rawData) free(rawData);
continue;
}
// Strip XHTML tags and write plain text
uint32_t textLen = 0;
uint8_t* plainText = _stripXhtml(rawData, rawSize, &textLen);
free(rawData);
if (plainText && textLen > 0) {
outFile.write(plainText, textLen);
// Add chapter separator
outFile.print("\n\n");
totalBytes += textLen + 2;
chaptersWritten++;
}
if (plainText) free(plainText);
}
outFile.flush();
outFile.close();
// Release SD CS for other SPI users
digitalWrite(SDCARD_CS, HIGH);
_freeChapterPaths(chapterPaths, chapterCount);
delete zip;
epubFile.close();
unsigned long elapsed = millis() - t0;
Serial.printf("EpubProc: Done! %d chapters, %u bytes in %lu ms -> '%s'\n",
chaptersWritten, totalBytes, elapsed, txtPath);
return chaptersWritten > 0;
}
// ----------------------------------------------------------
// Extract just the title from an EPUB (for display in file list).
// Returns false if it can't be determined.
// ----------------------------------------------------------
static bool getTitle(const char* epubPath, char* titleBuf, int titleBufSize) {
File epubFile = SD.open(epubPath, FILE_READ);
if (!epubFile) return false;
EpubZipReader* zip = new EpubZipReader();
if (!zip) { epubFile.close(); return false; }
if (!zip->open(epubFile)) {
delete zip; epubFile.close(); return false;
}
char opfPath[EPUB_XML_BUF_SIZE];
if (!_findOpfPath(zip, opfPath, sizeof(opfPath))) {
delete zip; epubFile.close(); return false;
}
// Extract OPF and find <dc:title>
int opfIdx = zip->findEntry(opfPath);
if (opfIdx < 0) { delete zip; epubFile.close(); return false; }
uint32_t opfSize = 0;
uint8_t* opfData = zip->extractEntry(opfIdx, &opfSize);
delete zip;
epubFile.close();
if (!opfData) return false;
bool found = _extractTagContent((const char*)opfData, opfSize,
"dc:title", titleBuf, titleBufSize);
free(opfData);
return found;
}
// ----------------------------------------------------------
// Build a cache .txt path from an .epub path.
// e.g., "/books/mybook.epub" -> "/books/.epub_cache/mybook.txt"
// ----------------------------------------------------------
static void buildCachePath(const char* epubPath, char* cachePath, int cachePathSize) {
// Extract filename without extension
const char* lastSlash = strrchr(epubPath, '/');
const char* filename = lastSlash ? lastSlash + 1 : epubPath;
// Find the directory part
char dir[128];
if (lastSlash) {
int dirLen = lastSlash - epubPath;
if (dirLen >= (int)sizeof(dir)) dirLen = sizeof(dir) - 1;
strncpy(dir, epubPath, dirLen);
dir[dirLen] = '\0';
} else {
strcpy(dir, "/books");
}
// Create cache directory if needed
char cacheDir[160];
snprintf(cacheDir, sizeof(cacheDir), "%s/.epub_cache", dir);
if (!SD.exists(cacheDir)) {
SD.mkdir(cacheDir);
}
// Strip .epub extension
char baseName[128];
strncpy(baseName, filename, sizeof(baseName) - 1);
baseName[sizeof(baseName) - 1] = '\0';
char* dot = strrchr(baseName, '.');
if (dot) *dot = '\0';
snprintf(cachePath, cachePathSize, "%s/%s.txt", cacheDir, baseName);
}
private:
// ----------------------------------------------------------
// Parse container.xml to find the OPF file path.
// Returns true if found.
// ----------------------------------------------------------
static bool _findOpfPath(EpubZipReader* zip, char* opfPath, int opfPathSize) {
int idx = zip->findEntry("META-INF/container.xml");
if (idx < 0) {
// Fallback: find any .opf file directly
idx = zip->findEntryBySuffix(".opf");
if (idx >= 0) {
const ZipEntry* e = zip->getEntry(idx);
strncpy(opfPath, e->filename, opfPathSize - 1);
opfPath[opfPathSize - 1] = '\0';
return true;
}
return false;
}
uint32_t size = 0;
uint8_t* data = zip->extractEntry(idx, &size);
if (!data) return false;
// Find: full-path="OEBPS/content.opf"
bool found = _extractAttribute((const char*)data, size,
"full-path", opfPath, opfPathSize);
free(data);
return found;
}
// ----------------------------------------------------------
// Parse OPF to extract title, build manifest, and resolve spine.
//
// Populates chapterPaths (heap-allocated array of strings) with
// full ZIP paths for each chapter in spine order.
// Caller must free with _freeChapterPaths().
// ----------------------------------------------------------
static bool _parseOpf(EpubZipReader* zip, const char* opfPath,
const char* baseDir, char* title, int titleSize,
char*** outChapterPaths, int* outChapterCount) {
int opfIdx = zip->findEntry(opfPath);
if (opfIdx < 0) return false;
uint32_t opfSize = 0;
uint8_t* opfData = zip->extractEntry(opfIdx, &opfSize);
if (!opfData) return false;
const char* xml = (const char*)opfData;
// Extract title
_extractTagContent(xml, opfSize, "dc:title", title, titleSize);
// Build manifest: map id -> href
// We use two parallel arrays to avoid complex data structures
struct ManifestItem {
char id[64];
char href[128];
bool isContent; // has media-type containing "html" or "xml"
};
// Heap-allocate manifest (could be large)
ManifestItem* manifest = (ManifestItem*)ps_malloc(
EPUB_MAX_MANIFEST * sizeof(ManifestItem));
if (!manifest) {
manifest = (ManifestItem*)malloc(EPUB_MAX_MANIFEST * sizeof(ManifestItem));
}
if (!manifest) {
free(opfData);
return false;
}
int manifestCount = 0;
// Parse <item> elements from <manifest>
const char* manifestStart = _findTag(xml, opfSize, "<manifest");
const char* manifestEnd = manifestStart ?
_findTag(manifestStart, opfSize - (manifestStart - xml), "</manifest") : nullptr;
if (!manifestEnd) manifestEnd = xml + opfSize;
if (manifestStart) {
const char* pos = manifestStart;
while (pos < manifestEnd && manifestCount < EPUB_MAX_MANIFEST) {
pos = _findTag(pos, manifestEnd - pos, "<item");
if (!pos || pos >= manifestEnd) break;
// Find the closing > of this <item ... />
const char* tagEnd = (const char*)memchr(pos, '>', manifestEnd - pos);
if (!tagEnd) break;
tagEnd++;
ManifestItem& item = manifest[manifestCount];
item.id[0] = '\0';
item.href[0] = '\0';
item.isContent = false;
_extractAttributeFromTag(pos, tagEnd - pos, "id",
item.id, sizeof(item.id));
_extractAttributeFromTag(pos, tagEnd - pos, "href",
item.href, sizeof(item.href));
// Check media-type for content files
char mediaType[64];
mediaType[0] = '\0';
_extractAttributeFromTag(pos, tagEnd - pos, "media-type",
mediaType, sizeof(mediaType));
item.isContent = (strstr(mediaType, "html") != nullptr ||
strstr(mediaType, "xml") != nullptr);
if (item.id[0] && item.href[0]) {
manifestCount++;
}
pos = tagEnd;
}
}
Serial.printf("EpubProc: Manifest has %d items\n", manifestCount);
// Parse <spine> to get reading order
// Spine contains <itemref idref="..."/> elements
const char* spineStart = _findTag(xml, opfSize, "<spine");
const char* spineEnd = spineStart ?
_findTag(spineStart, opfSize - (spineStart - xml), "</spine") : nullptr;
if (!spineEnd) spineEnd = xml + opfSize;
// Collect spine idrefs
char** chapterPaths = (char**)ps_malloc(EPUB_MAX_CHAPTERS * sizeof(char*));
if (!chapterPaths) chapterPaths = (char**)malloc(EPUB_MAX_CHAPTERS * sizeof(char*));
if (!chapterPaths) {
free(manifest);
free(opfData);
return false;
}
int chapterCount = 0;
if (spineStart) {
const char* pos = spineStart;
while (pos < spineEnd && chapterCount < EPUB_MAX_CHAPTERS) {
pos = _findTag(pos, spineEnd - pos, "<itemref");
if (!pos || pos >= spineEnd) break;
const char* tagEnd = (const char*)memchr(pos, '>', spineEnd - pos);
if (!tagEnd) break;
tagEnd++;
char idref[64];
idref[0] = '\0';
_extractAttributeFromTag(pos, tagEnd - pos, "idref",
idref, sizeof(idref));
if (idref[0]) {
// Look up in manifest
for (int m = 0; m < manifestCount; m++) {
if (strcmp(manifest[m].id, idref) == 0 && manifest[m].isContent) {
// Build full path: baseDir + href
int pathLen = strlen(baseDir) + strlen(manifest[m].href) + 1;
char* fullPath = (char*)malloc(pathLen);
if (fullPath) {
snprintf(fullPath, pathLen, "%s%s", baseDir, manifest[m].href);
chapterPaths[chapterCount++] = fullPath;
}
break;
}
}
}
pos = tagEnd;
}
}
free(manifest);
free(opfData);
*outChapterPaths = chapterPaths;
*outChapterCount = chapterCount;
return chapterCount > 0;
}
// ----------------------------------------------------------
// Strip XHTML/HTML tags from raw content, producing plain text.
//
// Handles:
// - Tag removal (everything between < and >)
// - <p>, <br>, <div>, <h1>-<h6> → newlines
// - HTML entity decoding (&amp; &lt; &gt; &quot; &apos; &#NNN; &#xHH;)
// - Collapse multiple whitespace/newlines
// - Skip <head>, <style>, <script> content entirely
//
// Returns heap-allocated buffer (caller must free).
// ----------------------------------------------------------
static uint8_t* _stripXhtml(const uint8_t* input, uint32_t inputLen,
uint32_t* outLen) {
// Output can't be larger than input
uint8_t* output = (uint8_t*)ps_malloc(inputLen + 1);
if (!output) output = (uint8_t*)malloc(inputLen + 1);
if (!output) { *outLen = 0; return nullptr; }
uint32_t outPos = 0;
bool inTag = false;
bool skipContent = false; // Inside <head>, <style>, <script>
char tagName[32];
int tagNamePos = 0;
bool tagNameDone = false;
bool isClosingTag = false;
bool lastWasNewline = false;
bool lastWasSpace = false;
// Skip to <body> if present (ignore everything before it)
const uint8_t* start = input;
const uint8_t* inputEnd = input + inputLen;
const char* bodyStart = _findTagCI((const char*)input, inputLen, "<body");
if (bodyStart) {
const char* bodyTagEnd = (const char*)memchr(bodyStart, '>',
inputEnd - (const uint8_t*)bodyStart);
if (bodyTagEnd) {
start = (const uint8_t*)(bodyTagEnd + 1);
}
}
const uint8_t* end = inputEnd;
for (const uint8_t* p = start; p < end; p++) {
char c = (char)*p;
if (inTag) {
// Collecting tag name
if (!tagNameDone) {
if (tagNamePos == 0 && c == '/') {
isClosingTag = true;
continue;
}
if (c == '>' || c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '/') {
tagName[tagNamePos] = '\0';
tagNameDone = true;
} else if (tagNamePos < (int)sizeof(tagName) - 1) {
tagName[tagNamePos++] = (c >= 'A' && c <= 'Z') ? (c + 32) : c;
}
}
if (c == '>') {
inTag = false;
// Handle skip regions
if (!isClosingTag) {
if (strcmp(tagName, "head") == 0 ||
strcmp(tagName, "style") == 0 ||
strcmp(tagName, "script") == 0) {
skipContent = true;
}
} else {
if (strcmp(tagName, "head") == 0 ||
strcmp(tagName, "style") == 0 ||
strcmp(tagName, "script") == 0) {
skipContent = false;
}
}
if (!skipContent) {
// Block-level elements produce newlines
if (strcmp(tagName, "p") == 0 ||
strcmp(tagName, "div") == 0 ||
strcmp(tagName, "br") == 0 ||
strcmp(tagName, "h1") == 0 ||
strcmp(tagName, "h2") == 0 ||
strcmp(tagName, "h3") == 0 ||
strcmp(tagName, "h4") == 0 ||
strcmp(tagName, "h5") == 0 ||
strcmp(tagName, "h6") == 0 ||
strcmp(tagName, "li") == 0 ||
strcmp(tagName, "tr") == 0 ||
strcmp(tagName, "blockquote") == 0 ||
strcmp(tagName, "hr") == 0) {
if (outPos > 0 && !lastWasNewline) {
output[outPos++] = '\n';
lastWasNewline = true;
lastWasSpace = false;
}
}
}
continue;
}
continue;
}
// Not in a tag
if (c == '<') {
inTag = true;
tagNamePos = 0;
tagNameDone = false;
isClosingTag = false;
continue;
}
if (skipContent) continue;
// Handle HTML entities
if (c == '&') {
char decoded = _decodeEntity(p, end, &p);
if (decoded) {
c = decoded;
// p now points to the ';' or last char of entity; loop will increment
}
}
// Handle UTF-8 multi-byte sequences (smart quotes, em dashes, accented chars, etc.)
// These appear as raw bytes in XHTML. Typographic chars are mapped to ASCII;
// accented Latin chars are preserved as UTF-8 for CP437 rendering on e-ink.
if ((uint8_t)c >= 0xC0) {
uint32_t codepoint = 0;
int extraBytes = 0;
if (((uint8_t)c & 0xE0) == 0xC0) {
// 2-byte sequence: 110xxxxx 10xxxxxx
codepoint = (uint8_t)c & 0x1F;
extraBytes = 1;
} else if (((uint8_t)c & 0xF0) == 0xE0) {
// 3-byte sequence: 1110xxxx 10xxxxxx 10xxxxxx
codepoint = (uint8_t)c & 0x0F;
extraBytes = 2;
} else if (((uint8_t)c & 0xF8) == 0xF0) {
// 4-byte sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
codepoint = (uint8_t)c & 0x07;
extraBytes = 3;
}
// Read continuation bytes
bool valid = true;
for (int b = 0; b < extraBytes && p + 1 + b < end; b++) {
uint8_t cb = *(p + 1 + b);
if ((cb & 0xC0) != 0x80) { valid = false; break; }
codepoint = (codepoint << 6) | (cb & 0x3F);
}
if (valid && extraBytes > 0) {
p += extraBytes; // Skip continuation bytes (loop increments past lead byte)
// Map Unicode codepoints to displayable equivalents
// Typographic chars → ASCII, accented chars → preserved as UTF-8
char mapped = 0;
switch (codepoint) {
case 0x2018: case 0x2019: mapped = '\''; break; // Smart single quotes
case 0x201C: case 0x201D: mapped = '"'; break; // Smart double quotes
case 0x2013: case 0x2014: mapped = '-'; break; // En/em dash
case 0x2026: mapped = '.'; break; // Ellipsis
case 0x2022: mapped = '*'; break; // Bullet
case 0x00A0: mapped = ' '; break; // Non-breaking space
case 0x00AB: case 0x00BB: mapped = '"'; break; // Guillemets
case 0x2032: mapped = '\''; break; // Prime
case 0x2033: mapped = '"'; break; // Double prime
case 0x2010: case 0x2011: mapped = '-'; break; // Hyphens
case 0x2012: mapped = '-'; break; // Figure dash
case 0x2015: mapped = '-'; break; // Horizontal bar
case 0x2039: case 0x203A: mapped = '\''; break; // Single guillemets
default:
if (codepoint >= 0x20 && codepoint < 0x7F) {
mapped = (char)codepoint; // Basic ASCII range
} else if (unicodeToCP437(codepoint)) {
// Accented character that the e-ink font can render via CP437.
// Preserve as UTF-8 in the output; the text reader will decode
// and map to CP437 at render time.
if (codepoint <= 0x7FF) {
output[outPos++] = 0xC0 | (codepoint >> 6);
output[outPos++] = 0x80 | (codepoint & 0x3F);
} else if (codepoint <= 0xFFFF) {
output[outPos++] = 0xE0 | (codepoint >> 12);
output[outPos++] = 0x80 | ((codepoint >> 6) & 0x3F);
output[outPos++] = 0x80 | (codepoint & 0x3F);
}
lastWasNewline = false;
lastWasSpace = false;
continue; // Already wrote to output
} else {
continue; // Skip unmappable characters
}
break;
}
c = mapped;
} else {
continue; // Skip malformed UTF-8
}
} else if ((uint8_t)c >= 0x80) {
// Stray continuation byte (0x80-0xBF) — skip
continue;
}
// Whitespace collapsing
if (c == '\n' || c == '\r') {
if (!lastWasNewline && outPos > 0) {
output[outPos++] = '\n';
lastWasNewline = true;
lastWasSpace = false;
}
continue;
}
if (c == ' ' || c == '\t') {
if (!lastWasSpace && !lastWasNewline && outPos > 0) {
output[outPos++] = ' ';
lastWasSpace = true;
}
continue;
}
// Regular character
output[outPos++] = c;
lastWasNewline = false;
lastWasSpace = false;
}
// Trim trailing whitespace
while (outPos > 0 && (output[outPos-1] == '\n' || output[outPos-1] == ' ')) {
outPos--;
}
output[outPos] = '\0';
*outLen = outPos;
return output;
}
// ----------------------------------------------------------
// Decode an HTML entity starting at '&'.
// Advances *pos to the last character consumed.
// Returns the decoded character, or '&' if not recognized.
// ----------------------------------------------------------
static char _decodeEntity(const uint8_t* p, const uint8_t* end,
const uint8_t** outPos) {
// Look for ';' within a reasonable range
const uint8_t* semi = p + 1;
int maxLen = 10;
while (semi < end && semi < p + maxLen && *semi != ';') semi++;
if (*semi != ';' || semi >= end) {
*outPos = p; // Not an entity, return '&' literal
return '&';
}
int entityLen = semi - p - 1; // Length between & and ;
const char* entity = (const char*)(p + 1);
*outPos = semi; // Skip past ';'
// Named entities
if (entityLen == 3 && strncmp(entity, "amp", 3) == 0) return '&';
if (entityLen == 2 && strncmp(entity, "lt", 2) == 0) return '<';
if (entityLen == 2 && strncmp(entity, "gt", 2) == 0) return '>';
if (entityLen == 4 && strncmp(entity, "quot", 4) == 0) return '"';
if (entityLen == 4 && strncmp(entity, "apos", 4) == 0) return '\'';
if (entityLen == 4 && strncmp(entity, "nbsp", 4) == 0) return ' ';
if (entityLen == 5 && strncmp(entity, "mdash", 5) == 0) return '-';
if (entityLen == 5 && strncmp(entity, "ndash", 5) == 0) return '-';
if (entityLen == 6 && strncmp(entity, "hellip", 6) == 0) return '.';
if (entityLen == 5 && strncmp(entity, "lsquo", 5) == 0) return '\'';
if (entityLen == 5 && strncmp(entity, "rsquo", 5) == 0) return '\'';
if (entityLen == 5 && strncmp(entity, "ldquo", 5) == 0) return '"';
if (entityLen == 5 && strncmp(entity, "rdquo", 5) == 0) return '"';
// Common accented character entities → CP437 bytes for built-in font
if (entityLen == 6 && strncmp(entity, "eacute", 6) == 0) return (char)0x82; // é
if (entityLen == 6 && strncmp(entity, "egrave", 6) == 0) return (char)0x8A; // è
if (entityLen == 5 && strncmp(entity, "ecirc", 5) == 0) return (char)0x88; // ê
if (entityLen == 4 && strncmp(entity, "euml", 4) == 0) return (char)0x89; // ë
if (entityLen == 6 && strncmp(entity, "agrave", 6) == 0) return (char)0x85; // à
if (entityLen == 6 && strncmp(entity, "aacute", 6) == 0) return (char)0xA0; // á
if (entityLen == 5 && strncmp(entity, "acirc", 5) == 0) return (char)0x83; // â
if (entityLen == 4 && strncmp(entity, "auml", 4) == 0) return (char)0x84; // ä
if (entityLen == 6 && strncmp(entity, "ccedil", 6) == 0) return (char)0x87; // ç
if (entityLen == 6 && strncmp(entity, "iacute", 6) == 0) return (char)0xA1; // í
if (entityLen == 5 && strncmp(entity, "icirc", 5) == 0) return (char)0x8C; // î
if (entityLen == 4 && strncmp(entity, "iuml", 4) == 0) return (char)0x8B; // ï
if (entityLen == 6 && strncmp(entity, "igrave", 6) == 0) return (char)0x8D; // ì
if (entityLen == 6 && strncmp(entity, "oacute", 6) == 0) return (char)0xA2; // ó
if (entityLen == 5 && strncmp(entity, "ocirc", 5) == 0) return (char)0x93; // ô
if (entityLen == 4 && strncmp(entity, "ouml", 4) == 0) return (char)0x94; // ö
if (entityLen == 6 && strncmp(entity, "ograve", 6) == 0) return (char)0x95; // ò
if (entityLen == 6 && strncmp(entity, "uacute", 6) == 0) return (char)0xA3; // ú
if (entityLen == 5 && strncmp(entity, "ucirc", 5) == 0) return (char)0x96; // û
if (entityLen == 4 && strncmp(entity, "uuml", 4) == 0) return (char)0x81; // ü
if (entityLen == 6 && strncmp(entity, "ugrave", 6) == 0) return (char)0x97; // ù
if (entityLen == 6 && strncmp(entity, "ntilde", 6) == 0) return (char)0xA4; // ñ
if (entityLen == 6 && strncmp(entity, "Eacute", 6) == 0) return (char)0x90; // É
if (entityLen == 6 && strncmp(entity, "Ccedil", 6) == 0) return (char)0x80; // Ç
if (entityLen == 6 && strncmp(entity, "Ntilde", 6) == 0) return (char)0xA5; // Ñ
if (entityLen == 4 && strncmp(entity, "Auml", 4) == 0) return (char)0x8E; // Ä
if (entityLen == 4 && strncmp(entity, "Ouml", 4) == 0) return (char)0x99; // Ö
if (entityLen == 4 && strncmp(entity, "Uuml", 4) == 0) return (char)0x9A; // Ü
if (entityLen == 5 && strncmp(entity, "szlig", 5) == 0) return (char)0xE1; // ß
// Numeric entities: &#NNN; or &#xHH;
if (entityLen >= 2 && entity[0] == '#') {
int codepoint = 0;
if (entity[1] == 'x' || entity[1] == 'X') {
// Hex
for (int i = 2; i < entityLen; i++) {
char ch = entity[i];
if (ch >= '0' && ch <= '9') codepoint = codepoint * 16 + (ch - '0');
else if (ch >= 'a' && ch <= 'f') codepoint = codepoint * 16 + (ch - 'a' + 10);
else if (ch >= 'A' && ch <= 'F') codepoint = codepoint * 16 + (ch - 'A' + 10);
}
} else {
// Decimal
for (int i = 1; i < entityLen; i++) {
char ch = entity[i];
if (ch >= '0' && ch <= '9') codepoint = codepoint * 10 + (ch - '0');
}
}
// Map to displayable character (best effort)
if (codepoint >= 32 && codepoint < 127) return (char)codepoint;
if (codepoint == 160) return ' '; // non-breaking space
// Try CP437 mapping for accented characters.
// The byte value will be passed through to the built-in font.
uint8_t cp437 = unicodeToCP437(codepoint);
if (cp437) return (char)cp437;
// Unknown codepoint > 127: skip it
return ' ';
}
// Unknown entity - output as space
return ' ';
}
// ----------------------------------------------------------
// Find a tag in XML data (case-sensitive, e.g., "<manifest").
// Returns pointer to '<' of found tag, or nullptr.
// ----------------------------------------------------------
static const char* _findTag(const char* data, int dataLen, const char* tag) {
int tagLen = strlen(tag);
const char* end = data + dataLen - tagLen;
for (const char* p = data; p <= end; p++) {
if (memcmp(p, tag, tagLen) == 0) return p;
}
return nullptr;
}
// ----------------------------------------------------------
// Find a tag case-insensitively (for <body>, <BODY>, etc.).
// ----------------------------------------------------------
static const char* _findTagCI(const char* data, int dataLen, const char* tag) {
int tagLen = strlen(tag);
const char* end = data + dataLen - tagLen;
for (const char* p = data; p <= end; p++) {
if (strncasecmp(p, tag, tagLen) == 0) return p;
}
return nullptr;
}
// ----------------------------------------------------------
// Extract an attribute value from a region of XML.
// Scans for attr="value" and copies value to outBuf.
// ----------------------------------------------------------
static bool _extractAttribute(const char* data, int dataLen,
const char* attrName, char* outBuf, int outBufSize) {
int nameLen = strlen(attrName);
const char* end = data + dataLen;
for (const char* p = data; p < end - nameLen - 2; p++) {
if (strncmp(p, attrName, nameLen) == 0 && p[nameLen] == '=') {
p += nameLen + 1;
char quote = *p;
if (quote != '"' && quote != '\'') continue;
p++;
const char* valEnd = (const char*)memchr(p, quote, end - p);
if (!valEnd) continue;
int valLen = valEnd - p;
if (valLen >= outBufSize) valLen = outBufSize - 1;
memcpy(outBuf, p, valLen);
outBuf[valLen] = '\0';
return true;
}
}
return false;
}
// ----------------------------------------------------------
// Extract an attribute value from within a single tag string.
// (More targeted version for parsing <item id="x" href="y"/>)
// ----------------------------------------------------------
static bool _extractAttributeFromTag(const char* tag, int tagLen,
const char* attrName,
char* outBuf, int outBufSize) {
return _extractAttribute(tag, tagLen, attrName, outBuf, outBufSize);
}
// ----------------------------------------------------------
// Extract text content between <tagName>...</tagName>.
// Works for simple cases like <dc:title>The Iliad</dc:title>.
// ----------------------------------------------------------
static bool _extractTagContent(const char* data, int dataLen,
const char* tagName, char* outBuf, int outBufSize) {
// Build open tag pattern: "<dc:title" (without >)
char openTag[64];
snprintf(openTag, sizeof(openTag), "<%s", tagName);
const char* start = _findTag(data, dataLen, openTag);
if (!start) return false;
// Find the > that closes the opening tag
const char* end = data + dataLen;
const char* contentStart = (const char*)memchr(start, '>', end - start);
if (!contentStart) return false;
contentStart++; // Skip past '>'
// Find closing tag
char closeTag[64];
snprintf(closeTag, sizeof(closeTag), "</%s>", tagName);
const char* contentEnd = _findTag(contentStart, end - contentStart, closeTag);
if (!contentEnd) return false;
int len = contentEnd - contentStart;
if (len >= outBufSize) len = outBufSize - 1;
memcpy(outBuf, contentStart, len);
outBuf[len] = '\0';
return true;
}
// ----------------------------------------------------------
// Get directory portion of a path.
// "OEBPS/content.opf" -> "OEBPS/"
// "content.opf" -> ""
// ----------------------------------------------------------
static void _getDirectory(const char* path, char* dirBuf, int dirBufSize) {
const char* lastSlash = strrchr(path, '/');
if (lastSlash) {
int len = lastSlash - path + 1; // Include trailing /
if (len >= dirBufSize) len = dirBufSize - 1;
memcpy(dirBuf, path, len);
dirBuf[len] = '\0';
} else {
dirBuf[0] = '\0';
}
}
// ----------------------------------------------------------
// Free the chapter paths array allocated by _parseOpf().
// ----------------------------------------------------------
static void _freeChapterPaths(char** paths, int count) {
if (paths) {
for (int i = 0; i < count; i++) {
if (paths[i]) free(paths[i]);
}
free(paths);
}
}
};

View File

@@ -0,0 +1,651 @@
#pragma once
// =============================================================================
// M4BMetadata.h - Lightweight MP4/M4B atom parser for metadata extraction
//
// Walks the MP4 atom (box) tree to extract:
// - Title (moov/udta/meta/ilst/©nam)
// - Author (moov/udta/meta/ilst/©ART)
// - Cover art (moov/udta/meta/ilst/covr) - JPEG offset+size within file
// - Duration (moov/mvhd timescale + duration)
// - Chapter markers (moov/udta/chpl) - Nero-style chapter list
//
// Designed for embedded use: no dynamic allocation, reads directly from SD
// via Arduino File API, uses a small stack buffer for atom headers.
//
// Usage:
// M4BMetadata meta;
// File f = SD.open("/audiobooks/mybook.m4b");
// if (meta.parse(f)) {
// Serial.printf("Title: %s\n", meta.title);
// Serial.printf("Author: %s\n", meta.author);
// if (meta.hasCoverArt) {
// // JPEG data is at meta.coverOffset, meta.coverSize bytes
// }
// }
// f.close();
// =============================================================================
#include <SD.h>
// Maximum metadata string lengths (including null terminator)
#define M4B_MAX_TITLE 128
#define M4B_MAX_AUTHOR 64
#define M4B_MAX_CHAPTERS 100
struct M4BChapter {
uint32_t startMs; // Chapter start time in milliseconds
char name[48]; // Chapter title (truncated to fit)
};
class M4BMetadata {
public:
// Extracted metadata
char title[M4B_MAX_TITLE];
char author[M4B_MAX_AUTHOR];
bool hasCoverArt;
uint32_t coverOffset; // Byte offset of JPEG/PNG data within file
uint32_t coverSize; // Size of cover image data in bytes
uint8_t coverFormat; // 13=JPEG, 14=PNG (from MP4 well-known type)
uint32_t durationMs; // Total duration in milliseconds
uint32_t sampleRate; // Audio sample rate (from audio stsd)
uint32_t bitrate; // Approximate bitrate in bps
// Chapter data
M4BChapter chapters[M4B_MAX_CHAPTERS];
int chapterCount;
M4BMetadata() { clear(); }
void clear() {
title[0] = '\0';
author[0] = '\0';
hasCoverArt = false;
coverOffset = 0;
coverSize = 0;
coverFormat = 0;
durationMs = 0;
sampleRate = 44100;
bitrate = 0;
chapterCount = 0;
}
// Parse an open file. Returns true if at least title or duration was found.
// File position is NOT preserved — caller should seek as needed afterward.
bool parse(File& file) {
clear();
if (!file || file.size() < 8) return false;
_fileSize = file.size();
// Walk top-level atoms looking for 'moov'
uint32_t pos = 0;
while (pos < _fileSize) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
if (hdr.type == ATOM_MOOV) {
parseMoov(file, hdr.dataOffset, hdr.dataOffset + hdr.dataSize);
break; // moov found and parsed, we're done
}
// Skip to next top-level atom
pos += hdr.size;
if (hdr.size == 0) break; // size=0 means "extends to EOF"
}
return (title[0] != '\0' || durationMs > 0);
}
// Get chapter index for a given playback position (milliseconds).
// Returns -1 if no chapters or position is before first chapter.
int getChapterForPosition(uint32_t positionMs) const {
if (chapterCount == 0) return -1;
int ch = 0;
for (int i = 1; i < chapterCount; i++) {
if (chapters[i].startMs > positionMs) break;
ch = i;
}
return ch;
}
// Get the start position of the next chapter after the given position.
// Returns 0 if no next chapter.
uint32_t getNextChapterMs(uint32_t positionMs) const {
for (int i = 0; i < chapterCount; i++) {
if (chapters[i].startMs > positionMs) return chapters[i].startMs;
}
return 0;
}
// Get the start position of the current or previous chapter.
uint32_t getPrevChapterMs(uint32_t positionMs) const {
uint32_t prev = 0;
for (int i = 0; i < chapterCount; i++) {
if (chapters[i].startMs >= positionMs) break;
prev = chapters[i].startMs;
}
return prev;
}
private:
uint32_t _fileSize;
// MP4 atom type codes (big-endian FourCC)
static constexpr uint32_t ATOM_MOOV = 0x6D6F6F76; // 'moov'
static constexpr uint32_t ATOM_MVHD = 0x6D766864; // 'mvhd'
static constexpr uint32_t ATOM_UDTA = 0x75647461; // 'udta'
static constexpr uint32_t ATOM_META = 0x6D657461; // 'meta'
static constexpr uint32_t ATOM_ILST = 0x696C7374; // 'ilst'
static constexpr uint32_t ATOM_NAM = 0xA96E616D; // '©nam'
static constexpr uint32_t ATOM_ART = 0xA9415254; // '©ART'
static constexpr uint32_t ATOM_COVR = 0x636F7672; // 'covr'
static constexpr uint32_t ATOM_DATA = 0x64617461; // 'data'
static constexpr uint32_t ATOM_CHPL = 0x6368706C; // 'chpl' (Nero chapters)
static constexpr uint32_t ATOM_TRAK = 0x7472616B; // 'trak'
static constexpr uint32_t ATOM_MDIA = 0x6D646961; // 'mdia'
static constexpr uint32_t ATOM_MDHD = 0x6D646864; // 'mdhd'
static constexpr uint32_t ATOM_HDLR = 0x68646C72; // 'hdlr'
struct AtomHeader {
uint32_t type;
uint64_t size; // Total atom size including header
uint32_t dataOffset; // File offset where data begins (after header)
uint64_t dataSize; // size - header_length
};
// Read a 32-bit big-endian value from file at current position
static uint32_t readU32BE(File& file) {
uint8_t buf[4];
file.read(buf, 4);
return ((uint32_t)buf[0] << 24) | ((uint32_t)buf[1] << 16) |
((uint32_t)buf[2] << 8) | buf[3];
}
// Read a 64-bit big-endian value
static uint64_t readU64BE(File& file) {
uint32_t hi = readU32BE(file);
uint32_t lo = readU32BE(file);
return ((uint64_t)hi << 32) | lo;
}
// Read a 16-bit big-endian value
static uint16_t readU16BE(File& file) {
uint8_t buf[2];
file.read(buf, 2);
return ((uint16_t)buf[0] << 8) | buf[1];
}
// Read atom header at given file offset
bool readAtomHeader(File& file, uint32_t offset, AtomHeader& hdr) {
if (offset + 8 > _fileSize) return false;
file.seek(offset);
uint32_t size32 = readU32BE(file);
hdr.type = readU32BE(file);
if (size32 == 1) {
// 64-bit extended size
if (offset + 16 > _fileSize) return false;
hdr.size = readU64BE(file);
hdr.dataOffset = offset + 16;
hdr.dataSize = (hdr.size > 16) ? hdr.size - 16 : 0;
} else if (size32 == 0) {
// Atom extends to end of file
hdr.size = _fileSize - offset;
hdr.dataOffset = offset + 8;
hdr.dataSize = hdr.size - 8;
} else {
hdr.size = size32;
hdr.dataOffset = offset + 8;
hdr.dataSize = (size32 > 8) ? size32 - 8 : 0;
}
return true;
}
// Parse the moov container atom
void parseMoov(File& file, uint32_t start, uint32_t end) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
switch (hdr.type) {
case ATOM_MVHD:
parseMvhd(file, hdr.dataOffset, (uint32_t)hdr.dataSize);
break;
case ATOM_UDTA:
parseUdta(file, hdr.dataOffset, hdr.dataOffset + (uint32_t)hdr.dataSize);
break;
case ATOM_TRAK:
break;
}
pos += (uint32_t)hdr.size;
}
}
// Parse mvhd (movie header) for duration
void parseMvhd(File& file, uint32_t offset, uint32_t size) {
file.seek(offset);
uint8_t version = file.read();
if (version == 0) {
file.seek(offset + 4); // skip version(1) + flags(3)
/* create_time */ readU32BE(file);
/* modify_time */ readU32BE(file);
uint32_t timescale = readU32BE(file);
uint32_t duration = readU32BE(file);
if (timescale > 0) {
durationMs = (uint32_t)((uint64_t)duration * 1000 / timescale);
}
} else if (version == 1) {
file.seek(offset + 4);
/* create_time */ readU64BE(file);
/* modify_time */ readU64BE(file);
uint32_t timescale = readU32BE(file);
uint64_t duration = readU64BE(file);
if (timescale > 0) {
durationMs = (uint32_t)(duration * 1000 / timescale);
}
}
}
// Parse udta container — contains meta and/or chpl
void parseUdta(File& file, uint32_t start, uint32_t end) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
if (hdr.type == ATOM_META) {
parseMeta(file, hdr.dataOffset + 4,
hdr.dataOffset + (uint32_t)hdr.dataSize);
} else if (hdr.type == ATOM_CHPL) {
parseChpl(file, hdr.dataOffset, (uint32_t)hdr.dataSize);
}
pos += (uint32_t)hdr.size;
}
}
// Parse meta container — contains hdlr + ilst
void parseMeta(File& file, uint32_t start, uint32_t end) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
if (hdr.type == ATOM_ILST) {
parseIlst(file, hdr.dataOffset, hdr.dataOffset + (uint32_t)hdr.dataSize);
}
pos += (uint32_t)hdr.size;
}
}
// Parse ilst (iTunes metadata list) — contains ©nam, ©ART, covr etc.
void parseIlst(File& file, uint32_t start, uint32_t end) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
switch (hdr.type) {
case ATOM_NAM:
extractTextData(file, hdr.dataOffset,
hdr.dataOffset + (uint32_t)hdr.dataSize,
title, M4B_MAX_TITLE);
break;
case ATOM_ART:
extractTextData(file, hdr.dataOffset,
hdr.dataOffset + (uint32_t)hdr.dataSize,
author, M4B_MAX_AUTHOR);
break;
case ATOM_COVR:
extractCoverData(file, hdr.dataOffset,
hdr.dataOffset + (uint32_t)hdr.dataSize);
break;
}
pos += (uint32_t)hdr.size;
}
}
// Extract text from a 'data' sub-atom within an ilst entry.
void extractTextData(File& file, uint32_t start, uint32_t end,
char* dest, int maxLen) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
if (hdr.type == ATOM_DATA && hdr.dataSize > 8) {
uint32_t textOffset = hdr.dataOffset + 8;
uint32_t textLen = (uint32_t)hdr.dataSize - 8;
if (textLen > (uint32_t)(maxLen - 1)) textLen = maxLen - 1;
file.seek(textOffset);
file.read((uint8_t*)dest, textLen);
dest[textLen] = '\0';
return;
}
pos += (uint32_t)hdr.size;
}
}
// Extract cover art location from 'data' sub-atom within covr.
void extractCoverData(File& file, uint32_t start, uint32_t end) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
if (hdr.type == ATOM_DATA && hdr.dataSize > 8) {
file.seek(hdr.dataOffset);
uint32_t typeIndicator = readU32BE(file);
uint8_t wellKnownType = typeIndicator & 0xFF;
coverOffset = hdr.dataOffset + 8;
coverSize = (uint32_t)hdr.dataSize - 8;
coverFormat = wellKnownType; // 13=JPEG, 14=PNG
hasCoverArt = (coverSize > 0);
Serial.printf("M4B: Cover art found - %s, %u bytes at offset %u\n",
wellKnownType == 13 ? "JPEG" :
wellKnownType == 14 ? "PNG" : "unknown",
coverSize, coverOffset);
return;
}
pos += (uint32_t)hdr.size;
}
}
// =====================================================================
// ID3v2 Parser for MP3 files
// =====================================================================
public:
// Parse ID3v2 tags from an MP3 file. Extracts title (TIT2), artist
// (TPE1), and cover art (APIC). Fills the same metadata fields as
// the M4B parser so decodeCoverArt() works unchanged.
bool parseID3v2(File& file) {
clear();
if (!file || file.size() < 10) return false;
file.seek(0);
uint8_t hdr[10];
if (file.read(hdr, 10) != 10) return false;
// Verify "ID3" magic
if (hdr[0] != 'I' || hdr[1] != 'D' || hdr[2] != '3') {
Serial.println("ID3: No ID3v2 header found");
return false;
}
uint8_t versionMajor = hdr[3]; // 3 = ID3v2.3, 4 = ID3v2.4
bool v24 = (versionMajor == 4);
bool hasExtHeader = (hdr[5] & 0x40) != 0;
// Tag size is syncsafe integer (4 x 7-bit bytes)
uint32_t tagSize = ((uint32_t)(hdr[6] & 0x7F) << 21) |
((uint32_t)(hdr[7] & 0x7F) << 14) |
((uint32_t)(hdr[8] & 0x7F) << 7) |
(hdr[9] & 0x7F);
uint32_t tagEnd = 10 + tagSize;
if (tagEnd > file.size()) tagEnd = file.size();
Serial.printf("ID3: v2.%d, %u bytes\n", versionMajor, tagSize);
// Skip extended header if present
uint32_t pos = 10;
if (hasExtHeader && pos + 4 < tagEnd) {
file.seek(pos);
uint32_t extSize;
if (v24) {
uint8_t eb[4];
file.read(eb, 4);
extSize = ((uint32_t)(eb[0] & 0x7F) << 21) |
((uint32_t)(eb[1] & 0x7F) << 14) |
((uint32_t)(eb[2] & 0x7F) << 7) |
(eb[3] & 0x7F);
} else {
extSize = readU32BE(file) + 4;
}
pos += extSize;
}
// Walk ID3v2 frames
bool foundTitle = false, foundArtist = false, foundCover = false;
while (pos + 10 < tagEnd) {
file.seek(pos);
uint8_t fhdr[10];
if (file.read(fhdr, 10) != 10) break;
if (fhdr[0] == 0) break;
char frameId[5] = { (char)fhdr[0], (char)fhdr[1],
(char)fhdr[2], (char)fhdr[3], '\0' };
uint32_t frameSize;
if (v24) {
frameSize = ((uint32_t)(fhdr[4] & 0x7F) << 21) |
((uint32_t)(fhdr[5] & 0x7F) << 14) |
((uint32_t)(fhdr[6] & 0x7F) << 7) |
(fhdr[7] & 0x7F);
} else {
frameSize = ((uint32_t)fhdr[4] << 24) | ((uint32_t)fhdr[5] << 16) |
((uint32_t)fhdr[6] << 8) | fhdr[7];
}
if (frameSize == 0 || pos + 10 + frameSize > tagEnd) break;
uint32_t dataStart = pos + 10;
// --- TIT2 (Title) ---
if (!foundTitle && strcmp(frameId, "TIT2") == 0 && frameSize > 1) {
id3ExtractText(file, dataStart, frameSize, title, M4B_MAX_TITLE);
foundTitle = (title[0] != '\0');
}
// --- TPE1 (Artist/Author) ---
if (!foundArtist && strcmp(frameId, "TPE1") == 0 && frameSize > 1) {
id3ExtractText(file, dataStart, frameSize, author, M4B_MAX_AUTHOR);
foundArtist = (author[0] != '\0');
}
// --- APIC (Attached Picture) ---
if (!foundCover && strcmp(frameId, "APIC") == 0 && frameSize > 20) {
id3ExtractAPIC(file, dataStart, frameSize);
foundCover = hasCoverArt;
}
pos = dataStart + frameSize;
// Early exit once we have everything
if (foundTitle && foundArtist && foundCover) break;
}
if (foundTitle) Serial.printf("ID3: Title: %s\n", title);
if (foundArtist) Serial.printf("ID3: Author: %s\n", author);
return (foundTitle || foundCover);
}
private:
// Extract text from a TIT2/TPE1 frame.
// Format: encoding(1) + text data
void id3ExtractText(File& file, uint32_t offset, uint32_t size,
char* dest, int maxLen) {
file.seek(offset);
uint8_t encoding = file.read();
uint32_t textLen = size - 1;
if (textLen == 0) return;
if (encoding == 0 || encoding == 3) {
// ISO-8859-1 or UTF-8 — read directly
uint32_t readLen = (textLen < (uint32_t)(maxLen - 1))
? textLen : (uint32_t)(maxLen - 1);
file.read((uint8_t*)dest, readLen);
dest[readLen] = '\0';
// Strip trailing nulls
while (readLen > 0 && dest[readLen - 1] == '\0') readLen--;
dest[readLen] = '\0';
}
else if (encoding == 1 || encoding == 2) {
// UTF-16 (with or without BOM) — crude ASCII extraction
// Static buffer to avoid stack overflow (loopTask has limited stack)
static uint8_t u16buf[128];
uint32_t readLen = (textLen > sizeof(u16buf)) ? sizeof(u16buf) : textLen;
file.read(u16buf, readLen);
uint32_t srcStart = 0;
// Skip BOM if present
if (readLen >= 2 && ((u16buf[0] == 0xFF && u16buf[1] == 0xFE) ||
(u16buf[0] == 0xFE && u16buf[1] == 0xFF))) {
srcStart = 2;
}
bool littleEndian = (srcStart >= 2 && u16buf[0] == 0xFF);
int dstIdx = 0;
for (uint32_t i = srcStart; i + 1 < readLen && dstIdx < maxLen - 1; i += 2) {
uint8_t lo = littleEndian ? u16buf[i] : u16buf[i + 1];
uint8_t hi = littleEndian ? u16buf[i + 1] : u16buf[i];
if (lo == 0 && hi == 0) break; // null terminator
if (hi == 0 && lo >= 0x20 && lo < 0x7F) {
dest[dstIdx++] = (char)lo;
} else {
dest[dstIdx++] = '?';
}
}
dest[dstIdx] = '\0';
}
}
// Extract APIC (cover art) frame.
// Format: encoding(1) + MIME(null-term) + picType(1) + desc(null-term) + imageData
void id3ExtractAPIC(File& file, uint32_t offset, uint32_t frameSize) {
file.seek(offset);
uint8_t encoding = file.read();
// Read MIME type (null-terminated ASCII)
char mime[32] = {0};
int mimeLen = 0;
while (mimeLen < 31) {
int b = file.read();
if (b < 0) return; // Read error
if (b == 0) break; // Null terminator = end of MIME string
mime[mimeLen++] = (char)b;
}
mime[mimeLen] = '\0';
// Picture type (1 byte)
uint8_t picType = file.read();
(void)picType;
// Skip description (null-terminated, encoding-dependent)
if (encoding == 0 || encoding == 3) {
// Single-byte null terminator
while (true) {
int b = file.read();
if (b < 0) return; // Read error
if (b == 0) break; // Null terminator
}
} else {
// UTF-16: double-null terminator
while (true) {
int b1 = file.read();
int b2 = file.read();
if (b1 < 0 || b2 < 0) return; // Read error
if (b1 == 0 && b2 == 0) break; // Double-null terminator
}
}
// Everything from here to end of frame is image data
uint32_t imgOffset = file.position();
uint32_t imgEnd = offset + frameSize;
if (imgOffset >= imgEnd) return;
uint32_t imgSize = imgEnd - imgOffset;
// Determine format from MIME type
bool isJpeg = (strstr(mime, "jpeg") || strstr(mime, "jpg"));
bool isPng = (strstr(mime, "png") != nullptr);
// Also detect by magic bytes if MIME is generic
if (!isJpeg && !isPng && imgSize > 4) {
file.seek(imgOffset);
uint8_t magic[4];
file.read(magic, 4);
if (magic[0] == 0xFF && magic[1] == 0xD8) isJpeg = true;
else if (magic[0] == 0x89 && magic[1] == 'P' &&
magic[2] == 'N' && magic[3] == 'G') isPng = true;
}
coverOffset = imgOffset;
coverSize = imgSize;
coverFormat = isJpeg ? 13 : (isPng ? 14 : 0);
hasCoverArt = (imgSize > 100 && (isJpeg || isPng));
if (hasCoverArt) {
Serial.printf("ID3: Cover %s, %u bytes\n",
isJpeg ? "JPEG" : "PNG", imgSize);
}
}
// Parse Nero-style chapter list (chpl atom).
void parseChpl(File& file, uint32_t offset, uint32_t size) {
if (size < 9) return;
file.seek(offset);
uint8_t version = file.read();
file.read(); // flags byte 1
file.read(); // flags byte 2
file.read(); // flags byte 3
file.read(); // reserved
uint32_t count;
if (version == 1) {
count = readU32BE(file);
} else {
count = file.read();
}
if (count > M4B_MAX_CHAPTERS) count = M4B_MAX_CHAPTERS;
chapterCount = 0;
for (uint32_t i = 0; i < count; i++) {
if (!file.available()) break;
uint64_t timestamp = readU64BE(file);
uint32_t startMs = (uint32_t)(timestamp / 10000); // 100ns -> ms
uint8_t nameLen = file.read();
if (nameLen == 0 || !file.available()) break;
M4BChapter& ch = chapters[chapterCount];
ch.startMs = startMs;
uint8_t readLen = (nameLen < sizeof(ch.name) - 1) ? nameLen : sizeof(ch.name) - 1;
file.read((uint8_t*)ch.name, readLen);
ch.name[readLen] = '\0';
if (nameLen > readLen) {
file.seek(file.position() + (nameLen - readLen));
}
chapterCount++;
}
Serial.printf("M4B: Found %d chapters\n", chapterCount);
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,255 @@
#pragma once
// =============================================================================
// ModemManager - A7682E 4G Modem Driver for T-Deck Pro (V1.1 4G variant)
//
// Runs AT commands on a dedicated FreeRTOS task (Core 0, priority 1) to never
// block the mesh radio loop. Communicates with main loop via lock-free queues.
//
// Supports: SMS send/receive, voice call dial/answer/hangup/DTMF
//
// Guard: HAS_4G_MODEM (defined only for the 4G build environment)
// =============================================================================
#ifdef HAS_4G_MODEM
#ifndef MODEM_MANAGER_H
#define MODEM_MANAGER_H
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
#include <freertos/semphr.h>
#include "variant.h"
#include "ApnDatabase.h"
// ---------------------------------------------------------------------------
// Modem pins (from variant.h, always defined for reference)
// MODEM_POWER_EN 41 Board 6609 enable
// MODEM_PWRKEY 40 Power key toggle
// MODEM_RST 9 Reset (shared with I2S BCLK on audio board)
// MODEM_RI 7 Ring indicator (shared with I2S DOUT on audio)
// MODEM_DTR 8 Data terminal ready (shared with I2S LRC on audio)
// MODEM_RX 10 UART RX (shared with PIN_PERF_POWERON)
// MODEM_TX 11 UART TX
// ---------------------------------------------------------------------------
// SMS field limits
#define SMS_PHONE_LEN 20
#define SMS_BODY_LEN 161 // 160 chars + null
// Task configuration
#define MODEM_TASK_PRIORITY 1 // Below mesh (default loop = priority 1 on core 1)
#define MODEM_TASK_STACK_SIZE 6144 // Increased for call handling
#define MODEM_TASK_CORE 0 // Run on core 0 (mesh runs on core 1)
// Queue sizes
#define MODEM_SEND_QUEUE_SIZE 4
#define MODEM_RECV_QUEUE_SIZE 8
#define MODEM_CALL_CMD_QUEUE_SIZE 4
#define MODEM_CALL_EVT_QUEUE_SIZE 4
// ---------------------------------------------------------------------------
// Modem state machine
// ---------------------------------------------------------------------------
enum class ModemState {
OFF,
POWERING_ON,
INITIALIZING,
REGISTERING,
READY,
ERROR,
SENDING_SMS,
// Voice call states
DIALING, // ATD sent, waiting for connect/carrier
RINGING_IN, // Incoming call detected (RING URC)
IN_CALL // Voice call active
};
// ---------------------------------------------------------------------------
// SMS structures (unchanged)
// ---------------------------------------------------------------------------
// Outgoing SMS (queued from main loop to modem task)
struct SMSOutgoing {
char phone[SMS_PHONE_LEN];
char body[SMS_BODY_LEN];
};
// Incoming SMS (queued from modem task to main loop)
struct SMSIncoming {
char phone[SMS_PHONE_LEN];
char body[SMS_BODY_LEN];
uint32_t timestamp; // epoch seconds (from modem RTC or millis-based)
};
// ---------------------------------------------------------------------------
// Voice call structures
// ---------------------------------------------------------------------------
// Commands from main loop → modem task
enum class CallCmd : uint8_t {
DIAL, // Initiate outgoing call
ANSWER, // Answer incoming call
HANGUP, // End active call or reject incoming
DTMF, // Send DTMF tone during call
SET_VOLUME // Set speaker volume
};
struct CallCommand {
CallCmd cmd;
char phone[SMS_PHONE_LEN]; // Used by DIAL
char dtmf; // Used by DTMF (single digit: 0-9, *, #)
uint8_t volume; // Used by SET_VOLUME (0-5)
};
// Events from modem task → main loop
enum class CallEventType : uint8_t {
INCOMING, // Incoming call ringing (+CLIP parsed)
CONNECTED, // Call answered / outgoing connected
ENDED, // Call ended (local hangup, remote hangup, or no carrier)
MISSED, // Incoming call ended before answer
BUSY, // Outgoing call got busy signal
NO_ANSWER, // Outgoing call not answered
DIAL_FAILED // ATD command failed
};
struct CallEvent {
CallEventType type;
char phone[SMS_PHONE_LEN]; // Caller/callee number (from +CLIP or dial)
uint32_t duration; // Call duration in seconds (for ENDED)
};
// ---------------------------------------------------------------------------
// ModemManager class
// ---------------------------------------------------------------------------
class ModemManager {
public:
void begin();
void shutdown();
// --- SMS API (unchanged) ---
bool sendSMS(const char* phone, const char* body);
bool recvSMS(SMSIncoming& out);
// --- Voice Call API ---
bool dialCall(const char* phone); // Queue outgoing call
bool answerCall(); // Answer incoming call
bool hangupCall(); // End active / reject incoming
bool sendDTMF(char digit); // Send DTMF during call
bool setCallVolume(uint8_t level); // Set volume 0-5
bool pollCallEvent(CallEvent& out); // Poll from main loop
// --- State queries (lock-free reads) ---
ModemState getState() const { return _state; }
int getSignalBars() const; // 0-5
int getCSQ() const { return _csq; }
bool isReady() const { return _state == ModemState::READY; }
bool isInCall() const { return _state == ModemState::IN_CALL; }
bool isRinging() const { return _state == ModemState::RINGING_IN; }
bool isDialing() const { return _state == ModemState::DIALING; }
bool isCallActive() const {
return _state == ModemState::IN_CALL ||
_state == ModemState::DIALING ||
_state == ModemState::RINGING_IN;
}
const char* getOperator() const { return _operator; }
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)
static bool loadEnabledConfig();
static void saveEnabledConfig(bool enabled);
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
TaskHandle_t _taskHandle = nullptr;
// SMS queues
QueueHandle_t _sendQueue = nullptr;
QueueHandle_t _recvQueue = nullptr;
// Call queues
QueueHandle_t _callCmdQueue = nullptr; // main loop → modem task
QueueHandle_t _callEvtQueue = nullptr; // modem task → main loop
SemaphoreHandle_t _uartMutex = nullptr;
// URC line buffer (accumulated between AT commands)
static const int URC_BUF_SIZE = 256;
char _urcBuf[URC_BUF_SIZE];
int _urcPos = 0;
// UART AT command helpers (called only from modem task)
bool modemPowerOn();
bool sendAT(const char* cmd, const char* expect, uint32_t timeout_ms = 2000);
bool waitResponse(const char* expect, uint32_t timeout_ms, char* buf = nullptr, size_t bufLen = 0);
void pollCSQ();
void pollIncomingSMS();
bool doSendSMS(const char* phone, const char* body);
// URC (unsolicited result code) handling
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();
bool doHangup();
bool doSendDTMF(char digit);
bool doSetVolume(uint8_t level);
void queueCallEvent(CallEventType type, const char* phone = nullptr, uint32_t duration = 0);
// FreeRTOS task
static void taskEntry(void* param);
void taskLoop();
};
// Global singleton
extern ModemManager modemManager;
#endif // MODEM_MANAGER_H
#endif // HAS_4G_MODEM

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
#ifdef HAS_4G_MODEM
#include "SMSContacts.h"
// Global singleton
SMSContactStore smsContacts;
#endif // HAS_4G_MODEM

View File

@@ -0,0 +1,176 @@
#pragma once
// =============================================================================
// SMSContacts - Phone-to-name lookup for SMS contacts (4G variant)
//
// Stores contacts in /sms/contacts.txt on SD card.
// Format: one contact per line as "phone=Display Name"
//
// Completely separate from mesh ContactInfo / IdentityStore.
//
// Guard: HAS_4G_MODEM
// =============================================================================
#ifdef HAS_4G_MODEM
#ifndef SMS_CONTACTS_H
#define SMS_CONTACTS_H
#include <Arduino.h>
#include <SD.h>
#define SMS_CONTACT_NAME_LEN 24
#define SMS_CONTACT_MAX 30
#define SMS_CONTACTS_FILE "/sms/contacts.txt"
struct SMSContact {
char phone[20]; // matches SMS_PHONE_LEN
char name[SMS_CONTACT_NAME_LEN];
bool valid;
};
class SMSContactStore {
public:
void begin() {
_count = 0;
memset(_contacts, 0, sizeof(_contacts));
load();
}
// Look up a name by phone number. Returns nullptr if not found.
const char* lookup(const char* phone) const {
for (int i = 0; i < _count; i++) {
if (_contacts[i].valid && strcmp(_contacts[i].phone, phone) == 0) {
return _contacts[i].name;
}
}
return nullptr;
}
// Fill buf with display name if found, otherwise copy phone number.
// Returns true if a name was found.
bool displayName(const char* phone, char* buf, size_t bufLen) const {
const char* name = lookup(phone);
if (name && name[0]) {
strncpy(buf, name, bufLen - 1);
buf[bufLen - 1] = '\0';
return true;
}
strncpy(buf, phone, bufLen - 1);
buf[bufLen - 1] = '\0';
return false;
}
// Add or update a contact. Returns true on success.
bool set(const char* phone, const char* name) {
// Update existing
for (int i = 0; i < _count; i++) {
if (_contacts[i].valid && strcmp(_contacts[i].phone, phone) == 0) {
strncpy(_contacts[i].name, name, SMS_CONTACT_NAME_LEN - 1);
_contacts[i].name[SMS_CONTACT_NAME_LEN - 1] = '\0';
save();
return true;
}
}
// Add new
if (_count >= SMS_CONTACT_MAX) return false;
strncpy(_contacts[_count].phone, phone, sizeof(_contacts[_count].phone) - 1);
_contacts[_count].phone[sizeof(_contacts[_count].phone) - 1] = '\0';
strncpy(_contacts[_count].name, name, SMS_CONTACT_NAME_LEN - 1);
_contacts[_count].name[SMS_CONTACT_NAME_LEN - 1] = '\0';
_contacts[_count].valid = true;
_count++;
save();
return true;
}
// Remove a contact by phone number
bool remove(const char* phone) {
for (int i = 0; i < _count; i++) {
if (_contacts[i].valid && strcmp(_contacts[i].phone, phone) == 0) {
for (int j = i; j < _count - 1; j++) {
_contacts[j] = _contacts[j + 1];
}
_count--;
memset(&_contacts[_count], 0, sizeof(SMSContact));
save();
return true;
}
}
return false;
}
// Accessors for list browsing
int count() const { return _count; }
const SMSContact& get(int index) const { return _contacts[index]; }
// Check if a contact exists
bool exists(const char* phone) const { return lookup(phone) != nullptr; }
private:
SMSContact _contacts[SMS_CONTACT_MAX];
int _count = 0;
void load() {
File f = SD.open(SMS_CONTACTS_FILE, FILE_READ);
if (!f) {
Serial.println("[SMSContacts] No contacts file, starting fresh");
return;
}
char line[64];
while (f.available() && _count < SMS_CONTACT_MAX) {
int pos = 0;
while (f.available() && pos < (int)sizeof(line) - 1) {
char c = f.read();
if (c == '\n' || c == '\r') break;
line[pos++] = c;
}
line[pos] = '\0';
if (pos == 0) continue;
// Consume trailing CR/LF
while (f.available()) {
int pk = f.peek();
if (pk == '\n' || pk == '\r') { f.read(); continue; }
break;
}
// Parse "phone=name"
char* eq = strchr(line, '=');
if (!eq) continue;
*eq = '\0';
const char* phone = line;
const char* name = eq + 1;
if (strlen(phone) == 0 || strlen(name) == 0) continue;
strncpy(_contacts[_count].phone, phone, sizeof(_contacts[_count].phone) - 1);
strncpy(_contacts[_count].name, name, SMS_CONTACT_NAME_LEN - 1);
_contacts[_count].valid = true;
_count++;
}
f.close();
Serial.printf("[SMSContacts] Loaded %d contacts\n", _count);
}
void save() {
if (!SD.exists("/sms")) SD.mkdir("/sms");
File f = SD.open(SMS_CONTACTS_FILE, FILE_WRITE);
if (!f) {
Serial.println("[SMSContacts] Failed to write contacts file");
return;
}
for (int i = 0; i < _count; i++) {
if (!_contacts[i].valid) continue;
f.print(_contacts[i].phone);
f.print('=');
f.println(_contacts[i].name);
}
f.close();
}
};
// Global singleton
extern SMSContactStore smsContacts;
#endif // SMS_CONTACTS_H
#endif // HAS_4G_MODEM

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,196 @@
#ifdef HAS_4G_MODEM
#include "SMSStore.h"
#include <Mesh.h> // For MESH_DEBUG_PRINTLN
#include "target.h" // For SDCARD_CS macro
// Global singleton
SMSStore smsStore;
void SMSStore::begin() {
// Ensure SMS directory exists
if (!SD.exists(SMS_DIR)) {
SD.mkdir(SMS_DIR);
MESH_DEBUG_PRINTLN("[SMSStore] created %s", SMS_DIR);
}
_ready = true;
MESH_DEBUG_PRINTLN("[SMSStore] ready");
}
void SMSStore::phoneToFilename(const char* phone, char* out, size_t outLen) {
// Convert phone number to safe filename: strip non-alphanumeric, prefix with dir
// e.g. "+1234567890" -> "/sms/p1234567890.sms"
char safe[SMS_PHONE_LEN];
int j = 0;
for (int i = 0; phone[i] && j < SMS_PHONE_LEN - 1; i++) {
char c = phone[i];
if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
safe[j++] = c;
}
}
safe[j] = '\0';
snprintf(out, outLen, "%s/p%s.sms", SMS_DIR, safe);
}
bool SMSStore::saveMessage(const char* phone, const char* body, bool isSent, uint32_t timestamp) {
if (!_ready) return false;
char filepath[64];
phoneToFilename(phone, filepath, sizeof(filepath));
// Build record
SMSRecord rec;
memset(&rec, 0, sizeof(rec));
rec.timestamp = timestamp;
rec.isSent = isSent ? 1 : 0;
rec.bodyLen = strlen(body);
if (rec.bodyLen >= SMS_BODY_LEN) rec.bodyLen = SMS_BODY_LEN - 1;
strncpy(rec.phone, phone, SMS_PHONE_LEN - 1);
strncpy(rec.body, body, SMS_BODY_LEN - 1);
// Append to file
File f = SD.open(filepath, FILE_APPEND);
if (!f) {
// Try creating
f = SD.open(filepath, FILE_WRITE);
if (!f) {
MESH_DEBUG_PRINTLN("[SMSStore] can't open %s", filepath);
return false;
}
}
size_t written = f.write((uint8_t*)&rec, sizeof(rec));
f.close();
// Release SD CS
digitalWrite(SDCARD_CS, HIGH);
return written == sizeof(rec);
}
int SMSStore::loadConversations(SMSConversation* out, int maxCount) {
if (!_ready) return 0;
File dir = SD.open(SMS_DIR);
if (!dir || !dir.isDirectory()) return 0;
int count = 0;
File entry;
while ((entry = dir.openNextFile()) && count < maxCount) {
const char* name = entry.name();
// Only process .sms files
if (!strstr(name, ".sms")) { entry.close(); continue; }
size_t fileSize = entry.size();
if (fileSize < sizeof(SMSRecord)) { entry.close(); continue; }
int numRecords = fileSize / sizeof(SMSRecord);
// Read the last record for preview
SMSRecord lastRec;
entry.seek(fileSize - sizeof(SMSRecord));
if (entry.read((uint8_t*)&lastRec, sizeof(SMSRecord)) != sizeof(SMSRecord)) {
entry.close();
continue;
}
SMSConversation& conv = out[count];
memset(&conv, 0, sizeof(SMSConversation));
strncpy(conv.phone, lastRec.phone, SMS_PHONE_LEN - 1);
strncpy(conv.preview, lastRec.body, 39);
conv.preview[39] = '\0';
conv.lastTimestamp = lastRec.timestamp;
conv.messageCount = numRecords;
conv.unreadCount = 0; // TODO: track read state
conv.valid = true;
count++;
entry.close();
}
dir.close();
// Release SD CS
digitalWrite(SDCARD_CS, HIGH);
// Sort by most recent (simple bubble sort, small N)
for (int i = 0; i < count - 1; i++) {
for (int j = 0; j < count - 1 - i; j++) {
if (out[j].lastTimestamp < out[j + 1].lastTimestamp) {
SMSConversation tmp = out[j];
out[j] = out[j + 1];
out[j + 1] = tmp;
}
}
}
return count;
}
int SMSStore::loadMessages(const char* phone, SMSMessage* out, int maxCount) {
if (!_ready) return 0;
char filepath[64];
phoneToFilename(phone, filepath, sizeof(filepath));
File f = SD.open(filepath, FILE_READ);
if (!f) return 0;
size_t fileSize = f.size();
int numRecords = fileSize / sizeof(SMSRecord);
// Load from end of file (most recent N messages), in chronological order
int startIdx = numRecords > maxCount ? numRecords - maxCount : 0;
// Read chronologically (oldest first) for chat-style display
SMSRecord rec;
int outIdx = 0;
for (int i = startIdx; i < numRecords && outIdx < maxCount; i++) {
f.seek(i * sizeof(SMSRecord));
if (f.read((uint8_t*)&rec, sizeof(SMSRecord)) != sizeof(SMSRecord)) continue;
out[outIdx].timestamp = rec.timestamp;
out[outIdx].isSent = rec.isSent != 0;
out[outIdx].valid = true;
strncpy(out[outIdx].phone, rec.phone, SMS_PHONE_LEN - 1);
strncpy(out[outIdx].body, rec.body, SMS_BODY_LEN - 1);
outIdx++;
}
f.close();
digitalWrite(SDCARD_CS, HIGH);
return outIdx;
}
bool SMSStore::deleteConversation(const char* phone) {
if (!_ready) return false;
char filepath[64];
phoneToFilename(phone, filepath, sizeof(filepath));
bool ok = SD.remove(filepath);
digitalWrite(SDCARD_CS, HIGH);
return ok;
}
int SMSStore::getMessageCount(const char* phone) {
if (!_ready) return 0;
char filepath[64];
phoneToFilename(phone, filepath, sizeof(filepath));
File f = SD.open(filepath, FILE_READ);
if (!f) return 0;
int count = f.size() / sizeof(SMSRecord);
f.close();
digitalWrite(SDCARD_CS, HIGH);
return count;
}
#endif // HAS_4G_MODEM

View File

@@ -0,0 +1,87 @@
#pragma once
// =============================================================================
// SMSStore - SD card backed SMS message storage
//
// Stores sent and received messages in /sms/ on the SD card.
// Each conversation is a separate file named by phone number (sanitised).
// Messages are appended as fixed-size records for simple random access.
//
// Guard: HAS_4G_MODEM
// =============================================================================
#ifdef HAS_4G_MODEM
#ifndef SMS_STORE_H
#define SMS_STORE_H
#include <Arduino.h>
#include <SD.h>
#define SMS_PHONE_LEN 20
#define SMS_BODY_LEN 161
#define SMS_MAX_CONVERSATIONS 20
#define SMS_DIR "/sms"
// Fixed-size on-disk record (256 bytes, easy alignment)
struct SMSRecord {
uint32_t timestamp; // epoch seconds
uint8_t isSent; // 1=sent, 0=received
uint8_t reserved[2];
uint8_t bodyLen; // actual length of body
char phone[SMS_PHONE_LEN]; // 20
char body[SMS_BODY_LEN]; // 161
uint8_t padding[256 - 4 - 3 - 1 - SMS_PHONE_LEN - SMS_BODY_LEN];
};
// In-memory message for UI
struct SMSMessage {
uint32_t timestamp;
bool isSent;
bool valid;
char phone[SMS_PHONE_LEN];
char body[SMS_BODY_LEN];
};
// Conversation summary for inbox view
struct SMSConversation {
char phone[SMS_PHONE_LEN];
char preview[40]; // last message preview
uint32_t lastTimestamp;
int messageCount;
int unreadCount;
bool valid;
};
class SMSStore {
public:
void begin();
bool isReady() const { return _ready; }
// Save a message (sent or received)
bool saveMessage(const char* phone, const char* body, bool isSent, uint32_t timestamp);
// Load conversation list (sorted by most recent)
int loadConversations(SMSConversation* out, int maxCount);
// Load messages for a specific phone number (chronological, oldest first)
int loadMessages(const char* phone, SMSMessage* out, int maxCount);
// Delete all messages for a phone number
bool deleteConversation(const char* phone);
// Get total message count for a phone number
int getMessageCount(const char* phone);
private:
bool _ready = false;
// Convert phone number to safe filename
void phoneToFilename(const char* phone, char* out, size_t outLen);
};
// Global singleton
extern SMSStore smsStore;
#endif // SMS_STORE_H
#endif // HAS_4G_MODEM

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

@@ -22,6 +22,14 @@
#include "../AbstractUITask.h"
#include "../NodePrefs.h"
#ifdef HAS_4G_MODEM
#include "SMSScreen.h"
#endif
#ifdef MECK_WEB_READER
#include "WebReaderScreen.h"
#endif
class UITask : public AbstractUITask {
DisplayDriver* _display;
SensorManager* _sensors;
@@ -32,6 +40,7 @@ class UITask : public AbstractUITask {
GenericVibration vibration;
#endif
unsigned long _next_refresh, _auto_off;
unsigned long _kb_flash_off_at; // Keyboard flash turn-off timer
NodePrefs* _node_prefs;
char _alert[80];
unsigned long _alert_expiry;
@@ -51,6 +60,19 @@ class UITask : public AbstractUITask {
UIScreen* splash;
UIScreen* home;
UIScreen* msg_preview;
UIScreen* channel_screen; // Channel message history screen
UIScreen* contacts_screen; // Contacts list screen
UIScreen* text_reader; // *** NEW: Text reader screen ***
UIScreen* notes_screen; // Notes editor screen
UIScreen* settings_screen; // Settings/onboarding screen
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
#ifdef HAS_4G_MODEM
UIScreen* sms_screen; // SMS messaging screen (4G variant only)
#endif
UIScreen* repeater_admin; // Repeater admin screen
#ifdef MECK_WEB_READER
UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required)
#endif
UIScreen* curr;
void userLedHandler();
@@ -67,27 +89,101 @@ 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;
ui_started_at = 0;
curr = NULL;
}
void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs);
void gotoHomeScreen() { setCurrScreen(home); }
void showAlert(const char* text, int duration_millis);
void gotoHomeScreen();
void gotoChannelScreen(); // Navigate to channel message screen
void gotoContactsScreen(); // Navigate to contacts list
void gotoTextReader(); // *** NEW: Navigate to text reader ***
void gotoNotesScreen(); // Navigate to notes editor
void gotoSettingsScreen(); // Navigate to settings
void gotoOnboarding(); // Navigate to settings in onboarding mode
void gotoAudiobookPlayer(); // Navigate to audiobook player
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
#ifdef MECK_WEB_READER
void gotoWebReader(); // Navigate to web reader (browser)
#endif
#ifdef HAS_4G_MODEM
void gotoSMSScreen();
bool isOnSMSScreen() const { return curr == sms_screen; }
SMSScreen* getSMSScreen() const { return (SMSScreen*)sms_screen; }
#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 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; }
#ifdef MECK_WEB_READER
bool isOnWebReader() const { return curr == web_reader; }
#endif
#ifdef MECK_AUDIO_VARIANT
// Check if audio is playing/paused in the background (for status indicators)
bool isAudioPlayingInBackground() const;
bool isAudioPausedInBackground() const;
#endif
uint8_t getChannelScreenViewIdx() const;
void toggleBuzzer();
bool getGPSState();
void toggleGPS();
// Check if home screen is in an editing mode (e.g. UTC offset editor)
bool isEditingHomeScreen() const;
// Inject a key press from external source (e.g., keyboard)
void injectKey(char c);
// 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; }
UIScreen* getMsgPreviewScreen() const { return msg_preview; }
UIScreen* getTextReaderScreen() const { return text_reader; } // *** NEW ***
UIScreen* getNotesScreen() const { return notes_screen; }
UIScreen* getContactsScreen() const { return contacts_screen; }
UIScreen* getChannelScreen() const { return channel_screen; }
UIScreen* getSettingsScreen() const { return settings_screen; }
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
#ifdef MECK_WEB_READER
UIScreen* getWebReaderScreen() const { return web_reader; }
#endif
// from AbstractUITask
void msgRead(int msgcount) override;
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override;
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
const uint8_t* path = nullptr) override;
void notify(UIEventType t = UIEventType::none) override;
void loop() override;
void shutdown(bool restart = false);
};
};

View File

@@ -0,0 +1,152 @@
#pragma once
// =============================================================================
// Utf8CP437.h - UTF-8 decoding and Unicode-to-CP437 mapping
//
// The Adafruit GFX built-in 6x8 font uses the CP437 character set for codes
// 128-255. This header provides utilities to:
// 1. Decode UTF-8 multi-byte sequences into Unicode codepoints
// 2. Map Unicode codepoints to CP437 byte values for display
//
// Used by both EpubProcessor (at XHTML→text conversion time) and
// TextReaderScreen (at render time for plain .txt files).
// =============================================================================
// Map a Unicode codepoint to its CP437 equivalent byte.
// Returns the CP437 byte (0x80-0xFF) for supported accented characters,
// the codepoint itself for ASCII (0x20-0x7E), or 0 if unmappable.
inline uint8_t unicodeToCP437(uint32_t cp) {
// ASCII passthrough
if (cp >= 0x20 && cp < 0x7F) return (uint8_t)cp;
switch (cp) {
// Uppercase accented
case 0x00C7: return 0x80; // Ç
case 0x00C9: return 0x90; // É
case 0x00C4: return 0x8E; // Ä
case 0x00C5: return 0x8F; // Å
case 0x00C6: return 0x92; // Æ
case 0x00D6: return 0x99; // Ö
case 0x00DC: return 0x9A; // Ü
case 0x00D1: return 0xA5; // Ñ
// Lowercase accented
case 0x00E9: return 0x82; // é
case 0x00E2: return 0x83; // â
case 0x00E4: return 0x84; // ä
case 0x00E0: return 0x85; // à
case 0x00E5: return 0x86; // å
case 0x00E7: return 0x87; // ç
case 0x00EA: return 0x88; // ê
case 0x00EB: return 0x89; // ë
case 0x00E8: return 0x8A; // è
case 0x00EF: return 0x8B; // ï
case 0x00EE: return 0x8C; // î
case 0x00EC: return 0x8D; // ì
case 0x00E6: return 0x91; // æ
case 0x00F4: return 0x93; // ô
case 0x00F6: return 0x94; // ö
case 0x00F2: return 0x95; // ò
case 0x00FB: return 0x96; // û
case 0x00F9: return 0x97; // ù
case 0x00FF: return 0x98; // ÿ
case 0x00FC: return 0x81; // ü
case 0x00E1: return 0xA0; // á
case 0x00ED: return 0xA1; // í
case 0x00F3: return 0xA2; // ó
case 0x00FA: return 0xA3; // ú
case 0x00F1: return 0xA4; // ñ
// Currency / symbols
case 0x00A2: return 0x9B; // ¢
case 0x00A3: return 0x9C; // £
case 0x00A5: return 0x9D; // ¥
case 0x00BF: return 0xA8; // ¿
case 0x00A1: return 0xAD; // ¡
case 0x00AB: return 0xAE; // «
case 0x00BB: return 0xAF; // »
case 0x00B0: return 0xF8; // °
case 0x00B1: return 0xF1; // ±
case 0x00B5: return 0xE6; // µ
case 0x00DF: return 0xE1; // ß
// Typographic (smart quotes, dashes, etc.)
case 0x2018: case 0x2019: return '\''; // Smart single quotes
case 0x201C: case 0x201D: return '"'; // Smart double quotes
case 0x2013: case 0x2014: return '-'; // En/em dash
case 0x2010: case 0x2011: case 0x2012: case 0x2015: return '-'; // Hyphens/bars
case 0x2026: return 0xFD; // Ellipsis (CP437 has no …, use ²? no, skip)
case 0x2022: return 0x07; // Bullet → CP437 bullet
case 0x00A0: return ' '; // Non-breaking space
case 0x2039: case 0x203A: return '\''; // Single guillemets
case 0x2032: return '\''; // Prime
case 0x2033: return '"'; // Double prime
default: return 0; // Unmappable
}
}
// Decode a single UTF-8 character from a byte buffer.
// Returns the Unicode codepoint and advances *pos past the full sequence.
// If the sequence is invalid, returns 0xFFFD (replacement char) and advances by 1.
//
// buf: input buffer
// bufLen: total buffer length
// pos: pointer to current position (updated on return)
inline uint32_t decodeUtf8Char(const char* buf, int bufLen, int* pos) {
int i = *pos;
if (i >= bufLen) return 0;
uint8_t c = (uint8_t)buf[i];
// ASCII (single byte)
if (c < 0x80) {
*pos = i + 1;
return c;
}
// Continuation byte without lead byte — skip
if (c < 0xC0) {
*pos = i + 1;
return 0xFFFD;
}
uint32_t codepoint;
int extraBytes;
if ((c & 0xE0) == 0xC0) {
codepoint = c & 0x1F;
extraBytes = 1;
} else if ((c & 0xF0) == 0xE0) {
codepoint = c & 0x0F;
extraBytes = 2;
} else if ((c & 0xF8) == 0xF0) {
codepoint = c & 0x07;
extraBytes = 3;
} else {
*pos = i + 1;
return 0xFFFD;
}
// Verify we have enough bytes and they're valid continuation bytes
if (i + extraBytes >= bufLen) {
*pos = i + 1;
return 0xFFFD;
}
for (int b = 1; b <= extraBytes; b++) {
uint8_t cb = (uint8_t)buf[i + b];
if ((cb & 0xC0) != 0x80) {
*pos = i + 1;
return 0xFFFD;
}
codepoint = (codepoint << 6) | (cb & 0x3F);
}
*pos = i + 1 + extraBytes;
return codepoint;
}
// Check if a byte is a UTF-8 continuation byte (10xxxxxx)
inline bool isUtf8Continuation(uint8_t c) {
return (c & 0xC0) == 0x80;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,216 @@
#pragma once
// Emoji Picker with scrolling grid and scroll bar
// 5 columns, 4 visible rows, scrollable through all 65 emoji
// WASD navigation, Enter to select, $/Q/Backspace to cancel
#include <helpers/ui/DisplayDriver.h>
#include "EmojiSprites.h"
#define EMOJI_PICKER_COLS 5
#define EMOJI_PICKER_VISIBLE_ROWS 4
#define EMOJI_PICKER_TOTAL_ROWS ((EMOJI_COUNT + EMOJI_PICKER_COLS - 1) / EMOJI_PICKER_COLS)
static const char* EMOJI_LABELS[EMOJI_COUNT] = {
"Lol", // 0 joy
"Like", // 1 thumbsup
"Sad", // 2 frown
"WiFi", // 3 wireless
"Inf", // 4 infinity
"Rex", // 5 trex
"Skul", // 6 skull
"Cros", // 7 cross
"Bolt", // 8 lightning
"Hat", // 9 tophat
"Moto", // 10 motorcycle
"Leaf", // 11 seedling
"AU", // 12 flag_au
"Umbr", // 13 umbrella
"Eye", // 14 nazar
"Glob", // 15 globe
"Rad", // 16 radioactive
"Cow", // 17 cow
"ET", // 18 alien
"Inv", // 19 invader
"Dagr", // 20 dagger
"Grim", // 21 grimace
"Mtn", // 22 mountain
"End", // 23 end_arrow
"Ring", // 24 hollow_circle
"Drag", // 25 dragon
"Web", // 26 globe_meridians
"Eggp", // 27 eggplant
"Shld", // 28 shield
"Gogl", // 29 goggles
"Lzrd", // 30 lizard
"Zany", // 31 zany_face
"Roo", // 32 kangaroo
"Fthr", // 33 feather
"Sun", // 34 bright
"Wave", // 35 part_alt
"Boat", // 36 motorboat
"Domi", // 37 domino
"Dish", // 38 satellite
"Pass", // 39 customs
"Cowb", // 40 cowboy
"Whl", // 41 wheel
"Koal", // 42 koala
"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 {
int cursor;
int scrollRow;
EmojiPicker() : cursor(0), scrollRow(0) {}
void reset() { cursor = 0; scrollRow = 0; }
void ensureVisible() {
int cursorRow = cursor / EMOJI_PICKER_COLS;
if (cursorRow < scrollRow) scrollRow = cursorRow;
else if (cursorRow >= scrollRow + EMOJI_PICKER_VISIBLE_ROWS)
scrollRow = cursorRow - EMOJI_PICKER_VISIBLE_ROWS + 1;
int maxScroll = EMOJI_PICKER_TOTAL_ROWS - EMOJI_PICKER_VISIBLE_ROWS;
if (maxScroll < 0) maxScroll = 0;
if (scrollRow > maxScroll) scrollRow = maxScroll;
if (scrollRow < 0) scrollRow = 0;
}
// Returns emoji escape byte, 0xFF for cancel, 0 for no action
uint8_t handleInput(char key) {
int row = cursor / EMOJI_PICKER_COLS;
int col = cursor % EMOJI_PICKER_COLS;
switch (key) {
case 'w': case 'W': case 0xF2:
if (row > 0) cursor -= EMOJI_PICKER_COLS;
break;
case 's': case 'S': case 0xF1:
if (cursor + EMOJI_PICKER_COLS < EMOJI_COUNT)
cursor += EMOJI_PICKER_COLS;
else if (row < EMOJI_PICKER_TOTAL_ROWS - 1)
cursor = EMOJI_COUNT - 1;
break;
case 'a': case 'A':
if (cursor > 0) cursor--;
break;
case 'd': case 'D':
if (cursor + 1 < EMOJI_COUNT) cursor++;
break;
case '\r':
ensureVisible();
return (uint8_t)(EMOJI_ESCAPE_START + cursor);
case '\b': case 'q': case 'Q': case KB_KEY_EMOJI:
return 0xFF;
default:
return 0;
}
ensureVisible();
return 0;
}
void draw(DisplayDriver& display) {
display.setTextSize(1);
display.setCursor(0, 0);
display.setColor(DisplayDriver::GREEN);
display.print("Select Emoji");
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
display.setTextSize(0);
int startY = 14;
int scrollBarW = 4;
int gridW = display.width() - scrollBarW - 1;
int cellW = gridW / EMOJI_PICKER_COLS;
int footerHeight = 14;
int gridH = display.height() - startY - footerHeight;
int cellH = gridH / EMOJI_PICKER_VISIBLE_ROWS;
for (int vr = 0; vr < EMOJI_PICKER_VISIBLE_ROWS; vr++) {
int absRow = scrollRow + vr;
if (absRow >= EMOJI_PICKER_TOTAL_ROWS) break;
for (int col = 0; col < EMOJI_PICKER_COLS; col++) {
int idx = absRow * EMOJI_PICKER_COLS + col;
if (idx >= EMOJI_COUNT) break;
int cx = col * cellW;
int cy = startY + vr * cellH;
if (idx == cursor) {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(cx, cy, cellW, cellH);
display.drawRect(cx + 1, cy + 1, cellW - 2, cellH - 2);
}
display.setColor(DisplayDriver::LIGHT);
const uint8_t* sprite = (const uint8_t*)pgm_read_ptr(&EMOJI_SPRITES_LG[idx]);
if (sprite) {
int spriteX = cx + (cellW - EMOJI_LG_W) / 2;
int spriteY = cy + 1;
display.drawXbm(spriteX, spriteY, sprite, EMOJI_LG_W, EMOJI_LG_H);
}
display.setColor(DisplayDriver::YELLOW);
uint16_t labelW = display.getTextWidth(EMOJI_LABELS[idx]);
int labelX = cx + (cellW - (int)labelW) / 2;
if (labelX < cx) labelX = cx;
display.setCursor(labelX, cy + 14);
display.print(EMOJI_LABELS[idx]);
}
}
// Scroll bar
int sbX = display.width() - scrollBarW;
display.setColor(DisplayDriver::LIGHT);
display.drawRect(sbX, startY, scrollBarW, gridH);
if (EMOJI_PICKER_TOTAL_ROWS > EMOJI_PICKER_VISIBLE_ROWS) {
int thumbH = (EMOJI_PICKER_VISIBLE_ROWS * gridH) / EMOJI_PICKER_TOTAL_ROWS;
if (thumbH < 4) thumbH = 4;
int maxScroll = EMOJI_PICKER_TOTAL_ROWS - EMOJI_PICKER_VISIBLE_ROWS;
int thumbY = startY + (scrollRow * (gridH - thumbH)) / maxScroll;
for (int y = thumbY + 1; y < thumbY + thumbH - 1; y++)
display.drawRect(sbX + 1, y, scrollBarW - 2, 1);
} else {
for (int y = startY + 1; y < startY + gridH - 1; y++)
display.drawRect(sbX + 1, y, scrollBarW - 2, 1);
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, footerY - 2, display.width(), 1);
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
display.print("WASD:Nav Ent:Pick");
const char* ct = "$:Back";
display.setCursor(display.width() - display.getTextWidth(ct) - 2, footerY);
display.print(ct);
}
};

View File

@@ -0,0 +1,16 @@
// WebReaderDeps.cpp
// -----------------------------------------------------------------------
// PlatformIO library dependency finder (LDF) hint file.
//
// The web reader's WiFi/HTTP includes live in WebReaderScreen.h (header-only),
// but PlatformIO's LDF can't always trace framework library dependencies
// through conditional #include chains in headers. This .cpp file exposes
// the includes at the top level where the scanner reliably finds them.
//
// No actual code here — just #include directives for the dependency finder.
// -----------------------------------------------------------------------
#ifdef MECK_WEB_READER
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#endif

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@ build_flags = -w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1
-D LORA_FREQ=869.525
-D LORA_BW=250
-D LORA_SF=11
-D ENABLE_ADVERT_ON_BOOT=1
-D ENABLE_PRIVATE_KEY_IMPORT=1 ; NOTE: comment these out for more secure firmware
-D ENABLE_PRIVATE_KEY_EXPORT=1
-D RADIOLIB_EXCLUDE_CC1101=1
@@ -58,6 +59,7 @@ platform = platformio/espressif32@6.11.0
monitor_filters = esp32_exception_decoder
extra_scripts = merge-bin.py
build_flags = ${arduino_base.build_flags}
-D ESP32_PLATFORM
; -D ESP32_CPU_FREQ=80 ; change it to your need
build_src_filter = ${arduino_base.build_src_filter}
@@ -67,10 +69,10 @@ lib_deps =
file://arch/esp32/AsyncElegantOTA
; esp32c6 uses arduino framework 3.x
; WARNING: experimental. pioarduino on esp32c6 needs work - it's not considered stable and has issues.
; WARNING: experimental. May not work as stable as other platforms.
[esp32c6_base]
extends = esp32_base
platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.12/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.13-1/platform-espressif32.zip
; ----------------- NRF52 ---------------------
@@ -79,7 +81,7 @@ extends = arduino_base
platform = nordicnrf52
platform_packages =
framework-arduinoadafruitnrf52 @ 1.10700.0
extra_scripts =
extra_scripts =
create-uf2.py
arch/nrf52/extra_scripts/patch_bluefruit.py
build_flags = ${arduino_base.build_flags}
@@ -147,4 +149,4 @@ lib_deps =
adafruit/Adafruit_VL53L0X @ ^1.2.4
stevemarple/MicroNMEA @ ^2.0.6
adafruit/Adafruit BME680 Library @ ^2.0.4
adafruit/Adafruit BMP085 Library @ ^1.2.4
adafruit/Adafruit BMP085 Library @ ^1.2.4

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
@@ -169,4 +184,4 @@ public:
int findChannelIdx(const mesh::GroupChannel& ch);
void loop();
};
};

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
) {
@@ -249,4 +257,4 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
bool SerialBLEInterface::isConnected() const {
return deviceConnected; //pServer != NULL && pServer->getConnectedCount() > 0;
}
}

View File

@@ -88,4 +88,4 @@ public:
#else
#define BLE_DEBUG_PRINT(...) {}
#define BLE_DEBUG_PRINTLN(...) {}
#endif
#endif

View File

@@ -84,6 +84,10 @@ void GxEPDDisplay::startFrame(Color bkg) {
void GxEPDDisplay::setTextSize(int sz) {
display_crc.update<int>(sz);
switch(sz) {
case 0: // Tiny - built-in 6x8 pixel font
display.setFont(NULL);
display.setTextSize(1);
break;
case 1: // Small - use 9pt (was 9pt)
display.setFont(&FreeSans9pt7b);
break;

View File

@@ -0,0 +1,70 @@
#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
//
// 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.
#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_BOOST_TIMEOUT_MS
#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds
#endif
class CPUPowerManager {
public:
CPUPowerManager() : _boosted(false), _boost_started(0) {}
void begin() {
setCpuFrequencyMhz(CPU_FREQ_IDLE);
_boosted = 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();
}
}
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);
}
}
bool isBoosted() const { return _boosted; }
uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); }
private:
bool _boosted;
unsigned long _boost_started;
};
#endif // ESP32

View File

@@ -0,0 +1,185 @@
#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

@@ -0,0 +1,72 @@
#pragma once
#include <Arduino.h>
// Transparent Stream wrapper that counts NMEA sentences (newline-delimited)
// flowing from the GPS serial port to the MicroNMEA parser.
//
// Usage: Instead of MicroNMEALocationProvider gps(Serial2, &rtc_clock);
// Use: GPSStreamCounter gpsStream(Serial2);
// MicroNMEALocationProvider gps(gpsStream, &rtc_clock);
//
// Every read() call passes through to the underlying stream; when a '\n'
// is seen the sentence counter increments. This lets the UI display a
// live "nmea" count so users can confirm the baud rate is correct and
// the GPS module is actually sending data.
class GPSStreamCounter : public Stream {
public:
GPSStreamCounter(Stream& inner)
: _inner(inner), _sentences(0), _sentences_snapshot(0),
_last_snapshot(0), _sentences_per_sec(0) {}
// --- Stream read interface (passes through) ---
int available() override { return _inner.available(); }
int peek() override { return _inner.peek(); }
int read() override {
int c = _inner.read();
if (c == '\n') {
_sentences++;
}
return c;
}
// --- Stream write interface (pass through for NMEA commands if needed) ---
size_t write(uint8_t b) override { return _inner.write(b); }
// --- Sentence counting API ---
// Total sentences received since boot (or last reset)
uint32_t getSentenceCount() const { return _sentences; }
// Sentences received per second (updated each time you call it,
// with a 1-second rolling window)
uint16_t getSentencesPerSec() {
unsigned long now = millis();
unsigned long elapsed = now - _last_snapshot;
if (elapsed >= 1000) {
uint32_t delta = _sentences - _sentences_snapshot;
// Scale to per-second if interval wasn't exactly 1000ms
_sentences_per_sec = (uint16_t)((delta * 1000UL) / elapsed);
_sentences_snapshot = _sentences;
_last_snapshot = now;
}
return _sentences_per_sec;
}
// Reset all counters (e.g. when GPS hardware power cycles)
void resetCounters() {
_sentences = 0;
_sentences_snapshot = 0;
_sentences_per_sec = 0;
_last_snapshot = millis();
}
private:
Stream& _inner;
volatile uint32_t _sentences;
uint32_t _sentences_snapshot;
unsigned long _last_snapshot;
uint16_t _sentences_per_sec;
};

View File

@@ -1,24 +1,25 @@
#include <Arduino.h>
#include "variant.h"
#include "TDeckBoard.h"
#include <Mesh.h> // For MESH_DEBUG_PRINTLN
uint32_t deviceOnline = 0x00;
void TDeckBoard::begin() {
Serial.println("TDeckBoard::begin() - starting");
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - starting");
// Enable peripheral power (keyboard, sensors, etc.) FIRST
// This powers the BQ27220 fuel gauge and other I2C devices
pinMode(PIN_PERF_POWERON, OUTPUT);
digitalWrite(PIN_PERF_POWERON, HIGH);
delay(50); // Allow peripherals to power up before I2C init
Serial.println("TDeckBoard::begin() - peripheral power enabled");
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - peripheral power enabled");
// Initialize I2C with correct pins for T-Deck Pro
Wire.begin(I2C_SDA, I2C_SCL);
Wire.setClock(100000); // 100kHz for reliable fuel gauge communication
Serial.println("TDeckBoard::begin() - I2C initialized");
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - I2C initialized");
// Now call parent class begin (after power and I2C are ready)
ESP32Board::begin();
@@ -28,7 +29,7 @@ void TDeckBoard::begin() {
pinMode(P_LORA_EN, OUTPUT);
digitalWrite(P_LORA_EN, HIGH);
delay(10); // Allow module to power up
Serial.println("TDeckBoard::begin() - LoRa power enabled");
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - LoRa power enabled");
#endif
// Enable GPS module power and initialize Serial2
@@ -37,14 +38,20 @@ void TDeckBoard::begin() {
pinMode(PIN_GPS_EN, OUTPUT);
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE); // GPS_EN_ACTIVE is 1 (HIGH)
delay(100); // Allow GPS to power up
Serial.println("TDeckBoard::begin() - GPS power enabled");
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - GPS power enabled");
#endif
// Initialize Serial2 for GPS with correct pins
Serial2.begin(GPS_BAUDRATE, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
Serial.print("TDeckBoard::begin() - GPS Serial2 initialized at ");
Serial.print(GPS_BAUDRATE);
Serial.println(" baud");
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - GPS Serial2 initialized at %d baud", GPS_BAUDRATE);
#endif
// Disable 4G modem power (only present on 4G version, not audio version)
// This turns off the red status LED on the modem module
#ifdef MODEM_POWER_EN
pinMode(MODEM_POWER_EN, OUTPUT);
digitalWrite(MODEM_POWER_EN, LOW); // Cut power to modem
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - 4G modem power disabled");
#endif
// Configure user button
@@ -65,15 +72,14 @@ void TDeckBoard::begin() {
rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1);
}
// Test BQ27220 communication
// Test BQ27220 communication and configure design capacity
#if HAS_BQ27220
uint16_t voltage = getBattMilliVolts();
Serial.print("TDeckBoard::begin() - Battery voltage: ");
Serial.print(voltage);
Serial.println(" mV");
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - Battery voltage: %d mV", voltage);
configureFuelGauge();
#endif
Serial.println("TDeckBoard::begin() - complete");
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - complete");
}
uint16_t TDeckBoard::getBattMilliVolts() {
@@ -81,13 +87,13 @@ uint16_t TDeckBoard::getBattMilliVolts() {
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(BQ27220_REG_VOLTAGE);
if (Wire.endTransmission(false) != 0) {
Serial.println("BQ27220: I2C error reading voltage");
MESH_DEBUG_PRINTLN("BQ27220: I2C error reading voltage");
return 0;
}
uint8_t count = Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2);
if (count != 2) {
Serial.println("BQ27220: Read error - wrong byte count");
MESH_DEBUG_PRINTLN("BQ27220: Read error - wrong byte count");
return 0;
}
@@ -118,4 +124,357 @@ uint8_t TDeckBoard::getBatteryPercent() {
#else
return 0;
#endif
}
// ---- BQ27220 extended register helpers ----
#if HAS_BQ27220
// Read a 16-bit register from BQ27220. Returns 0 on I2C error.
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;
}
// Read a single byte from BQ27220 register.
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();
}
// Write a 16-bit subcommand to BQ27220 Control register (0x00).
// Subcommands control unsealing, config mode, sealing, etc.
static bool bq27220_writeControl(uint16_t subcmd) {
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x00); // Control register
Wire.write(subcmd & 0xFF); // LSB first
Wire.write((subcmd >> 8) & 0xFF); // MSB
return Wire.endTransmission() == 0;
}
#endif
// ---- BQ27220 Design Capacity configuration ----
// The BQ27220 ships with a 3000 mAh default. The T-Deck Pro uses a 1400 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).
//
// Procedure follows TI TRM SLUUBD4A Section 6.1:
// 1. Unseal → 2. Full Access → 3. Enter CFG_UPDATE
// 4. Write Design Capacity via MAC → 5. Exit CFG_UPDATE → 6. Seal
bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
#if HAS_BQ27220
// Read current design capacity from standard command register
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) {
// Design Capacity correct, but check if Full Charge Capacity is sane.
// After a Design Capacity change, FCC may still hold the old factory
// value (e.g. 3000 mAh) until a RESET forces reinitialization.
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.
// The gauge derives FCC from Design Energy (not just Design Capacity).
// Design Energy = capacity × nominal voltage (3.7V for LiPo).
// If Design Energy still reflects 3000 mAh, FCC stays at 3000.
// Fix: enter CFG_UPDATE and write correct Design Energy.
Serial.printf("BQ27220: FCC %d >> DC %d, updating Design Energy\n",
fcc, designCapacity_mAh);
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
Serial.printf("BQ27220: Target Design Energy = %d mWh\n", designEnergy);
// 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 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) {
// Design Energy is at data memory address 0x92A1 (2 bytes after DC at 0x929F)
// Read old values for checksum calculation
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);
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=0x%02X%02X new=0x%02X%02X chk=0x%02X\n",
oldMSB, oldLSB, newMSB, newLSB, newChk);
// Write new Design Energy
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
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);
// Exit CFG_UPDATE with reinit
bq27220_writeControl(0x0091);
delay(200);
Serial.println("BQ27220: Design Energy updated, exited CFG_UPDATE");
} else {
Serial.println("BQ27220: Failed to enter CFG_UPDATE for DE fix");
bq27220_writeControl(0x0092); // Exit cleanly
}
// Seal
bq27220_writeControl(0x0030);
delay(5);
fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
Serial.printf("BQ27220: FCC after Design Energy update: %d mAh\n", fcc);
}
return true;
}
Serial.printf("BQ27220: Updating Design Capacity from %d to %d mAh\n", currentDC, designCapacity_mAh);
// Step 1: Unseal (default unseal keys)
bq27220_writeControl(0x0414);
delay(2);
bq27220_writeControl(0x3672);
delay(2);
// Step 2: Enter Full Access mode
bq27220_writeControl(0xFFFF);
delay(2);
bq27220_writeControl(0xFFFF);
delay(2);
// Step 3: Enter CFG_UPDATE mode
bq27220_writeControl(0x0090);
// Wait for CFGUPMODE bit (bit 10) in OperationStatus register
bool cfgReady = false;
for (int i = 0; i < 50; i++) {
delay(20);
uint16_t opStatus = bq27220_read16(BQ27220_REG_OP_STATUS);
Serial.printf("BQ27220: OperationStatus = 0x%04X (attempt %d)\n", opStatus, i);
if (opStatus & 0x0400) { // CFGUPMODE is bit 10
cfgReady = true;
break;
}
}
if (!cfgReady) {
Serial.println("BQ27220: ERROR - Timeout waiting for CFGUPDATE mode");
bq27220_writeControl(0x0092); // Try to exit cleanly
bq27220_writeControl(0x0030); // Re-seal
return false;
}
Serial.println("BQ27220: Entered CFGUPDATE mode");
// Step 4: Write Design Capacity via MAC Data Memory interface
// Design Capacity mAh lives at data memory address 0x929F
// 4a. Select the data memory block by writing address to 0x3E-0x3F
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); // MACDataControl register
Wire.write(0x9F); // Address low byte
Wire.write(0x92); // Address high byte
Wire.endTransmission();
delay(10);
// 4b. Read old data (MSB, LSB) and checksum for differential update
uint8_t oldMSB = bq27220_read8(0x40);
uint8_t oldLSB = bq27220_read8(0x41);
uint8_t oldChksum = bq27220_read8(0x60);
uint8_t dataLen = bq27220_read8(0x61);
Serial.printf("BQ27220: Old DC bytes=0x%02X 0x%02X chk=0x%02X len=%d\n",
oldMSB, oldLSB, oldChksum, dataLen);
// 4c. Compute new values (BQ27220 stores big-endian in data memory)
uint8_t newMSB = (designCapacity_mAh >> 8) & 0xFF;
uint8_t newLSB = designCapacity_mAh & 0xFF;
// Differential checksum: remove old bytes, add new bytes
uint8_t temp = (255 - oldChksum - oldMSB - oldLSB);
uint8_t newChksum = 255 - ((temp + newMSB + newLSB) & 0xFF);
Serial.printf("BQ27220: New DC bytes=0x%02X 0x%02X chk=0x%02X\n",
newMSB, newLSB, newChksum);
// 4d. Write address + new data as a single block transaction
// BQ27220 MAC requires: [0x3E] [addr_lo] [addr_hi] [data...]
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); // Start at MACDataControl
Wire.write(0x9F); // Address low byte
Wire.write(0x92); // Address high byte
Wire.write(newMSB); // Data byte 0 (at 0x40)
Wire.write(newLSB); // Data byte 1 (at 0x41)
uint8_t writeResult = Wire.endTransmission();
Serial.printf("BQ27220: Write block result = %d\n", writeResult);
// 4e. Write updated checksum and length
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x60);
Wire.write(newChksum);
Wire.write(dataLen);
writeResult = Wire.endTransmission();
Serial.printf("BQ27220: Write checksum result = %d\n", writeResult);
delay(10);
// 4f. Verify the write took effect before exiting config mode
// Re-read the block to confirm
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E);
Wire.write(0x9F);
Wire.write(0x92);
Wire.endTransmission();
delay(10);
uint8_t verMSB = bq27220_read8(0x40);
uint8_t verLSB = bq27220_read8(0x41);
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...");
delay(200); // Allow gauge to reinitialize
// Verify
uint16_t verifyDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
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 6: Seal the device
bq27220_writeControl(0x0030);
delay(5);
return verifyDC == designCapacity_mAh;
#else
return false;
#endif
}
int16_t TDeckBoard::getAvgCurrent() {
#if HAS_BQ27220
return (int16_t)bq27220_read16(BQ27220_REG_AVG_CURRENT);
#else
return 0;
#endif
}
int16_t TDeckBoard::getAvgPower() {
#if HAS_BQ27220
return (int16_t)bq27220_read16(BQ27220_REG_AVG_POWER);
#else
return 0;
#endif
}
uint16_t TDeckBoard::getTimeToEmpty() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_TIME_TO_EMPTY);
#else
return 0xFFFF;
#endif
}
uint16_t TDeckBoard::getRemainingCapacity() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_REMAIN_CAP);
#else
return 0;
#endif
}
uint16_t TDeckBoard::getFullChargeCapacity() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_FULL_CAP);
#else
return 0;
#endif
}
uint16_t TDeckBoard::getDesignCapacity() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_DESIGN_CAP);
#else
return 0;
#endif
}
int16_t TDeckBoard::getBattTemperature() {
#if HAS_BQ27220
uint16_t raw = bq27220_read16(BQ27220_REG_TEMPERATURE);
// BQ27220 returns 0.1°K, convert to 0.1°C (273.1K = 0°C)
return (int16_t)(raw - 2731);
#else
return 0;
#endif
}

View File

@@ -7,15 +7,35 @@
#include <driver/rtc_io.h>
// BQ27220 Fuel Gauge Registers
#define BQ27220_REG_VOLTAGE 0x08
#define BQ27220_REG_CURRENT 0x0C
#define BQ27220_REG_SOC 0x2C
#define BQ27220_REG_TEMPERATURE 0x06 // Temperature (0.1°K)
#define BQ27220_REG_VOLTAGE 0x08
#define BQ27220_REG_CURRENT 0x0C // Instantaneous current (mA, signed)
#define BQ27220_REG_SOC 0x2C
#define BQ27220_REG_REMAIN_CAP 0x10 // Remaining capacity (mAh)
#define BQ27220_REG_FULL_CAP 0x12 // Full charge capacity (mAh)
#define BQ27220_REG_AVG_CURRENT 0x14 // Average current (mA, signed)
#define BQ27220_REG_TIME_TO_EMPTY 0x16 // Minutes until empty
#define BQ27220_REG_AVG_POWER 0x24 // Average power (mW, signed)
#define BQ27220_REG_DESIGN_CAP 0x3C // Design capacity (mAh, read-only standard cmd)
#define BQ27220_REG_OP_STATUS 0x3A // Operation status
#define BQ27220_I2C_ADDR 0x55
// T-Deck Pro battery capacity (all variants use 1400 mAh cell)
#ifndef BQ27220_DESIGN_CAPACITY_MAH
#define BQ27220_DESIGN_CAPACITY_MAH 1400
#endif
class TDeckBoard : public ESP32Board {
public:
void begin();
void powerOff() override {
// Stop Bluetooth before power off
btStop();
// Don't call parent or enterDeepSleep - let normal shutdown continue
// Display will show "hibernating..." text
}
void enterDeepSleep(uint32_t secs, int pin_wake_btn) {
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
@@ -45,6 +65,30 @@ public:
// Read state of charge percentage from BQ27220
uint8_t getBatteryPercent();
// Read average current in mA (negative = discharging, positive = charging)
int16_t getAvgCurrent();
// Read average power in mW (negative = discharging, positive = charging)
int16_t getAvgPower();
// Read time-to-empty in minutes (0xFFFF if charging/unavailable)
uint16_t getTimeToEmpty();
// Read remaining capacity in mAh
uint16_t getRemainingCapacity();
// Read full charge capacity in mAh (learned value, may need cycling to update)
uint16_t getFullChargeCapacity();
// Read design capacity in mAh (the configured battery size)
uint16_t getDesignCapacity();
// Read battery temperature in 0.1°C units (e.g., 256 = 25.6°C)
int16_t getBattTemperature();
// Configure BQ27220 design capacity (checks on boot, writes only if wrong)
bool configureFuelGauge(uint16_t designCapacity_mAh = BQ27220_DESIGN_CAPACITY_MAH);
const char* getManufacturerName() const {
return "LilyGo T-Deck Pro";
}

View File

@@ -0,0 +1,329 @@
#pragma once
#include <Arduino.h>
#include <Wire.h>
// TCA8418 Register addresses
#define TCA8418_REG_CFG 0x01
#define TCA8418_REG_INT_STAT 0x02
#define TCA8418_REG_KEY_LCK_EC 0x03
#define TCA8418_REG_KEY_EVENT_A 0x04
#define TCA8418_REG_KP_GPIO1 0x1D
#define TCA8418_REG_KP_GPIO2 0x1E
#define TCA8418_REG_KP_GPIO3 0x1F
#define TCA8418_REG_DEBOUNCE 0x29
#define TCA8418_REG_GPI_EM1 0x20
#define TCA8418_REG_GPI_EM2 0x21
#define TCA8418_REG_GPI_EM3 0x22
// Key codes for special keys
#define KB_KEY_NONE 0
#define KB_KEY_BACKSPACE '\b'
#define KB_KEY_ENTER '\r'
#define KB_KEY_SPACE ' '
#define KB_KEY_EMOJI 0x01 // Non-printable code for $ key (emoji picker)
class TCA8418Keyboard {
private:
uint8_t _addr;
TwoWire* _wire;
bool _initialized;
bool _shiftActive; // Sticky shift (one-shot or held)
bool _shiftConsumed; // Was shift active for the last returned key
bool _shiftHeld; // Shift key physically held down
bool _shiftUsedWhileHeld; // Was shift consumed by any key while held
bool _altActive; // Sticky alt (one-shot)
bool _symActive; // Sticky sym (one-shot)
unsigned long _lastShiftTime; // For Shift+key combos
uint8_t readReg(uint8_t reg) {
_wire->beginTransmission(_addr);
_wire->write(reg);
_wire->endTransmission();
_wire->requestFrom(_addr, (uint8_t)1);
return _wire->available() ? _wire->read() : 0;
}
void writeReg(uint8_t reg, uint8_t val) {
_wire->beginTransmission(_addr);
_wire->write(reg);
_wire->write(val);
_wire->endTransmission();
}
// Map raw key codes to characters (from working reader firmware)
char getKeyChar(uint8_t keyCode) {
switch (keyCode) {
// Row 1 - QWERTYUIOP
case 10: return 'q'; // Q (was 97 on different hardware)
case 9: return 'w';
case 8: return 'e';
case 7: return 'r';
case 6: return 't';
case 5: return 'y';
case 4: return 'u';
case 3: return 'i';
case 2: return 'o';
case 1: return 'p';
// Row 2 - ASDFGHJKL + Backspace
case 20: return 'a'; // A (was 98 on different hardware)
case 19: return 's';
case 18: return 'd';
case 17: return 'f';
case 16: return 'g';
case 15: return 'h';
case 14: return 'j';
case 13: return 'k';
case 12: return 'l';
case 11: return '\b'; // Backspace
// Row 3 - Alt ZXCVBNM Sym Enter
case 30: return 0; // Alt - handled separately
case 29: return 'z';
case 28: return 'x';
case 27: return 'c';
case 26: return 'v';
case 25: return 'b';
case 24: return 'n';
case 23: return 'm';
case 22: return 0; // Symbol key - handled separately
case 21: return '\r'; // Enter
// Row 4 - Shift Mic Space Sym Shift
case 35: return 0; // Left shift - handled separately
case 34: return 0; // Mic
case 33: return ' '; // Space
case 32: return 0; // Sym - handled separately
case 31: return 0; // Right shift - handled separately
default: return 0;
}
}
// Map key with Alt modifier - same as Sym for this keyboard
char getAltChar(uint8_t keyCode) {
return getSymChar(keyCode); // Alt does same as Sym
}
// Map key with Sym modifier - based on actual T-Deck Pro keyboard silk-screen
char getSymChar(uint8_t keyCode) {
switch (keyCode) {
// Row 1: Q W E R T Y U I O P
case 10: return '#'; // Q -> #
case 9: return '1'; // W -> 1
case 8: return '2'; // E -> 2
case 7: return '3'; // R -> 3
case 6: return '('; // T -> (
case 5: return ')'; // Y -> )
case 4: return '_'; // U -> _
case 3: return '-'; // I -> -
case 2: return '+'; // O -> +
case 1: return '@'; // P -> @
// Row 2: A S D F G H J K L
case 20: return '*'; // A -> *
case 19: return '4'; // S -> 4
case 18: return '5'; // D -> 5
case 17: return '6'; // F -> 6
case 16: return '/'; // G -> /
case 15: return ':'; // H -> :
case 14: return ';'; // J -> ;
case 13: return '\''; // K -> '
case 12: return '"'; // L -> "
// Row 3: Z X C V B N M
case 29: return '7'; // Z -> 7
case 28: return '8'; // X -> 8
case 27: return '9'; // C -> 9
case 26: return '?'; // V -> ?
case 25: return '!'; // B -> !
case 24: return ','; // N -> ,
case 23: return '.'; // M -> .
// Row 4: Mic key -> 0
case 34: return '0'; // Mic -> 0
default: return 0;
}
}
public:
TCA8418Keyboard(uint8_t addr = 0x34, TwoWire* wire = &Wire)
: _addr(addr), _wire(wire), _initialized(false),
_shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _lastShiftTime(0) {}
bool begin() {
// Check if device responds
_wire->beginTransmission(_addr);
if (_wire->endTransmission() != 0) {
Serial.println("TCA8418: Device not found");
return false;
}
// Configure keyboard matrix (8 rows x 10 cols)
writeReg(TCA8418_REG_KP_GPIO1, 0xFF); // Rows 0-7 as keypad
writeReg(TCA8418_REG_KP_GPIO2, 0xFF); // Cols 0-7 as keypad
writeReg(TCA8418_REG_KP_GPIO3, 0x03); // Cols 8-9 as keypad
// Enable keypad with FIFO overflow detection
writeReg(TCA8418_REG_CFG, 0x11); // KE_IEN + INT_CFG
// Set debounce
writeReg(TCA8418_REG_DEBOUNCE, 0x03);
// Clear any pending interrupts
writeReg(TCA8418_REG_INT_STAT, 0x1F);
// Flush the FIFO
while (readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) {
readReg(TCA8418_REG_KEY_EVENT_A);
}
_initialized = true;
Serial.println("TCA8418: Keyboard initialized OK");
return true;
}
// Read a key press - returns character or 0 if no key
char readKey() {
if (!_initialized) return 0;
// Check for key events in FIFO
uint8_t keyCount = readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F;
if (keyCount == 0) return 0;
// Read key event from FIFO
uint8_t keyEvent = readReg(TCA8418_REG_KEY_EVENT_A);
// Bit 7: 1 = press, 0 = release
bool pressed = (keyEvent & 0x80) != 0;
uint8_t keyCode = keyEvent & 0x7F;
// Clear interrupt
writeReg(TCA8418_REG_INT_STAT, 0x1F);
Serial.printf("KB raw: event=0x%02X code=%d pressed=%d count=%d\n",
keyEvent, keyCode, pressed, keyCount);
// Track shift release (before the general release-ignore)
if (!pressed && (keyCode == 35 || keyCode == 31)) {
_shiftHeld = false;
// If shift was used while held (e.g. cursor nav), clear it completely
// so the next bare keypress isn't treated as shifted.
// If shift was NOT used (tap-then-release), keep _shiftActive for one-shot.
if (_shiftUsedWhileHeld) {
_shiftActive = false;
}
_shiftUsedWhileHeld = false;
return 0;
}
// Only act on key press, not release
if (!pressed || keyCode == 0) {
return 0;
}
// Handle modifier keys - set sticky state and return 0
if (keyCode == 35 || keyCode == 31) { // Shift keys
_shiftActive = true;
_shiftHeld = true;
_shiftUsedWhileHeld = false;
_lastShiftTime = millis();
Serial.println("KB: Shift activated");
return 0;
}
if (keyCode == 30) { // Alt key
_altActive = true;
Serial.println("KB: Alt activated");
return 0;
}
if (keyCode == 32) { // Sym key (bottom row)
_symActive = true;
Serial.println("KB: Sym activated");
return 0;
}
// Handle dedicated $ key (key code 22, next to M)
// Bare press = emoji picker, Sym+$ = literal '$'
if (keyCode == 22) {
if (_symActive) {
_symActive = false;
Serial.println("KB: Sym+$ -> '$'");
return '$';
}
Serial.println("KB: $ key -> emoji");
return KB_KEY_EMOJI;
}
// 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) {
_symActive = false;
Serial.println("KB: Mic -> '0'");
return '0';
}
// Get the character
char c = 0;
if (_altActive) {
c = getAltChar(keyCode);
_altActive = false; // Reset sticky alt
if (c != 0) {
Serial.printf("KB: Alt+key -> '%c'\n", c);
return c;
}
}
if (_symActive) {
c = getSymChar(keyCode);
_symActive = false; // Reset sticky sym
if (c != 0) {
Serial.printf("KB: Sym+key -> '%c'\n", c);
return c;
}
}
c = getKeyChar(keyCode);
if (c != 0 && _shiftActive) {
// Apply shift - uppercase letters
if (c >= 'a' && c <= 'z') {
c = c - 'a' + 'A';
}
// Track that shift was used while physically held
if (_shiftHeld) {
_shiftUsedWhileHeld = true;
}
// Only clear shift if it's one-shot (tap), not held down
if (!_shiftHeld) {
_shiftActive = false;
}
_shiftConsumed = true; // Record that shift was active for this key
} else {
_shiftConsumed = false;
}
if (c != 0) {
Serial.printf("KB: code %d -> '%c' (0x%02X)\n", keyCode, c >= 32 ? c : '?', c);
} else {
Serial.printf("KB: code %d -> UNMAPPED\n", keyCode);
}
return c;
}
bool isReady() const { return _initialized; }
// Check if shift was pressed within the last N milliseconds
bool wasShiftRecentlyPressed(unsigned long withinMs = 500) const {
return (millis() - _lastShiftTime) < withinMs;
}
// Check if shift was active when the most recent key was produced
// (immune to e-ink refresh timing unlike wasShiftRecentlyPressed)
bool wasShiftConsumed() const {
return _shiftConsumed;
}
};

View File

@@ -80,6 +80,7 @@ build_flags =
-D PIN_DISPLAY_BL=45
-D PIN_USER_BTN=0
-D CST328_PIN_RST=38
-D ARDUINO_LOOP_STACK_SIZE=32768
build_src_filter = ${esp32_base.build_src_filter}
+<../variants/LilyGo_TDeck_Pro>
+<helpers/sensors/*.cpp>
@@ -90,14 +91,73 @@ lib_deps =
adafruit/Adafruit GFX Library@^1.11.0
bakercp/CRC32@^2.0.0
[env:LilyGo_TDeck_Pro_companion_radio_usb]
; ---------------------------------------------------------------------------
; Meck unified builds — one codebase, three 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=350
-D MAX_GROUP_CHANNELS=40
-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
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 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=1500
-D MAX_GROUP_CHANNELS=20
-D OFFLINE_QUEUE_SIZE=256
-D MECK_AUDIO_VARIANT
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
; 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=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.5.4G"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -108,15 +168,22 @@ lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:LilyGo_TDeck_Pro_companion_radio_ble]
; 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=350
-D MAX_GROUP_CHANNELS=40
-D BLE_PIN_CODE=234567
-D MAX_CONTACTS=1500
-D MAX_GROUP_CHANNELS=20
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v0.9.5.4G.SA"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -125,22 +192,4 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:LilyGo_TDeck_Pro_repeater]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-D ADVERT_NAME='"TDeck Pro Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D NO_OTA=1
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<../examples/simple_repeater>
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
me-no-dev/AsyncTCP @ ^1.1.1
me-no-dev/ESPAsyncWebServer @ ^1.2.3
densaugeo/base64 @ ~1.4.0

View File

@@ -17,12 +17,13 @@ ESP32RTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if HAS_GPS
MicroNMEALocationProvider gps(Serial2, &rtc_clock);
// Wrap Serial2 with a sentence counter so the UI can show NMEA throughput.
// MicroNMEALocationProvider reads through this wrapper transparently.
GPSStreamCounter gpsStream(Serial2);
MicroNMEALocationProvider gps(gpsStream, &rtc_clock);
EnvironmentSensorManager sensors(gps);
#pragma message "GPS enabled - using EnvironmentSensorManager with MicroNMEALocationProvider"
#else
SensorManager sensors;
#pragma message "GPS disabled - using basic SensorManager"
#endif
#ifdef DISPLAY_CLASS
@@ -31,38 +32,37 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#endif
bool radio_init() {
Serial.println("radio_init() - starting");
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
fallback_clock.begin();
Serial.println("radio_init() - fallback_clock started");
MESH_DEBUG_PRINTLN("radio_init() - fallback_clock started");
// Wire already initialized in board.begin() - just use it for RTC
rtc_clock.begin(Wire);
Serial.println("radio_init() - rtc_clock started");
// Debug GPS status
#if HAS_GPS
Serial.println("radio_init() - HAS_GPS is defined");
Serial.print("radio_init() - gps object address: ");
Serial.println((uint32_t)&gps, HEX);
#else
Serial.println("radio_init() - HAS_GPS is NOT defined");
#endif
MESH_DEBUG_PRINTLN("radio_init() - rtc_clock started");
#if defined(P_LORA_SCLK)
Serial.println("radio_init() - initializing LoRa SPI...");
MESH_DEBUG_PRINTLN("radio_init() - initializing LoRa SPI...");
loraSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS);
Serial.println("radio_init() - SPI initialized, calling radio.std_init()...");
MESH_DEBUG_PRINTLN("radio_init() - SPI initialized, calling radio.std_init()...");
bool result = radio.std_init(&loraSpi);
Serial.print("radio_init() - radio.std_init() returned: ");
Serial.println(result ? "SUCCESS" : "FAILED");
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
Serial.println("radio_init() - calling radio.std_init() without custom SPI...");
return radio.std_init();
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
}
@@ -84,4 +84,8 @@ void radio_set_tx_power(uint8_t dbm) {
mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng);
}
void radio_reset_agc() {
radio.setRxBoostedGainMode(true);
}

View File

@@ -18,6 +18,7 @@
#if HAS_GPS
#include "helpers/sensors/EnvironmentSensorManager.h"
#include "helpers/sensors/MicroNMEALocationProvider.h"
#include "GPSStreamCounter.h"
#else
#include <helpers/SensorManager.h>
#endif
@@ -27,6 +28,7 @@ extern WRAPPER_CLASS radio_driver;
extern AutoDiscoverRTCClock rtc_clock;
#if HAS_GPS
extern GPSStreamCounter gpsStream;
extern EnvironmentSensorManager sensors;
#else
extern SensorManager sensors;
@@ -41,4 +43,5 @@ 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();
mesh::LocalIdentity radio_new_identity();
void radio_reset_agc();

View File

@@ -1,31 +0,0 @@
#include <Arduino.h>
#include <Wire.h>
#include "TechoBoard.h"
#ifdef LILYGO_TECHO
void TechoBoard::begin() {
NRF52Board::begin();
Wire.begin();
pinMode(SX126X_POWER_EN, OUTPUT);
digitalWrite(SX126X_POWER_EN, HIGH);
delay(10); // give sx1262 some time to power up
}
uint16_t TechoBoard::getBattMilliVolts() {
int adcvalue = 0;
analogReference(AR_INTERNAL_3_0);
analogReadResolution(12);
delay(10);
// ADC range is 0..3000mV and resolution is 12-bit (0..4095)
adcvalue = analogRead(PIN_VBAT_READ);
// Convert the raw value to compensated mv, taking the resistor-
// divider into account (providing the actual LIPO voltage)
return (uint16_t)((float)adcvalue * REAL_VBAT_MV_PER_LSB);
}
#endif

View File

@@ -1,44 +0,0 @@
#pragma once
#include <MeshCore.h>
#include <Arduino.h>
#include <helpers/NRF52Board.h>
// built-ins
#define VBAT_MV_PER_LSB (0.73242188F) // 3.0V ADC range and 12-bit ADC resolution = 3000mV/4096
#define VBAT_DIVIDER (0.5F) // 150K + 150K voltage divider on VBAT
#define VBAT_DIVIDER_COMP (2.0F) // Compensation factor for the VBAT divider
#define PIN_VBAT_READ (4)
#define REAL_VBAT_MV_PER_LSB (VBAT_DIVIDER_COMP * VBAT_MV_PER_LSB)
class TechoBoard : public NRF52BoardOTA {
public:
TechoBoard() : NRF52BoardOTA("TECHO_OTA") {}
void begin();
uint16_t getBattMilliVolts() override;
const char* getManufacturerName() const override {
return "LilyGo T-Echo";
}
void powerOff() override {
#ifdef LED_RED
digitalWrite(LED_RED, LOW);
#endif
#ifdef LED_GREEN
digitalWrite(LED_GREEN, LOW);
#endif
#ifdef LED_BLUE
digitalWrite(LED_BLUE, LOW);
#endif
#ifdef DISP_BACKLIGHT
digitalWrite(DISP_BACKLIGHT, LOW);
#endif
#ifdef PIN_PWR_EN
digitalWrite(PIN_PWR_EN, LOW);
#endif
sd_power_system_off();
}
};

View File

@@ -1,98 +0,0 @@
[LilyGo_T-Echo-Lite]
extends = nrf52_base
board = t-echo
board_build.ldscript = boards/nrf52840_s140_v6.ld
build_flags = ${nrf52_base.build_flags}
-I variants/lilygo_techo_lite
-I src/helpers/nrf52
-I lib/nrf52/s140_nrf52_6.1.1_API/include
-I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52
-D LILYGO_TECHO
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
-D LORA_TX_POWER=22
-D SX126X_POWER_EN=30
-D SX126X_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1
-D P_LORA_TX_LED=LED_GREEN
-D DISABLE_DIAGNOSTIC_OUTPUT
-D ENV_INCLUDE_GPS=1
-D GPS_BAUD_RATE=9600
-D PIN_GPS_EN=GPS_EN
-D DISPLAY_CLASS=GxEPDDisplay
-D EINK_DISPLAY_MODEL=GxEPD2_122_T61
-D EINK_SCALE_X=1.5f
-D EINK_SCALE_Y=2.0f
-D EINK_X_OFFSET=0
-D EINK_Y_OFFSET=10
-D DISPLAY_ROTATION=4
-D AUTO_OFF_MILLIS=0
build_src_filter = ${nrf52_base.build_src_filter}
+<helpers/*.cpp>
+<TechoBoard.cpp>
+<helpers/sensors/EnvironmentSensorManager.cpp>
+<helpers/ui/GxEPDDisplay.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../variants/lilygo_techo_lite>
lib_deps =
${nrf52_base.lib_deps}
stevemarple/MicroNMEA @ ^2.0.6
adafruit/Adafruit BME280 Library @ ^2.3.0
https://github.com/SoulOfNoob/GxEPD2.git
bakercp/CRC32 @ ^2.0.0
debug_tool = jlink
upload_protocol = nrfutil
[env:LilyGo_T-Echo-Lite_repeater]
extends = LilyGo_T-Echo-Lite
build_src_filter = ${LilyGo_T-Echo-Lite.build_src_filter}
+<../examples/simple_repeater>
build_flags =
${LilyGo_T-Echo-Lite.build_flags}
-D ADVERT_NAME='"T-Echo-Lite Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
[env:LilyGo_T-Echo-Lite_room_server]
extends = LilyGo_T-Echo-Lite
build_src_filter = ${LilyGo_T-Echo-Lite.build_src_filter}
+<../examples/simple_room_server>
build_flags =
${LilyGo_T-Echo-Lite.build_flags}
-D ADVERT_NAME='"T-Echo-Lite Room"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
[env:LilyGo_T-Echo-Lite_companion_radio_ble]
extends = LilyGo_T-Echo-Lite
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704
build_flags =
${LilyGo_T-Echo-Lite.build_flags}
-I src/helpers/ui
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
; -D QSPIFLASH=1
-D BLE_PIN_CODE=123456
; -D BLE_DEBUG_LOGGING=1
-D OFFLINE_QUEUE_SIZE=256
-D UI_RECENT_LIST_SIZE=9
-D UI_SENSORS_PAGE=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
-D AUTO_SHUTDOWN_MILLIVOLTS=3300
build_src_filter = ${LilyGo_T-Echo-Lite.build_src_filter}
+<helpers/nrf52/SerialBLEInterface.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${LilyGo_T-Echo-Lite.lib_deps}
densaugeo/base64 @ ~1.4.0

View File

@@ -1,52 +0,0 @@
#include <Arduino.h>
#include "target.h"
#include <helpers/ArduinoHelpers.h>
#include <helpers/sensors/MicroNMEALocationProvider.h>
TechoBoard board;
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI);
WRAPPER_CLASS radio_driver(radio, board);
VolatileRTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
#ifdef ENV_INCLUDE_GPS
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors = EnvironmentSensorManager();
#endif
#ifdef DISPLAY_CLASS
DISPLAY_CLASS display;
MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
#endif
bool radio_init() {
rtc_clock.begin(Wire);
return radio.std_init(&SPI);
}
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); // create new random identity
}

View File

@@ -1,31 +0,0 @@
#pragma once
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <TechoBoard.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <helpers/AutoDiscoverRTCClock.h>
#include <helpers/SensorManager.h>
#include <helpers/sensors/EnvironmentSensorManager.h>
#include <helpers/sensors/LocationProvider.h>
#ifdef DISPLAY_CLASS
#include <helpers/ui/GxEPDDisplay.h>
#include <helpers/ui/MomentaryButton.h>
#endif
extern TechoBoard board;
extern WRAPPER_CLASS radio_driver;
extern AutoDiscoverRTCClock rtc_clock;
extern EnvironmentSensorManager sensors;
#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();

View File

@@ -1,39 +0,0 @@
#include "variant.h"
#include "wiring_constants.h"
#include "wiring_digital.h"
const int MISO = PIN_SPI1_MISO;
const int MOSI = PIN_SPI1_MOSI;
const int SCK = PIN_SPI1_SCK;
const uint32_t g_ADigitalPinMap[] = {
0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,
14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39,
40, 41, 42, 43, 44, 45, 46, 47
};
void initVariant() {
pinMode(PIN_PWR_EN, OUTPUT);
digitalWrite(PIN_PWR_EN, HIGH);
pinMode(PIN_BUTTON1, INPUT_PULLUP);
pinMode(PIN_BUTTON2, INPUT_PULLUP);
pinMode(LED_RED, OUTPUT);
pinMode(LED_GREEN, OUTPUT);
pinMode(LED_BLUE, OUTPUT);
digitalWrite(LED_BLUE, HIGH);
digitalWrite(LED_GREEN, HIGH);
digitalWrite(LED_RED, HIGH);
// pinMode(PIN_TXCO, OUTPUT);
// digitalWrite(PIN_TXCO, HIGH);
pinMode(DISP_POWER, OUTPUT);
digitalWrite(DISP_POWER, LOW);
// shutdown gps
pinMode(GPS_EN, OUTPUT);
digitalWrite(GPS_EN, LOW);
}

View File

@@ -1,158 +0,0 @@
/*
* variant.h
* Copyright (C) 2023 Seeed K.K.
* MIT License
*/
#pragma once
#define _PINNUM(port, pin) ((port) * 32 + (pin))
#include "WVariant.h"
////////////////////////////////////////////////////////////////////////////////
// Low frequency clock source
#define USE_LFXO // 32.768 kHz crystal oscillator
#define VARIANT_MCK (64000000ul)
#define WIRE_INTERFACES_COUNT (1)
////////////////////////////////////////////////////////////////////////////////
// Power
#define PIN_PWR_EN _PINNUM(0, 30) // RT9080_EN
#define BATTERY_PIN _PINNUM(0, 2)
#define ADC_MULTIPLIER (4.90F)
#define ADC_RESOLUTION (14)
#define BATTERY_SENSE_RES (12)
#define AREF_VOLTAGE (3.0)
////////////////////////////////////////////////////////////////////////////////
// Number of pins
#define PINS_COUNT (48)
#define NUM_DIGITAL_PINS (48)
#define NUM_ANALOG_INPUTS (1)
#define NUM_ANALOG_OUTPUTS (0)
////////////////////////////////////////////////////////////////////////////////
// UART pin definition
#define PIN_SERIAL1_RX PIN_GPS_TX
#define PIN_SERIAL1_TX PIN_GPS_RX
////////////////////////////////////////////////////////////////////////////////
// I2C pin definition
#define PIN_WIRE_SDA _PINNUM(0, 4) // (SDA)
#define PIN_WIRE_SCL _PINNUM(0, 2) // (SCL)
////////////////////////////////////////////////////////////////////////////////
// SPI pin definition
#define SPI_INTERFACES_COUNT _PINNUM(0, 2)
#define PIN_SPI_MISO _PINNUM(0, 17) // (MISO)
#define PIN_SPI_MOSI _PINNUM(0, 15) // (MOSI)
#define PIN_SPI_SCK _PINNUM(0, 13) // (SCK)
#define PIN_SPI_NSS (-1)
////////////////////////////////////////////////////////////////////////////////
// QSPI FLASH
#define PIN_QSPI_SCK _PINNUM(0, 4)
#define PIN_QSPI_CS _PINNUM(0, 12)
#define PIN_QSPI_IO0 _PINNUM(0, 6)
#define PIN_QSPI_IO1 _PINNUM(0, 8)
#define PIN_QSPI_IO2 _PINNUM(1, 9)
#define PIN_QSPI_IO3 _PINNUM(0, 26)
#define EXTERNAL_FLASH_DEVICES ZD25WQ32CEIGR
#define EXTERNAL_FLASH_USE_QSPI
////////////////////////////////////////////////////////////////////////////////
// Builtin LEDs
#define LED_RED _PINNUM(1, 14) // LED_3
#define LED_BLUE _PINNUM(1, 5) // LED_2
#define LED_GREEN _PINNUM(1, 7) // LED_1
//#define PIN_STATUS_LED LED_BLUE
#define LED_BUILTIN (-1)
#define LED_PIN LED_BUILTIN
#define LED_STATE_ON LOW
////////////////////////////////////////////////////////////////////////////////
// Builtin buttons
#define PIN_BUTTON1 _PINNUM(0, 24) // BOOT
#define BUTTON_PIN PIN_BUTTON1
#define PIN_USER_BTN BUTTON_PIN
#define PIN_BUTTON2 _PINNUM(0, 18)
#define BUTTON_PIN2 PIN_BUTTON2
#define EXTERNAL_FLASH_DEVICES MX25R1635F
#define EXTERNAL_FLASH_USE_QSPI
////////////////////////////////////////////////////////////////////////////////
// Lora
#define USE_SX1262
#define LORA_CS _PINNUM(0, 11)
#define SX126X_POWER_EN _PINNUM(0, 30)
#define SX126X_DIO1 _PINNUM(1, 8)
#define SX126X_BUSY _PINNUM(0, 14)
#define SX126X_RESET _PINNUM(0, 7)
#define SX126X_RF_VC1 _PINNUM(0, 27)
#define SX126X_RF_VC2 _PINNUM(0, 33)
#define P_LORA_DIO_1 SX126X_DIO1
#define P_LORA_NSS LORA_CS
#define P_LORA_RESET SX126X_RESET
#define P_LORA_BUSY SX126X_BUSY
#define P_LORA_SCLK PIN_SPI_SCK
#define P_LORA_MISO PIN_SPI_MISO
#define P_LORA_MOSI PIN_SPI_MOSI
////////////////////////////////////////////////////////////////////////////////
// SPI1
#define PIN_SPI1_MISO (-1) // Not used for Display
#define PIN_SPI1_MOSI _PINNUM(0, 20)
#define PIN_SPI1_SCK _PINNUM(0, 19)
// GxEPD2 needs that for a panel that is not even used !
extern const int MISO;
extern const int MOSI;
extern const int SCK;
////////////////////////////////////////////////////////////////////////////////
// Display
// #define DISP_MISO (-1) // Not used for Display
#define DISP_MOSI _PINNUM(0, 20)
#define DISP_SCLK _PINNUM(0, 19)
#define DISP_CS _PINNUM(0, 22)
#define DISP_DC _PINNUM(0, 21)
#define DISP_RST _PINNUM(0, 28)
#define DISP_BUSY _PINNUM(0, 3)
#define DISP_POWER _PINNUM(1, 12)
// #define DISP_BACKLIGHT (-1) // Display has no backlight
#define PIN_DISPLAY_CS DISP_CS
#define PIN_DISPLAY_DC DISP_DC
#define PIN_DISPLAY_RST DISP_RST
#define PIN_DISPLAY_BUSY DISP_BUSY
////////////////////////////////////////////////////////////////////////////////
// GPS
#define PIN_GPS_RX _PINNUM(1, 13) // RXD
#define PIN_GPS_TX _PINNUM(1, 15) // TXD
#define GPS_EN _PINNUM(1, 11) // POWER_RT9080_EN
#define PIN_GPS_STANDBY _PINNUM(1, 10)
#define PIN_GPS_PPS _PINNUM(0, 29) // 1PPS