54 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
33 changed files with 12504 additions and 387 deletions

View File

@@ -1,8 +1,6 @@
## 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.
***Please note as of 1 Feb 2026, the T-Deck Pro repeater & usb firmware has not been finalised nor confirmed as functioning.*** ⭐
### Contents
- [T-Deck Pro Keyboard Controls](#t-deck-pro-keyboard-controls)
- [Navigation (Home Screen)](#navigation-home-screen)
@@ -16,6 +14,8 @@ This fork was created specifically to focus on enabling BLE companion firmware f
- [Compose Mode](#compose-mode)
- [Symbol Entry (Sym Key)](#symbol-entry-sym-key)
- [Emoji Picker](#emoji-picker)
- [SMS & Phone App (4G only)](#sms--phone-app-4g-only)
- [Web Browser & IRC](#web-browser--irc)
- [About MeshCore](#about-meshcore)
- [What is MeshCore?](#what-is-meshcore)
- [Key Features](#key-features)
@@ -24,10 +24,11 @@ This fork was created specifically to focus on enabling BLE companion firmware f
- [MeshCore Flasher](#meshcore-flasher)
- [MeshCore Clients](#meshcore-clients)
- [Hardware Compatibility](#-hardware-compatibility)
- [License](#-license)
- [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
@@ -43,7 +44,11 @@ The T-Deck Pro BLE companion firmware includes full keyboard support for standal
| M | Open channel messages |
| C | Open contacts list |
| E | Open e-book reader |
| N | Open notes |
| S | Open settings |
| B | Open web browser (BLE and 4G variants only) |
| T | Open SMS & Phone app (4G variant only) |
| P | Open audiobook player (audio variant only) |
| Q | Back to home screen |
### Bluetooth (BLE)
@@ -81,6 +86,8 @@ The GPS page also shows the current time, satellite count, position, altitude, a
| W / S | Scroll messages up/down |
| A / D | Switch between channels |
| Enter | Compose new message |
| R | Reply to a message — enter reply select mode, scroll to a message with W/S, then press Enter to compose a reply with an @mention |
| V | View relay path of the last received message (scrollable, up to 20 hops) |
| Q | Back to home screen |
### Contacts Screen
@@ -90,10 +97,14 @@ Press **C** from the home screen to open the contacts list. All known mesh conta
| Key | Action |
|-----|--------|
| W / S | Scroll up / down through contacts |
| A / D | Cycle filter: All → Chat → Repeater → Room → Sensor |
| A / D | Cycle filter: All → Chat → Repeater → Room → Sensor → Favourites |
| Enter | Open DM compose (Chat contact) or repeater admin (Repeater contact) |
| X | Export contacts to SD card (wait 510 seconds for confirmation popup) |
| R | Import contacts from SD card (wait 510 seconds for confirmation popup) |
| Q | Back to home screen |
**Contact limits:** Standalone 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.
@@ -146,6 +157,8 @@ Press **S** from the home screen to open settings. On first boot (when the devic
| 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.
@@ -195,6 +208,22 @@ While in compose mode, press the **$** key to open the emoji picker. A scrollabl
| Enter | Insert selected emoji |
| $ / Q / Backspace | Cancel and return to compose |
### SMS & Phone App (4G only)
Press **T** from the home screen to open the SMS & Phone app. The app opens to a menu screen where you can choose between the **Phone** dialer (for calling any number) or the **SMS Inbox** (for messaging and calling saved contacts).
For full documentation including key mappings, dialpad usage, contacts management, and troubleshooting, see the [SMS & Phone App Guide](SMS%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.
@@ -251,7 +280,7 @@ Download a copy of the Meck firmware bin from https://github.com/pelgraine/Meck/
**Companion Firmware**
The companion firmware can be connected to via BLE. USB is planned for a future update.
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.
@@ -265,10 +294,6 @@ The companion firmware can be connected to via BLE. USB is planned for a future
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!
@@ -290,11 +315,36 @@ There are a number of fairly major features in the pipeline, with no particular
- [X] Standalone repeater admin access for Companion BLE firmware
- [X] GPS time sync with on-device timezone setting
- [X] Settings screen with radio presets, channel management, and first-boot onboarding
- [ ] Companion radio: USB
- [ ] Simple Repeater firmware for the T-Deck Pro
- [ ] Get pin 45 with the screen backlight functioning for the T-Deck Pro v1.1
- [ ] Canned messages function for Companion BLE firmware
- [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
- Join [MeshCore Discord](https://discord.gg/BMwCtwHj5V) to chat with the developers and get help from the community.
- Join [MeshCore Discord](https://discord.gg/BMwCtwHj5V) to chat with the developers and get help from the community.
## 📜 License
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.
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.

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

@@ -48,7 +48,11 @@ public:
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

@@ -706,6 +706,29 @@ bool MyMesh::uiSendCliCommand(uint32_t contact_idx, const char* command) {
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) {
@@ -816,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;
@@ -825,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;
@@ -1043,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();
@@ -1380,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;

View File

@@ -8,11 +8,11 @@
#define FIRMWARE_VER_CODE 8
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "20 Feb 2026"
#define FIRMWARE_BUILD_DATE "27 Feb 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.9.1A"
#define FIRMWARE_VERSION "Meck v0.9.5"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -111,6 +111,7 @@ public:
// 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; }

View File

@@ -1,4 +1,5 @@
#include <Arduino.h> // needed for PlatformIO
#include <esp_bt.h> // for esp_bt_controller_mem_release (web reader WiFi)
#include <Mesh.h>
#include "MyMesh.h"
#include "variant.h" // Board-specific defines (HAS_GPS, etc.)
@@ -16,6 +17,9 @@
#include "ChannelScreen.h"
#include "SettingsScreen.h"
#include "RepeaterAdminScreen.h"
#ifdef MECK_WEB_READER
#include "WebReaderScreen.h"
#endif
extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus
TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire);
@@ -29,10 +33,20 @@
static bool composeNeedsRefresh = false;
#define COMPOSE_REFRESH_INTERVAL 100 // ms before starting e-ink refresh after keypress (refresh itself takes ~644ms)
// Phone dialer debounce — independent from compose/smsSuppressLoop to avoid
// interfering with call view rendering and alert display
static bool dialerNeedsRefresh = false;
static unsigned long lastDialerRefresh = 0;
// DM compose mode (direct message to a specific contact)
static bool composeDM = false;
static int composeDMContactIdx = -1;
static char composeDMName[32];
#ifdef MECK_WEB_READER
static unsigned long lastWebReaderRefresh = 0;
static bool webReaderNeedsRefresh = false;
static bool webReaderTextEntry = false; // True when URL/password entry active
#endif
// AGC reset - periodically re-assert RX boosted gain to prevent sensitivity drift
#define AGC_RESET_INTERVAL_MS 500
static unsigned long lastAGCReset = 0;
@@ -50,11 +64,28 @@
// Audiobook player — Audio object is heap-allocated on first use to avoid
// consuming ~40KB of DMA/decode buffers at boot (starves BLE stack).
#ifdef MECK_AUDIO_VARIANT
#include "AudiobookPlayerScreen.h"
#include "Audio.h"
Audio* audio = nullptr;
// Audiobook player — Audio object is heap-allocated on first use to avoid
// consuming ~40KB of DMA/decode buffers at boot (starves BLE stack).
// Not available on 4G variant (I2S pins conflict with modem control lines).
#ifndef HAS_4G_MODEM
#include "AudiobookPlayerScreen.h"
#include "Audio.h"
Audio* audio = nullptr;
#endif
static bool audiobookMode = false;
#ifdef HAS_4G_MODEM
#include "ModemManager.h"
#include "SMSStore.h"
#include "SMSContacts.h"
#include "SMSScreen.h"
static bool smsMode = false;
#endif
// Touch input (for phone dialer numpad)
#ifdef HAS_TOUCHSCREEN
#include "TouchInput.h"
TouchInput touchInput(&Wire);
#endif
// Power management
@@ -167,6 +198,135 @@
digitalWrite(SDCARD_CS, HIGH);
return restored;
}
// -----------------------------------------------------------------------
// On-demand export: save current contacts to SD card.
// Writes binary backup + human-readable listing.
// Returns number of contacts exported, or -1 on error.
// -----------------------------------------------------------------------
int exportContactsToSD() {
if (!sdCardReady) return -1;
// Ensure in-memory contacts are flushed to SPIFFS first
the_mesh.saveContacts();
if (!SD.exists("/meshcore")) SD.mkdir("/meshcore");
// 1) Binary backup: SPIFFS /contacts3 → SD /meshcore/contacts.bin
if (!SPIFFS.exists("/contacts3")) return -1;
if (!copyFile(SPIFFS, "/contacts3", SD, "/meshcore/contacts.bin")) return -1;
// 2) Human-readable listing for inspection on a computer
int count = 0;
File txt = SD.open("/meshcore/contacts_export.txt", "w", true);
if (txt) {
txt.printf("Meck Contacts Export (%d total)\n", (int)the_mesh.getNumContacts());
txt.printf("========================================\n");
txt.printf("%-5s %-30s %s\n", "Type", "Name", "PubKey (prefix)");
txt.printf("----------------------------------------\n");
ContactInfo c;
for (uint32_t i = 0; i < (uint32_t)the_mesh.getNumContacts(); i++) {
if (the_mesh.getContactByIdx(i, c)) {
const char* typeStr = "???";
switch (c.type) {
case ADV_TYPE_CHAT: typeStr = "Chat"; break;
case ADV_TYPE_REPEATER: typeStr = "Rptr"; break;
case ADV_TYPE_ROOM: typeStr = "Room"; break;
}
// First 8 bytes of pub key as hex identifier
char hexBuf[20];
mesh::Utils::toHex(hexBuf, c.id.pub_key, 8);
txt.printf("%-5s %-30s %s\n", typeStr, c.name, hexBuf);
count++;
}
}
txt.printf("========================================\n");
txt.printf("Total: %d contacts\n", count);
txt.close();
}
digitalWrite(SDCARD_CS, HIGH);
Serial.printf("Contacts exported to SD: %d contacts\n", count);
return count;
}
// -----------------------------------------------------------------------
// On-demand import: merge contacts from SD backup into live table.
//
// Reads /meshcore/contacts.bin from SD and for each contact:
// - If already in memory (matching pub_key) → skip (keep current)
// - If NOT in memory → addContact (append to table)
//
// This is a non-destructive merge: you never lose contacts already in
// memory, and you gain any that were only in the backup.
//
// After merging, saves the combined set back to SPIFFS so it persists.
// Returns number of NEW contacts added, or -1 on error.
// -----------------------------------------------------------------------
int importContactsFromSD() {
if (!sdCardReady) return -1;
if (!SD.exists("/meshcore/contacts.bin")) return -1;
File file = SD.open("/meshcore/contacts.bin", "r");
if (!file) return -1;
int added = 0;
int skipped = 0;
while (true) {
ContactInfo c;
uint8_t pub_key[32];
uint8_t unused;
// Parse one contact record (same binary format as DataStore::loadContacts)
bool success = (file.read(pub_key, 32) == 32);
success = success && (file.read((uint8_t *)&c.name, 32) == 32);
success = success && (file.read(&c.type, 1) == 1);
success = success && (file.read(&c.flags, 1) == 1);
success = success && (file.read(&unused, 1) == 1);
success = success && (file.read((uint8_t *)&c.sync_since, 4) == 4);
success = success && (file.read((uint8_t *)&c.out_path_len, 1) == 1);
success = success && (file.read((uint8_t *)&c.last_advert_timestamp, 4) == 4);
success = success && (file.read(c.out_path, 64) == 64);
success = success && (file.read((uint8_t *)&c.lastmod, 4) == 4);
success = success && (file.read((uint8_t *)&c.gps_lat, 4) == 4);
success = success && (file.read((uint8_t *)&c.gps_lon, 4) == 4);
if (!success) break; // EOF or read error
c.id = mesh::Identity(pub_key);
c.shared_secret_valid = false;
// Check if this contact already exists in the live table
if (the_mesh.lookupContactByPubKey(pub_key, PUB_KEY_SIZE) != NULL) {
skipped++;
continue; // Already have this contact, skip
}
// New contact — add to the live table
if (the_mesh.addContact(c)) {
added++;
} else {
// Table is full, stop importing
Serial.printf("Import: table full after adding %d contacts\n", added);
break;
}
}
file.close();
digitalWrite(SDCARD_CS, HIGH);
// Persist the merged set to SPIFFS
if (added > 0) {
the_mesh.saveContacts();
}
Serial.printf("Contacts import: %d added, %d already present, %d total\n",
added, skipped, (int)the_mesh.getNumContacts());
return added;
}
#endif
// Believe it or not, this std C function is busted on some platforms!
@@ -423,10 +583,38 @@ void setup() {
// ---------------------------------------------------------------------------
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
{
// Deselect ALL SPI devices before SD init to prevent bus contention.
// E-ink, LoRa, and SD share the same SPI bus (SCK=36, MOSI=33, MISO=47).
// If LoRa CS is still asserted from board/radio init, it responds on the
// shared MISO line and corrupts SD card replies (CMD0 fails intermittently).
pinMode(SDCARD_CS, OUTPUT);
digitalWrite(SDCARD_CS, HIGH); // Deselect SD initially
digitalWrite(SDCARD_CS, HIGH);
if (SD.begin(SDCARD_CS, displaySpi, 4000000)) {
pinMode(PIN_EINK_CS, OUTPUT);
digitalWrite(PIN_EINK_CS, HIGH);
pinMode(LORA_CS, OUTPUT);
digitalWrite(LORA_CS, HIGH);
// SD cards need 74+ SPI clock cycles after power stabilization before
// accepting CMD0. A brief delay avoids race conditions on cold boot
// or with slow-starting cards.
delay(100);
// Retry loop — some SD cards are slow to initialise, especially on
// cold boot or marginal USB power. Three attempts with increasing
// settle time covers the vast majority of transient failures.
bool mounted = false;
for (int attempt = 0; attempt < 3 && !mounted; attempt++) {
if (attempt > 0) {
digitalWrite(SDCARD_CS, HIGH); // Ensure CS released between retries
delay(250);
MESH_DEBUG_PRINTLN("setup() - SD card retry %d/3", attempt + 1);
}
mounted = SD.begin(SDCARD_CS, displaySpi, 4000000);
}
if (mounted) {
sdCardReady = true;
MESH_DEBUG_PRINTLN("setup() - SD card initialized (early)");
@@ -435,7 +623,7 @@ void setup() {
MESH_DEBUG_PRINTLN("setup() - Settings restored from SD backup");
}
} else {
MESH_DEBUG_PRINTLN("setup() - SD card not available");
MESH_DEBUG_PRINTLN("setup() - SD card not available after 3 attempts");
}
}
#endif
@@ -500,6 +688,15 @@ void setup() {
initKeyboard();
#endif
// Initialize touch input (CST328)
#ifdef HAS_TOUCHSCREEN
if (touchInput.begin(CST328_PIN_INT)) {
MESH_DEBUG_PRINTLN("setup() - Touch input initialized");
} else {
MESH_DEBUG_PRINTLN("setup() - Touch input FAILED");
}
#endif
// ---------------------------------------------------------------------------
// SD card is already initialized (early init above).
// Now set up SD-dependent features: message history + text reader.
@@ -538,6 +735,32 @@ void setup() {
// Do an initial settings backup to SD (captures any first-boot defaults)
backupSettingsToSD();
// SMS / 4G modem init (after SD is ready)
#ifdef HAS_4G_MODEM
{
smsStore.begin();
smsContacts.begin();
// Tell SMS screen that SD is ready
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
if (smsScr) {
smsScr->setSDReady(true);
}
// Start modem if enabled in config (default = enabled)
bool modemEnabled = ModemManager::loadEnabledConfig();
if (modemEnabled) {
modemManager.begin();
MESH_DEBUG_PRINTLN("setup() - 4G modem manager started");
} else {
// Ensure modem power is off (kills red LED too)
pinMode(MODEM_POWER_EN, OUTPUT);
digitalWrite(MODEM_POWER_EN, LOW);
MESH_DEBUG_PRINTLN("setup() - 4G modem disabled by config");
}
}
#endif
}
#endif
@@ -607,7 +830,7 @@ void loop() {
cpuPower.loop();
// Audiobook: service audio decode regardless of which screen is active
#ifdef MECK_AUDIO_VARIANT
#ifndef HAS_4G_MODEM
{
AudiobookPlayerScreen* abPlayer =
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
@@ -619,7 +842,94 @@ void loop() {
}
}
}
#endif
#endif
// SMS: poll for incoming messages from modem
#ifdef HAS_4G_MODEM
{
SMSIncoming incoming;
while (modemManager.recvSMS(incoming)) {
// Save to store and notify UI
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
if (smsScr) {
smsScr->onIncomingSMS(incoming.phone, incoming.body, incoming.timestamp);
}
// Alert + buzzer
char alertBuf[48];
snprintf(alertBuf, sizeof(alertBuf), "SMS: %s", incoming.phone);
ui_task.showAlert(alertBuf, 2000);
ui_task.notify(UIEventType::contactMessage);
Serial.printf("[SMS] Received from %s: %.40s...\n", incoming.phone, incoming.body);
}
// Poll for voice call events from modem
CallEvent callEvt;
while (modemManager.pollCallEvent(callEvt)) {
SMSScreen* smsScr2 = (SMSScreen*)ui_task.getSMSScreen();
if (smsScr2) {
smsScr2->onCallEvent(callEvt);
}
if (callEvt.type == CallEventType::INCOMING) {
// Incoming call — auto-switch to SMS screen if not already there
char alertBuf[48];
char dispName[SMS_CONTACT_NAME_LEN];
smsContacts.displayName(callEvt.phone, dispName, sizeof(dispName));
snprintf(alertBuf, sizeof(alertBuf), "Call: %s", dispName);
ui_task.showAlert(alertBuf, 3000);
ui_task.notify(UIEventType::contactMessage);
if (!smsMode) {
ui_task.gotoSMSScreen();
}
ui_task.forceRefresh();
Serial.printf("[Call] Incoming from %s\n", callEvt.phone);
} else if (callEvt.type == CallEventType::CONNECTED) {
Serial.printf("[Call] Connected to %s\n", callEvt.phone);
ui_task.forceRefresh();
} else if (callEvt.type == CallEventType::ENDED) {
Serial.printf("[Call] Ended (%lus) with %s\n",
(unsigned long)callEvt.duration, callEvt.phone);
// Show alert with duration (supplements the immediate alert from Q hangup;
// this catches remote hangups and network drops)
{
char alertBuf[48];
if (callEvt.duration > 0) {
snprintf(alertBuf, sizeof(alertBuf), "Call Ended %lu:%02lu",
(unsigned long)(callEvt.duration / 60),
(unsigned long)(callEvt.duration % 60));
} else {
snprintf(alertBuf, sizeof(alertBuf), "Call Ended");
}
ui_task.showAlert(alertBuf, 2000);
}
ui_task.forceRefresh();
} else if (callEvt.type == CallEventType::MISSED) {
char alertBuf[48];
char dispName[SMS_CONTACT_NAME_LEN];
smsContacts.displayName(callEvt.phone, dispName, sizeof(dispName));
snprintf(alertBuf, sizeof(alertBuf), "Missed: %s", dispName);
ui_task.showAlert(alertBuf, 3000);
Serial.printf("[Call] Missed from %s\n", callEvt.phone);
ui_task.forceRefresh();
} else if (callEvt.type == CallEventType::BUSY) {
ui_task.showAlert("Line busy", 2000);
Serial.printf("[Call] Busy: %s\n", callEvt.phone);
ui_task.forceRefresh();
} else if (callEvt.type == CallEventType::NO_ANSWER) {
ui_task.showAlert("No answer", 2000);
Serial.printf("[Call] No answer: %s\n", callEvt.phone);
ui_task.forceRefresh();
} else if (callEvt.type == CallEventType::DIAL_FAILED) {
ui_task.showAlert("Call failed", 2000);
Serial.printf("[Call] Dial failed: %s\n", callEvt.phone);
ui_task.forceRefresh();
}
}
}
#endif
#ifdef DISPLAY_CLASS
// Skip UITask rendering when in compose mode to prevent flickering
#if defined(LilyGo_TDeck_Pro)
@@ -627,10 +937,26 @@ void loop() {
bool notesEditing = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isEditing();
bool notesRenaming = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isRenaming();
bool notesSuppressLoop = notesEditing || notesRenaming;
if (!composeMode && !notesSuppressLoop) {
#ifdef HAS_4G_MODEM
bool smsSuppressLoop = smsMode && ((SMSScreen*)ui_task.getSMSScreen())->isComposing();
#else
bool smsSuppressLoop = false;
#endif
#ifdef MECK_WEB_READER
// Safety: clear web reader text entry flag if we're no longer on the web reader
if (webReaderTextEntry && !ui_task.isOnWebReader()) {
webReaderTextEntry = false;
webReaderNeedsRefresh = false;
}
#endif
if (!composeMode && !notesSuppressLoop && !smsSuppressLoop && !dialerNeedsRefresh
#ifdef MECK_WEB_READER
&& !webReaderTextEntry
#endif
) {
ui_task.loop();
} else {
// Handle debounced screen refresh (compose, emoji picker, or notes editor)
// Handle debounced screen refresh (compose, emoji picker, notes, or web reader text entry)
if (composeNeedsRefresh && (millis() - lastComposeRefresh) >= COMPOSE_REFRESH_INTERVAL) {
if (composeMode) {
if (emojiPickerMode) {
@@ -642,16 +968,61 @@ void loop() {
// Notes editor/rename renders through UITask - force a refresh cycle
ui_task.forceRefresh();
ui_task.loop();
} else if (smsSuppressLoop) {
// SMS compose: render directly to display, same as mesh compose
#if defined(DISPLAY_CLASS) && defined(HAS_4G_MODEM)
display.startFrame();
((SMSScreen*)ui_task.getSMSScreen())->render(display);
display.endFrame();
#endif
}
lastComposeRefresh = millis();
composeNeedsRefresh = false;
}
// Phone dialer debounced render (separate from compose debounce)
#ifdef HAS_4G_MODEM
if (dialerNeedsRefresh && (millis() - lastDialerRefresh) >= COMPOSE_REFRESH_INTERVAL) {
if (smsMode) {
SMSScreen* dialScr = (SMSScreen*)ui_task.getSMSScreen();
if (dialScr && dialScr->getSubView() == SMSScreen::PHONE_DIALER) {
display.startFrame();
dialScr->render(display);
display.endFrame();
}
}
dialerNeedsRefresh = false;
lastDialerRefresh = millis();
}
#endif
#ifdef MECK_WEB_READER
if (webReaderNeedsRefresh && (millis() - lastWebReaderRefresh) >= COMPOSE_REFRESH_INTERVAL) {
WebReaderScreen* wr2 = (WebReaderScreen*)ui_task.getWebReaderScreen();
if (wr2) {
display.startFrame();
wr2->render(display);
display.endFrame();
}
lastWebReaderRefresh = millis();
webReaderNeedsRefresh = false;
}
// Password reveal expiry: re-render to mask character after 800ms
if (webReaderTextEntry && !webReaderNeedsRefresh) {
WebReaderScreen* wr3 = (WebReaderScreen*)ui_task.getWebReaderScreen();
if (wr3 && wr3->needsRevealRefresh() && (millis() - lastWebReaderRefresh) >= 850) {
display.startFrame();
wr3->render(display);
display.endFrame();
lastWebReaderRefresh = millis();
}
}
#endif
}
// Track reader/notes/audiobook mode state for key routing
readerMode = ui_task.isOnTextReader();
notesMode = ui_task.isOnNotesScreen();
#ifdef MECK_AUDIO_VARIANT
audiobookMode = ui_task.isOnAudiobookPlayer();
#ifdef HAS_4G_MODEM
smsMode = ui_task.isOnSMSScreen();
#endif
#else
ui_task.loop();
@@ -667,6 +1038,44 @@ void loop() {
#if defined(LilyGo_TDeck_Pro)
handleKeyboardInput();
#endif
// Poll touch input for phone dialer numpad
// Hybrid debounce: finger-up detection + 150ms minimum between accepted taps.
// The CST328 INT pin is pulse-based (not level), so getPoint() can return
// false intermittently during a hold. Time guard prevents that from
// causing repeat fires.
#if defined(HAS_TOUCHSCREEN) && defined(HAS_4G_MODEM)
{
static bool touchFingerDown = false;
static unsigned long lastTouchAccepted = 0;
if (smsMode) {
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
if (smsScr && smsScr->getSubView() == SMSScreen::PHONE_DIALER) {
int16_t tx, ty;
if (touchInput.getPoint(tx, ty)) {
unsigned long now = millis();
if (!touchFingerDown && (now - lastTouchAccepted >= 150)) {
touchFingerDown = true;
lastTouchAccepted = now;
if (smsScr->handleTouch(tx, ty)) {
dialerNeedsRefresh = true;
lastDialerRefresh = millis();
}
}
} else {
// Only allow finger-up after 100ms from last acceptance
// (prevents INT pulse misses from resetting state mid-hold)
if (touchFingerDown && (millis() - lastTouchAccepted >= 100)) {
touchFingerDown = false;
}
}
} else {
touchFingerDown = false;
}
}
}
#endif
}
// ============================================================================
@@ -747,7 +1156,7 @@ void handleKeyboardInput() {
if (wasDM) {
ui_task.gotoContactsScreen();
} else {
ui_task.gotoHomeScreen();
ui_task.gotoChannelScreen();
}
return;
}
@@ -767,7 +1176,7 @@ void handleKeyboardInput() {
if (wasDM) {
ui_task.gotoContactsScreen();
} else {
ui_task.gotoHomeScreen();
ui_task.gotoChannelScreen();
}
return;
}
@@ -843,7 +1252,7 @@ void handleKeyboardInput() {
}
// *** AUDIOBOOK MODE ***
#ifdef MECK_AUDIO_VARIANT
#ifndef HAS_4G_MODEM
if (audiobookMode) {
AudiobookPlayerScreen* abPlayer =
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
@@ -876,7 +1285,7 @@ void handleKeyboardInput() {
ui_task.injectKey(key);
return;
}
#endif // MECK_AUDIO_VARIANT
#endif // !HAS_4G_MODEM
// *** TEXT READER MODE ***
if (readerMode) {
@@ -1075,8 +1484,8 @@ void handleKeyboardInput() {
return;
}
// In menu state: Shift+Del exits to contacts, C opens compose
if (astate == RepeaterAdminScreen::STATE_MENU) {
// In category menu (top level): Shift+Del exits to contacts, C opens compose
if (astate == RepeaterAdminScreen::STATE_CATEGORY_MENU) {
if (shiftDel) {
Serial.println("Nav: Back to contacts from admin menu");
ui_task.gotoContactsScreen();
@@ -1098,8 +1507,9 @@ void handleKeyboardInput() {
return;
}
// In waiting/response/error states: convert Shift+Del to exit signal,
// pass all other keys through
// All other states (command menu, param entry, confirm, waiting,
// response, error): convert Shift+Del to exit signal and let the
// screen handle back-navigation internally
if (shiftDel) {
ui_task.injectKey(KEY_ADMIN_EXIT);
} else {
@@ -1108,6 +1518,119 @@ void handleKeyboardInput() {
return;
}
// SMS mode key routing (when on SMS screen)
#ifdef HAS_4G_MODEM
if (smsMode) {
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
if (smsScr) {
// Keep display alive — SMS routes many keys via handleInput() directly,
// bypassing injectKey() which normally extends the auto-off timer.
ui_task.keepAlive();
if (smsScr->isInCallView()) {
smsScr->handleInput(key);
if (!smsScr->isInCallView()) {
// Hangup just happened — show "Call Ended" alert immediately
ui_task.showAlert("Call Ended", 2000);
}
// Force immediate render (call screen updates or return-to-dialer)
ui_task.forceRefresh();
ui_task.loop();
return;
}
// Q from app menu → go home; Q from inner views is handled by SMSScreen
if ((key == 'q' || key == '\b') && smsScr->getSubView() == SMSScreen::APP_MENU) {
Serial.println("Nav: SMS -> Home");
ui_task.gotoHomeScreen();
return;
}
// Phone dialer: debounced refresh for digit entry, immediate render for
// view transitions (Enter=call, Q=back). This avoids the 686ms e-ink
// block per keypress while ensuring call/back screens render instantly.
if (smsScr->getSubView() == SMSScreen::PHONE_DIALER) {
smsScr->handleInput(key);
if (smsScr->getSubView() == SMSScreen::PHONE_DIALER) {
// Still on dialer (digit/backspace) — debounced refresh
dialerNeedsRefresh = true;
lastDialerRefresh = millis();
} else {
// View changed (startCall or Q back) — render immediately
dialerNeedsRefresh = false;
ui_task.forceRefresh();
ui_task.loop();
}
return;
}
if (smsScr->isComposing()) {
// Composing/text input: route directly to screen, bypass injectKey()
// to avoid UITask scheduling its own competing refresh
smsScr->handleInput(key);
if (smsScr->isComposing()) {
// Still composing — debounced refresh
composeNeedsRefresh = true;
lastComposeRefresh = millis();
} else {
// View changed (sent/cancelled) — immediate UITask refresh
composeNeedsRefresh = false;
ui_task.forceRefresh();
}
} else {
// Non-compose views (inbox, conversation, contacts): use normal inject
ui_task.injectKey(key);
}
return;
}
}
#endif
// *** WEB READER TEXT INPUT MODE ***
// Match compose mode pattern: key handler sets a flag and returns instantly.
// Main loop renders with 100ms debounce (same as COMPOSE_REFRESH_INTERVAL).
// This way the key handler never blocks for 648ms during a render.
#ifdef MECK_WEB_READER
if (ui_task.isOnWebReader()) {
WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen();
bool urlEdit = wr ? wr->isUrlEditing() : false;
bool passEdit = wr ? wr->isPasswordEntry() : false;
bool formEdit = wr ? wr->isFormFilling() : false;
bool searchEdit = wr ? wr->isSearchEditing() : false;
if (wr && (urlEdit || passEdit || formEdit || searchEdit)) {
webReaderTextEntry = true; // Suppress ui_task.loop() in main loop
wr->handleInput(key); // Updates buffer instantly, no render
// Check if text entry ended (submitted, cancelled, etc.)
if (!wr->isUrlEditing() && !wr->isPasswordEntry() && !wr->isFormFilling() && !wr->isSearchEditing()) {
// Text entry ended
webReaderTextEntry = false;
webReaderNeedsRefresh = false;
// fetchPage()/submitForm() handle their own rendering, or mode changed —
// let ui_task.loop() resume on next iteration
} else {
// Still typing — request debounced refresh
webReaderNeedsRefresh = true;
lastWebReaderRefresh = millis();
}
return;
} else {
// Not in text entry — clear flag so ui_task.loop() resumes
webReaderTextEntry = false;
// Q from HOME mode exits the web reader entirely (like text reader)
if ((key == 'q' || key == 'Q') && wr && wr->isHome() && !wr->isUrlEditing() && !wr->isSearchEditing()) {
Serial.println("Exiting web reader");
ui_task.gotoHomeScreen();
return;
}
// Route keys through normal UITask for navigation/scrolling
ui_task.injectKey(key);
return;
}
}
#endif
// Normal mode - not composing
switch (key) {
case 'c':
@@ -1128,25 +1651,74 @@ void handleKeyboardInput() {
ui_task.gotoTextReader();
break;
#ifndef HAS_4G_MODEM
case 'p':
#ifdef MECK_AUDIO_VARIANT
// Open audiobook player -- lazy-init Audio + screen on first use
// Open audiobook player - lazy-init Audio + screen on first use
Serial.println("Opening audiobook player");
if (!ui_task.getAudiobookScreen()) {
Serial.printf("Audiobook: lazy init -- free heap: %d, largest block: %d\n",
Serial.printf("Audiobook: lazy init - free heap: %d, largest block: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
audio = new Audio();
AudiobookPlayerScreen* abScreen = new AudiobookPlayerScreen(&ui_task, audio);
abScreen->setSDReady(sdCardReady);
ui_task.setAudiobookScreen(abScreen);
Serial.printf("Audiobook: init complete -- free heap: %d\n", ESP.getFreeHeap());
Serial.printf("Audiobook: init complete - free heap: %d\n", ESP.getFreeHeap());
}
ui_task.gotoAudiobookPlayer();
#else
Serial.println("Audio not available on this build variant");
ui_task.showAlert("No audio hardware", 1500);
#endif
break;
#endif
#ifdef HAS_4G_MODEM
case 't':
// Open SMS (4G variant only)
Serial.println("Opening SMS");
ui_task.gotoSMSScreen();
break;
#endif
#ifdef MECK_WEB_READER
case 'b':
// Open web reader (browser)
Serial.println("Opening web reader");
{
static bool webReaderWifiReady = false;
if (!webReaderWifiReady) {
// WiFi needs ~40KB contiguous heap. The BLE controller holds ~30KB,
// leaving only ~30KB largest block. We MUST release BLE memory first.
//
// This disables BLE for the duration of the session.
// BLE comes back on reboot.
Serial.printf("WebReader: heap BEFORE BT release: free=%d, largest=%d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
// 1) Stop BLE controller (disable + deinit)
btStop();
delay(50);
// 2) Release the BT controller's reserved memory region back to heap
esp_bt_controller_mem_release(ESP_BT_MODE_BTDM);
delay(50);
Serial.printf("WebReader: heap AFTER BT release: free=%d, largest=%d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
// 3) Now init WiFi while we have maximum contiguous heap
if (WiFi.mode(WIFI_STA)) {
Serial.println("WebReader: WiFi STA init OK");
webReaderWifiReady = true;
} else {
Serial.println("WebReader: WiFi STA init FAILED even after BT release");
// Clean up partial WiFi init to avoid memory leak
WiFi.mode(WIFI_OFF);
}
Serial.printf("WebReader: heap after WiFi init: free=%d, largest=%d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
}
}
ui_task.gotoWebReader();
break;
#endif
case 'n':
// Open notes
@@ -1163,9 +1735,13 @@ void handleKeyboardInput() {
break;
case 's':
// Open settings (from home), or navigate down on channel/contacts/admin
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) {
ui_task.injectKey('s'); // Pass directly for channel/contacts scrolling
// Open settings (from home), or navigate down on channel/contacts/admin/web
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()
#ifdef MECK_WEB_READER
|| ui_task.isOnWebReader()
#endif
) {
ui_task.injectKey('s'); // Pass directly for scrolling
} else {
Serial.println("Opening settings");
ui_task.gotoSettingsScreen();
@@ -1174,8 +1750,12 @@ void handleKeyboardInput() {
case 'w':
// Navigate up/previous (scroll on channel screen)
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) {
ui_task.injectKey('w'); // Pass directly for channel/contacts switching
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()
#ifdef MECK_WEB_READER
|| ui_task.isOnWebReader()
#endif
) {
ui_task.injectKey('w'); // Pass directly for scrolling
} else {
Serial.println("Nav: Previous");
ui_task.injectKey(0xF2); // KEY_PREV
@@ -1230,18 +1810,51 @@ void handleKeyboardInput() {
Serial.printf("Selected contact type=%d idx=%d\n", ctype, idx);
}
} else if (ui_task.isOnChannelScreen()) {
// Don't enter compose if path overlay is showing
// If path overlay is showing, Enter copies path text to compose buffer
ChannelScreen* chScr2 = (ChannelScreen*)ui_task.getChannelScreen();
if (chScr2 && chScr2->isShowingPathOverlay()) {
char pathText[138];
int pathLen = chScr2->formatPathAsText(pathText, sizeof(pathText));
if (pathLen > 0) {
int copyLen = pathLen < 137 ? pathLen : 137;
memcpy(composeBuffer, pathText, copyLen);
composeBuffer[copyLen] = '\0';
composePos = copyLen;
} else {
composeBuffer[0] = '\0';
composePos = 0;
}
composeDM = false;
composeDMContactIdx = -1;
composeChannelIdx = ui_task.getChannelScreenViewIdx();
composeMode = true;
chScr2->dismissPathOverlay();
Serial.printf("Compose with path, channel %d, prefill %d chars\n", composeChannelIdx, composePos);
drawComposeScreen();
lastComposeRefresh = millis();
break;
}
composeDM = false;
composeDMContactIdx = -1;
composeChannelIdx = ui_task.getChannelScreenViewIdx();
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx);
// If reply select mode is active, pre-fill @SenderName
char replySender[32];
if (chScr2 && chScr2->isReplySelectMode()
&& chScr2->getReplySelectSender(replySender, sizeof(replySender))) {
int prefixLen = snprintf(composeBuffer, sizeof(composeBuffer),
"@%s ", replySender);
composePos = prefixLen;
chScr2->exitReplySelect();
Serial.printf("Reply compose to @%s, channel %d\n",
replySender, composeChannelIdx);
} else {
composeBuffer[0] = '\0';
composePos = 0;
if (chScr2) chScr2->exitReplySelect(); // Clean up if somehow active
Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx);
}
drawComposeScreen();
lastComposeRefresh = millis();
} else {
@@ -1250,16 +1863,69 @@ void handleKeyboardInput() {
}
break;
case 'x':
// Export contacts to SD card (contacts screen only)
if (ui_task.isOnContactsScreen()) {
Serial.println("Contacts: Exporting to SD...");
int exported = exportContactsToSD();
if (exported >= 0) {
char alertBuf[48];
snprintf(alertBuf, sizeof(alertBuf), "Exported %d to SD", exported);
ui_task.showAlert(alertBuf, 2000);
} else {
ui_task.showAlert("Export failed (no SD?)", 2000);
}
}
break;
case 'r':
// Reply select mode (channel screen) or import contacts (contacts screen)
if (ui_task.isOnChannelScreen()) {
ui_task.injectKey('r');
} else if (ui_task.isOnContactsScreen()) {
Serial.println("Contacts: Importing from SD...");
int added = importContactsFromSD();
if (added > 0) {
// Invalidate the contacts screen cache so it rebuilds
ContactsScreen* cs2 = (ContactsScreen*)ui_task.getContactsScreen();
if (cs2) cs2->invalidateCache();
char alertBuf[48];
snprintf(alertBuf, sizeof(alertBuf), "+%d imported (%d total)",
added, (int)the_mesh.getNumContacts());
ui_task.showAlert(alertBuf, 2500);
} else if (added == 0) {
ui_task.showAlert("No new contacts to add", 2000);
} else {
ui_task.showAlert("Import failed (no backup?)", 2000);
}
}
break;
case 'q':
case '\b':
// If channel screen path overlay is showing, dismiss it instead of going home
// If channel screen reply select or path overlay is showing, dismiss it
if (ui_task.isOnChannelScreen()) {
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
if (chScr && chScr->isReplySelectMode()) {
ui_task.injectKey('q');
break;
}
if (chScr && chScr->isShowingPathOverlay()) {
ui_task.injectKey('q');
break;
}
}
#ifdef MECK_WEB_READER
// If web reader is in reading/link/wifi mode, inject q for internal navigation
// (reading→home, wifi→home). Only exit to firmware home if already on web home.
if (ui_task.isOnWebReader()) {
WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen();
if (wr && !wr->isHome()) {
ui_task.injectKey('q');
break;
}
}
#endif
// Go back to home screen (admin mode handled above)
Serial.println("Nav: Back to home");
ui_task.gotoHomeScreen();
@@ -1284,6 +1950,13 @@ void handleKeyboardInput() {
break;
default:
#ifdef MECK_WEB_READER
// Pass unhandled keys to web reader (l=link, g=go, k=bookmark, 0-9=link#)
if (ui_task.isOnWebReader()) {
ui_task.injectKey(key);
break;
}
#endif
Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key);
break;
}
@@ -1480,7 +2153,10 @@ void sendComposedMessage() {
// ============================================================================
// ESP32-audioI2S CALLBACKS
// ============================================================================
#ifdef MECK_AUDIO_VARIANT
// The audio library calls these global functions - must be defined at file scope.
// Not available on 4G variant (no audio hardware).
#ifndef HAS_4G_MODEM
void audio_info(const char *info) {
Serial.printf("Audio: %s\n", info);
}
@@ -1494,6 +2170,6 @@ void audio_eof_mp3(const char *info) {
abPlayer->onEOF();
}
}
#endif // MECK_AUDIO_VARIANT
#endif // !HAS_4G_MODEM
#endif // LilyGo_TDeck_Pro

View File

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

View File

@@ -14,7 +14,7 @@
// Maximum messages to store in history
#define CHANNEL_MSG_HISTORY_SIZE 300
#define CHANNEL_MSG_TEXT_LEN 160
#define MSG_PATH_MAX 8 // Max repeater hops stored per message
#define MSG_PATH_MAX 20 // Max repeater hops stored per message
#ifndef MAX_GROUP_CHANNELS
#define MAX_GROUP_CHANNELS 20
@@ -24,7 +24,7 @@
// On-disk format for message persistence (SD card)
// ---------------------------------------------------------------------------
#define MSG_FILE_MAGIC 0x4D434853 // "MCHS" - MeshCore History Store
#define MSG_FILE_VERSION 2
#define MSG_FILE_VERSION 3 // v3: MSG_PATH_MAX increased to 20
#define MSG_FILE_PATH "/meshcore/messages.bin"
struct __attribute__((packed)) MsgFileHeader {
@@ -44,7 +44,7 @@ struct __attribute__((packed)) MsgFileRecord {
uint8_t reserved;
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes (first byte of pub key)
char text[CHANNEL_MSG_TEXT_LEN];
// 176 bytes total
// 188 bytes total
};
class UITask; // Forward declaration
@@ -74,16 +74,31 @@ private:
uint8_t _viewChannelIdx; // Which channel we're currently viewing
bool _sdReady; // SD card is available for persistence
bool _showPathOverlay; // Show path detail overlay for last received msg
int _pathScrollPos; // Scroll offset within path overlay hop list
int _pathHopsVisible; // Hops that fit on screen (set during render)
// Reply select mode — press R to pick a message and reply with @mention
bool _replySelectMode; // True when user is picking a message to reply to
int _replySelectPos; // Index into chronological channelMsgs[] (0=oldest)
int _replyChannelMsgCount; // Cached count from last render (for input bounds)
// Per-channel unread message counts (standalone mode)
// Index 0..MAX_GROUP_CHANNELS-1 for channel messages
// Index MAX_GROUP_CHANNELS for DMs (channel_idx == 0xFF)
int _unread[MAX_GROUP_CHANNELS + 1];
public:
ChannelScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0),
_msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false) {
_msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false), _pathScrollPos(0), _pathHopsVisible(20),
_replySelectMode(false), _replySelectPos(-1), _replyChannelMsgCount(0) {
// Initialize all messages as invalid
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
_messages[i].valid = false;
memset(_messages[i].path, 0, MSG_PATH_MAX);
}
// Initialize unread counts
memset(_unread, 0, sizeof(_unread));
}
void setSDReady(bool ready) { _sdReady = ready; }
@@ -118,6 +133,18 @@ public:
// Reset scroll to show newest message
_scrollPos = 0;
_showPathOverlay = false; // Dismiss overlay on new message
_pathScrollPos = 0;
_replySelectMode = false; // Dismiss reply select on new message
_replySelectPos = -1;
// Track unread count for this channel (only for received messages, not sent)
// path_len == 0 means locally sent
if (path_len != 0) {
int unreadSlot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : channel_idx;
if (unreadSlot >= 0 && unreadSlot <= MAX_GROUP_CHANNELS) {
_unread[unreadSlot]++;
}
}
// Persist to SD card
saveToSD();
@@ -137,8 +164,107 @@ public:
int getMessageCount() const { return _msgCount; }
uint8_t getViewChannelIdx() const { return _viewChannelIdx; }
void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; _showPathOverlay = false; }
void setViewChannelIdx(uint8_t idx) {
_viewChannelIdx = idx;
_scrollPos = 0;
_showPathOverlay = false;
_pathScrollPos = 0;
markChannelRead(idx);
}
bool isShowingPathOverlay() const { return _showPathOverlay; }
void dismissPathOverlay() { _showPathOverlay = false; _pathScrollPos = 0; }
// --- Reply select mode (R key → pick a message → Enter to @mention reply) ---
bool isReplySelectMode() const { return _replySelectMode; }
void exitReplySelect() { _replySelectMode = false; _replySelectPos = -1; }
// Extract sender name from a "Sender: message" formatted text.
// Returns true if a sender was found, fills senderBuf (null-terminated).
static bool extractSenderName(const char* msgText, char* senderBuf, int bufLen) {
const char* colon = strstr(msgText, ": ");
if (!colon || colon == msgText) return false;
int nameLen = colon - msgText;
if (nameLen >= bufLen) nameLen = bufLen - 1;
memcpy(senderBuf, msgText, nameLen);
senderBuf[nameLen] = '\0';
return true;
}
// Get the sender name of the currently selected message in reply select mode.
// Returns true and fills senderBuf if valid selection exists.
bool getReplySelectSender(char* senderBuf, int bufLen) {
if (!_replySelectMode || _replySelectPos < 0) return false;
// Rebuild the channel message list (same logic as render)
static int rsMsgs[CHANNEL_MSG_HISTORY_SIZE];
int count = 0;
for (int i = 0; i < _msgCount && count < CHANNEL_MSG_HISTORY_SIZE; i++) {
int idx = _newestIdx - i;
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
rsMsgs[count++] = idx;
}
}
// Reverse to chronological (oldest first)
for (int l = 0, r = count - 1; l < r; l++, r--) {
int t = rsMsgs[l]; rsMsgs[l] = rsMsgs[r]; rsMsgs[r] = t;
}
if (_replySelectPos >= count) return false;
int idx = rsMsgs[_replySelectPos];
return extractSenderName(_messages[idx].text, senderBuf, bufLen);
}
// Get the ChannelMessage pointer for the currently selected reply message.
ChannelMessage* getReplySelectMsg() {
if (!_replySelectMode || _replySelectPos < 0) return nullptr;
static int rsMsgs[CHANNEL_MSG_HISTORY_SIZE];
int count = 0;
for (int i = 0; i < _msgCount && count < CHANNEL_MSG_HISTORY_SIZE; i++) {
int idx = _newestIdx - i;
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
rsMsgs[count++] = idx;
}
}
for (int l = 0, r = count - 1; l < r; l++, r--) {
int t = rsMsgs[l]; rsMsgs[l] = rsMsgs[r]; rsMsgs[r] = t;
}
if (_replySelectPos >= count) return nullptr;
return &_messages[rsMsgs[_replySelectPos]];
}
// --- Unread message tracking (standalone mode) ---
// Mark all messages for a channel as read
void markChannelRead(uint8_t channel_idx) {
int slot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : channel_idx;
if (slot >= 0 && slot <= MAX_GROUP_CHANNELS) {
_unread[slot] = 0;
}
}
// Get unread count for a specific channel
int getUnreadForChannel(uint8_t channel_idx) const {
int slot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : channel_idx;
if (slot >= 0 && slot <= MAX_GROUP_CHANNELS) {
return _unread[slot];
}
return 0;
}
// Get total unread across all channels
int getTotalUnread() const {
int total = 0;
for (int i = 0; i <= MAX_GROUP_CHANNELS; i++) {
total += _unread[i];
}
return total;
}
// Find the newest RECEIVED message for the current channel
// (path_len != 0 means received, path_len 0 = locally sent)
@@ -155,6 +281,24 @@ public:
return nullptr;
}
// Format the path of the newest received message as paste-ready text
// Output: comma-separated hex prefixes e.g. "30, 3b, 9b, 05, e8, 36"
// Returns length written (0 if no path available)
int formatPathAsText(char* buf, int bufLen) {
ChannelMessage* msg = getNewestReceivedMsg();
if (!msg || msg->path_len == 0 || msg->path_len == 0xFF) return 0;
int pos = 0;
int plen = msg->path_len < MSG_PATH_MAX ? msg->path_len : MSG_PATH_MAX;
for (int h = 0; h < plen && pos < bufLen - 1; h++) {
if (h > 0) pos += snprintf(buf + pos, bufLen - pos, ", ");
pos += snprintf(buf + pos, bufLen - pos, "%02x", msg->path[h]);
}
return pos;
}
// -----------------------------------------------------------------------
// SD card persistence
// -----------------------------------------------------------------------
@@ -360,19 +504,46 @@ public:
}
y += lineH + 2;
// Show each hop resolved against contacts
// Show each hop resolved against contacts (scrollable)
if (plen > 0 && plen != 0xFF) {
int displayHops = plen < MSG_PATH_MAX ? plen : MSG_PATH_MAX;
int maxY = display.height() - 26;
int footerReserve = 26; // footer + divider
int scrollBarW = 4;
int maxY = display.height() - footerReserve;
int hopAreaTop = y;
for (int h = 0; h < displayHops && y + lineH <= maxY; h++) {
// Calculate how many hops fit in the visible area
int hopsVisible = (maxY - hopAreaTop) / lineH;
if (hopsVisible < 1) hopsVisible = 1;
_pathHopsVisible = hopsVisible; // Cache for input handler
bool needsScroll = displayHops > hopsVisible;
// Clamp scroll position
int maxScroll = displayHops - hopsVisible;
if (maxScroll < 0) maxScroll = 0;
if (_pathScrollPos > maxScroll) _pathScrollPos = maxScroll;
// Available text width (narrower if scroll bar present)
int textRight = needsScroll ? display.width() - scrollBarW - 2 : display.width();
(void)textRight; // reserved for future truncation
int startHop = _pathScrollPos;
int endHop = startHop + hopsVisible;
if (endHop > displayHops) endHop = displayHops;
for (int h = startHop; h < endHop && y + lineH <= maxY; h++) {
uint8_t hopHash = msg->path[h];
display.setCursor(0, y);
display.setColor(DisplayDriver::LIGHT);
sprintf(tmp, " %d: ", h + 1);
display.print(tmp);
// Try to resolve: prefer repeaters, then any contact
// Always show hex prefix first
display.setColor(DisplayDriver::LIGHT);
sprintf(tmp, "%02X ", hopHash);
display.print(tmp);
// Try to resolve name: prefer repeaters, then any contact
bool resolved = false;
int numContacts = the_mesh.getNumContacts();
ContactInfo contact;
@@ -400,14 +571,32 @@ public:
}
}
}
// Fallback: show hex hash
// No name resolved - hex prefix already shown, add "?" marker
if (!resolved) {
display.setColor(DisplayDriver::LIGHT);
sprintf(tmp, "?%02X", hopHash);
display.print(tmp);
display.print("?");
}
y += lineH;
}
// Scroll bar (right edge) when hops exceed visible area
if (needsScroll) {
int sbX = display.width() - scrollBarW;
int sbTop = hopAreaTop;
int sbHeight = maxY - hopAreaTop;
// Outline
display.setColor(DisplayDriver::LIGHT);
display.drawRect(sbX, sbTop, scrollBarW, sbHeight);
// Proportional thumb
int thumbH = (hopsVisible * sbHeight) / displayHops;
if (thumbH < 4) thumbH = 4;
int thumbY = sbTop + (_pathScrollPos * (sbHeight - thumbH)) / maxScroll;
display.setColor(DisplayDriver::GREEN);
for (int ty = thumbY + 1; ty < thumbY + thumbH - 1; ty++)
display.drawRect(sbX + 1, ty, scrollBarW - 2, 1);
}
}
}
@@ -418,6 +607,16 @@ public:
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
display.print("Q:Back");
// Show scroll hint if path is scrollable
if (msg && msg->path_len > _pathHopsVisible && msg->path_len != 0xFF) {
const char* scrollHint = "W/S:Scrl";
int scrollW = display.getTextWidth(scrollHint);
display.setCursor((display.width() - scrollW) / 2, footerY);
display.print(scrollHint);
}
const char* copyHint = "Ent:Copy";
display.setCursor(display.width() - display.getTextWidth(copyHint) - 2, footerY);
display.print(copyHint);
#if AUTO_OFF_MILLIS == 0
return 5000;
@@ -468,6 +667,9 @@ public:
int tmp = channelMsgs[l]; channelMsgs[l] = channelMsgs[r]; channelMsgs[r] = tmp;
}
// Cache for reply select input bounds
_replyChannelMsgCount = numChannelMsgs;
// Clamp scroll position to valid range
int maxScroll = numChannelMsgs > _msgsPerPage ? numChannelMsgs - _msgsPerPage : 0;
if (_scrollPos > maxScroll) _scrollPos = maxScroll;
@@ -480,35 +682,66 @@ public:
// Display messages oldest-to-newest (top to bottom)
int msgsDrawn = 0;
bool screenFull = false;
bool lastMsgTruncated = false; // Did the last message get clipped by footer?
for (int i = startIdx; i < numChannelMsgs && y + lineHeight <= maxY; i++) {
int idx = channelMsgs[i];
ChannelMessage* msg = &_messages[idx];
// Reply select: is this the currently selected message?
bool isSelected = (_replySelectMode && i == _replySelectPos);
// Highlight: single fillRect for the entire message area, then
// draw DARK text on top (same pattern as web reader bookmarks).
// Because message height depends on word-wrap, we fill a generous
// area up-front and erase the excess after rendering.
int yStart = y;
int contentW = display.width();
int maxLinesPerMsg = 8;
if (isSelected) {
int maxFillH = (maxLinesPerMsg + 1) * lineHeight + 2;
int availH = maxY - y;
if (maxFillH > availH) maxFillH = availH;
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y + 5, contentW, maxFillH);
}
// Time indicator with hop count - inline on same line as message start
display.setCursor(0, y);
display.setColor(DisplayDriver::YELLOW);
display.setColor(isSelected ? DisplayDriver::DARK : DisplayDriver::YELLOW);
uint32_t age = _rtc->getCurrentTime() - msg->timestamp;
if (age < 60) {
sprintf(tmp, "(%d) %ds ", msg->path_len == 0xFF ? 0 : msg->path_len, age);
} else if (age < 3600) {
sprintf(tmp, "(%d) %dm ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 60);
} else if (age < 86400) {
sprintf(tmp, "(%d) %dh ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 3600);
if (isSelected) {
// Show > marker for selected message, replacing the hop count
if (age < 60) {
sprintf(tmp, ">%ds ", age);
} else if (age < 3600) {
sprintf(tmp, ">%dm ", age / 60);
} else if (age < 86400) {
sprintf(tmp, ">%dh ", age / 3600);
} else {
sprintf(tmp, ">%dd ", age / 86400);
}
} else {
sprintf(tmp, "(%d) %dd ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 86400);
if (age < 60) {
sprintf(tmp, "(%d) %ds ", msg->path_len == 0xFF ? 0 : msg->path_len, age);
} else if (age < 3600) {
sprintf(tmp, "(%d) %dm ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 60);
} else if (age < 86400) {
sprintf(tmp, "(%d) %dh ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 3600);
} else {
sprintf(tmp, "(%d) %dd ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 86400);
}
}
display.print(tmp);
// DO NOT advance y - message text continues on the same line
// Message text with character wrapping and inline emoji support
// (continues after timestamp on first line)
display.setColor(DisplayDriver::LIGHT);
display.setColor(isSelected ? DisplayDriver::DARK : DisplayDriver::LIGHT);
int textLen = strlen(msg->text);
int pos = 0;
int linesForThisMsg = 0;
int maxLinesPerMsg = 8;
char charStr[2] = {0, 0};
// Track position in pixels for emoji placement
@@ -602,12 +835,30 @@ public:
}
}
// Check if this message was clipped (not all text rendered)
lastMsgTruncated = (pos < textLen);
// If we didn't end on a full line, still count it
if (px > 0) {
y += lineHeight;
}
y += 2; // Small gap between messages
// Erase excess highlight below the actual message.
// The upfront fillRect covered a max area; restore the unused
// portion back to background so subsequent messages render cleanly.
if (isSelected) {
int usedH = y - yStart;
int maxFillH = (maxLinesPerMsg + 1) * lineHeight + 2;
int availH = maxY - yStart;
if (maxFillH > availH) maxFillH = availH;
if (usedH < maxFillH) {
display.setColor(DisplayDriver::DARK);
display.fillRect(0, y + 5, contentW, maxFillH - usedH);
}
}
msgsDrawn++;
if (y + lineHeight > maxY) screenFull = true;
}
@@ -617,7 +868,12 @@ public:
// prevents a feedback loop where variable-height messages cause
// msgsPerPage to oscillate, shifting startIdx every render (flicker).
if (screenFull && msgsDrawn > 0 && _scrollPos == 0) {
_msgsPerPage = msgsDrawn;
// If the last message was truncated (text clipped by footer), exclude it
// from the page count so next render starts one message later and the
// bottom message fits completely.
int effectiveDrawn = lastMsgTruncated ? msgsDrawn - 1 : msgsDrawn;
if (effectiveDrawn < 1) effectiveDrawn = 1;
_msgsPerPage = effectiveDrawn;
}
// --- Scroll bar (emoji picker style) ---
@@ -655,12 +911,17 @@ public:
display.setColor(DisplayDriver::YELLOW);
// Left side: abbreviated controls
display.print("Q:Bck A/D:Ch V:Pth");
// Right side: Ent:New
const char* rightText = "Ent:New";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
if (_replySelectMode) {
display.print("W/S:Sel V:Pth Q:X");
const char* rightText = "Ent:Reply";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
} else {
display.print("Q:Bck A/D:Ch R:Rply");
const char* rightText = "Ent:New";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
}
#if AUTO_OFF_MILLIS == 0 // e-ink
return 5000;
@@ -670,21 +931,107 @@ public:
}
bool handleInput(char c) override {
// If overlay is showing, only handle dismiss
// If overlay is showing, handle scroll and dismiss
if (_showPathOverlay) {
if (c == 'q' || c == 'Q' || c == '\b' || c == 'v' || c == 'V') {
_showPathOverlay = false;
_pathScrollPos = 0;
return true;
}
return true; // Consume all keys while overlay is up
if (c == '\r' || c == 13) {
return false; // Let main.cpp handle Enter for copy-to-compose
}
// W - scroll up in hop list
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_pathScrollPos > 0) {
_pathScrollPos--;
}
return true;
}
// S - scroll down in hop list
if (c == 's' || c == 'S' || c == 0xF1) {
ChannelMessage* msg = getNewestReceivedMsg();
if (msg && msg->path_len > 0 && msg->path_len != 0xFF) {
int totalHops = msg->path_len < MSG_PATH_MAX ? msg->path_len : MSG_PATH_MAX;
if (_pathScrollPos < totalHops - _pathHopsVisible) {
_pathScrollPos++;
}
}
return true;
}
return true; // Consume all other keys while overlay is up
}
// --- Reply select mode ---
if (_replySelectMode) {
// Q - exit reply select
if (c == 'q' || c == 'Q' || c == '\b') {
_replySelectMode = false;
_replySelectPos = -1;
return true;
}
// W - select older message (lower index in chronological order)
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_replySelectPos > 0) {
_replySelectPos--;
// Auto-scroll to keep selection visible
int startIdx = _replyChannelMsgCount - _msgsPerPage - _scrollPos;
if (startIdx < 0) startIdx = 0;
if (_replySelectPos < startIdx) {
_scrollPos++;
}
}
return true;
}
// S - select newer message (higher index in chronological order)
if (c == 's' || c == 'S' || c == 0xF1) {
if (_replySelectPos < _replyChannelMsgCount - 1) {
_replySelectPos++;
// Auto-scroll to keep selection visible
int endIdx = _replyChannelMsgCount - _scrollPos;
if (_replySelectPos >= endIdx) {
if (_scrollPos > 0) _scrollPos--;
}
}
return true;
}
// V - view path for the SELECTED message (not just newest received)
if (c == 'v' || c == 'V') {
// Path overlay will use getNewestReceivedMsg() — for v1 this is fine.
// The user can see the selected message's hop count in the > marker.
ChannelMessage* selMsg = getReplySelectMsg();
if (selMsg && selMsg->path_len != 0) {
_showPathOverlay = true;
_pathScrollPos = 0;
}
return true;
}
// Enter - let main.cpp handle (enters compose with @mention)
if (c == '\r' || c == 13) {
return false;
}
return true; // Consume all other keys in reply select
}
int channelMsgCount = getMessageCountForChannel();
// R - enter reply select mode
if (c == 'r' || c == 'R') {
if (channelMsgCount > 0) {
_replySelectMode = true;
// Start with newest message selected
_replySelectPos = _replyChannelMsgCount > 0
? _replyChannelMsgCount - 1 : channelMsgCount - 1;
return true;
}
return false;
}
// V - show path detail for last received message
if (c == 'v' || c == 'V') {
if (getNewestReceivedMsg() != nullptr) {
_showPathOverlay = true;
_pathScrollPos = 0;
return true;
}
return false; // No received messages to show
@@ -708,6 +1055,8 @@ public:
// A - previous channel
if (c == 'a' || c == 'A') {
_replySelectMode = false;
_replySelectPos = -1;
if (_viewChannelIdx > 0) {
_viewChannelIdx--;
} else {
@@ -721,11 +1070,14 @@ public:
}
}
_scrollPos = 0;
markChannelRead(_viewChannelIdx);
return true;
}
// D - next channel
if (c == 'd' || c == 'D') {
_replySelectMode = false;
_replySelectPos = -1;
ChannelDetails ch;
uint8_t nextIdx = _viewChannelIdx + 1;
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
@@ -734,6 +1086,7 @@ public:
_viewChannelIdx = 0;
}
_scrollPos = 0;
markChannelRead(_viewChannelIdx);
return true;
}

View File

@@ -18,6 +18,7 @@ public:
FILTER_REPEATER,
FILTER_ROOM, // Room servers
FILTER_SENSOR,
FILTER_FAVOURITE, // Contacts marked as favourite (any type)
FILTER_COUNT // keep last
};
@@ -30,9 +31,9 @@ private:
// Cached filtered contact indices for efficient scrolling
// We rebuild this on filter change or when entering the screen
static const int MAX_VISIBLE = 400; // matches MAX_CONTACTS build flag
uint16_t _filteredIdx[MAX_VISIBLE]; // indices into contact table
uint32_t _filteredTs[MAX_VISIBLE]; // cached last_advert_timestamp for sorting
// Arrays allocated in PSRAM when available (supports 1000+ contacts)
uint16_t* _filteredIdx; // indices into contact table
uint32_t* _filteredTs; // cached last_advert_timestamp for sorting
int _filteredCount; // how many contacts match current filter
bool _cacheValid;
@@ -48,6 +49,7 @@ private:
case FILTER_REPEATER: return "Rptr";
case FILTER_ROOM: return "Room";
case FILTER_SENSOR: return "Sens";
case FILTER_FAVOURITE: return "Fav";
default: return "?";
}
}
@@ -61,7 +63,7 @@ private:
}
}
bool matchesFilter(uint8_t adv_type) const {
bool matchesFilter(uint8_t adv_type, uint8_t flags = 0) const {
switch (_filter) {
case FILTER_ALL: return true;
case FILTER_CHAT: return adv_type == ADV_TYPE_CHAT;
@@ -70,6 +72,7 @@ private:
case FILTER_SENSOR: return (adv_type != ADV_TYPE_CHAT &&
adv_type != ADV_TYPE_REPEATER &&
adv_type != ADV_TYPE_ROOM);
case FILTER_FAVOURITE: return (flags & 0x01) != 0;
default: return true;
}
}
@@ -78,9 +81,9 @@ private:
_filteredCount = 0;
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo contact;
for (uint32_t i = 0; i < numContacts && _filteredCount < MAX_VISIBLE; i++) {
for (uint32_t i = 0; i < numContacts && _filteredCount < MAX_CONTACTS; i++) {
if (the_mesh.getContactByIdx(i, contact)) {
if (matchesFilter(contact.type)) {
if (matchesFilter(contact.type, contact.flags)) {
_filteredIdx[_filteredCount] = (uint16_t)i;
_filteredTs[_filteredCount] = contact.last_advert_timestamp;
_filteredCount++;
@@ -88,7 +91,7 @@ private:
}
}
// Sort by last_advert_timestamp descending (most recently seen first)
// Simple insertion sort — fine for up to 400 entries on ESP32
// Insertion sort fine for up to ~1000 entries on ESP32
for (int i = 1; i < _filteredCount; i++) {
uint16_t tmpIdx = _filteredIdx[i];
uint32_t tmpTs = _filteredTs[i];
@@ -130,7 +133,15 @@ private:
public:
ContactsScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _scrollPos(0), _filter(FILTER_ALL),
_filteredCount(0), _cacheValid(false), _rowsPerPage(5) {}
_filteredCount(0), _cacheValid(false), _rowsPerPage(5) {
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
_filteredIdx = (uint16_t*)ps_calloc(MAX_CONTACTS, sizeof(uint16_t));
_filteredTs = (uint32_t*)ps_calloc(MAX_CONTACTS, sizeof(uint32_t));
#else
_filteredIdx = new uint16_t[MAX_CONTACTS]();
_filteredTs = new uint32_t[MAX_CONTACTS]();
#endif
}
void invalidateCache() { _cacheValid = false; }

View File

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

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

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

View File

@@ -6,6 +6,10 @@
#include <MeshCore.h>
#include "../NodePrefs.h"
#ifdef HAS_4G_MODEM
#include "ModemManager.h"
#endif
// Forward declarations
class UITask;
class MyMesh;
@@ -56,12 +60,20 @@ enum SettingsRowType : uint8_t {
ROW_TX_POWER, // TX power (1-20 dBm)
ROW_UTC_OFFSET, // UTC offset (-12 to +14)
ROW_MSG_NOTIFY, // Keyboard flash on new msg toggle
#ifdef HAS_4G_MODEM
ROW_MODEM_TOGGLE, // 4G modem enable/disable toggle (4G builds only)
#endif
ROW_CH_HEADER, // "--- Channels ---" separator
ROW_CHANNEL, // A channel entry (dynamic, index stored separately)
ROW_ADD_CHANNEL, // "+ Add Hashtag Channel"
ROW_INFO_HEADER, // "--- Info ---" separator
ROW_PUB_KEY, // Public key display
ROW_FIRMWARE, // Firmware version
#ifdef HAS_4G_MODEM
ROW_IMEI, // IMEI display (read-only)
ROW_OPERATOR_INFO, // Carrier/operator display (read-only)
ROW_APN, // APN setting (editable)
#endif
};
// ---------------------------------------------------------------------------
@@ -76,7 +88,11 @@ enum EditMode : uint8_t {
};
// Max rows in the settings list
#ifdef HAS_4G_MODEM
#define SETTINGS_MAX_ROWS 46 // Extra rows for IMEI, Carrier, APN
#else
#define SETTINGS_MAX_ROWS 40
#endif
#define SETTINGS_TEXT_BUF 33 // 32 chars + null
class SettingsScreen : public UIScreen {
@@ -85,7 +101,7 @@ private:
mesh::RTCClock* _rtc;
NodePrefs* _prefs;
// Row table †rebuilt whenever channels change
// Row table — rebuilt whenever channels change
struct Row {
SettingsRowType type;
uint8_t param; // channel index for ROW_CHANNEL, preset index for ROW_RADIO_PRESET
@@ -109,9 +125,14 @@ private:
// Onboarding mode
bool _onboarding;
// Dirty flag for radio params †prompt to apply
// Dirty flag for radio params — prompt to apply
bool _radioChanged;
// 4G modem state (runtime cache of config)
#ifdef HAS_4G_MODEM
bool _modemEnabled;
#endif
// ---------------------------------------------------------------------------
// Row table management
// ---------------------------------------------------------------------------
@@ -128,6 +149,9 @@ private:
addRow(ROW_TX_POWER);
addRow(ROW_UTC_OFFSET);
addRow(ROW_MSG_NOTIFY);
#ifdef HAS_4G_MODEM
addRow(ROW_MODEM_TOGGLE);
#endif
addRow(ROW_CH_HEADER);
// Enumerate current channels
@@ -145,6 +169,12 @@ private:
addRow(ROW_PUB_KEY);
addRow(ROW_FIRMWARE);
#ifdef HAS_4G_MODEM
addRow(ROW_IMEI);
addRow(ROW_OPERATOR_INFO);
addRow(ROW_APN);
#endif
// Clamp cursor
if (_cursor >= _numRows) _cursor = _numRows - 1;
if (_cursor < 0) _cursor = 0;
@@ -162,7 +192,11 @@ private:
bool isSelectable(int idx) const {
if (idx < 0 || idx >= _numRows) return false;
SettingsRowType t = _rows[idx].type;
return t != ROW_CH_HEADER && t != ROW_INFO_HEADER;
return t != ROW_CH_HEADER && t != ROW_INFO_HEADER
#ifdef HAS_4G_MODEM
&& t != ROW_IMEI && t != ROW_OPERATOR_INFO
#endif
;
}
void skipNonSelectable(int dir) {
@@ -212,11 +246,11 @@ private:
strncpy(newCh.name, chanName, sizeof(newCh.name));
newCh.name[31] = '\0';
// SHA-256 the channel name → first 16 bytes become the secret
// SHA-256 the channel name → first 16 bytes become the secret
uint8_t hash[32];
mesh::Utils::sha256(hash, 32, (const uint8_t*)chanName, strlen(chanName));
memcpy(newCh.channel.secret, hash, 16);
// Upper 16 bytes left as zero → setChannel uses 128-bit mode
// Upper 16 bytes left as zero → setChannel uses 128-bit mode
// Find next empty slot
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
@@ -289,6 +323,9 @@ public:
_cursor = 0;
_scrollTop = 0;
_radioChanged = false;
#ifdef HAS_4G_MODEM
_modemEnabled = ModemManager::loadEnabledConfig();
#endif
rebuildRows();
}
@@ -473,6 +510,14 @@ public:
display.print(tmp);
break;
#ifdef HAS_4G_MODEM
case ROW_MODEM_TOGGLE:
snprintf(tmp, sizeof(tmp), "4G Modem: %s",
_modemEnabled ? "ON" : "OFF");
display.print(tmp);
break;
#endif
case ROW_CH_HEADER:
display.setColor(DisplayDriver::YELLOW);
display.print("--- Channels ---");
@@ -522,7 +567,7 @@ public:
// Show first 8 bytes of pub key as hex (16 chars)
char hexBuf[17];
mesh::Utils::toHex(hexBuf, the_mesh.self_id.pub_key, 8);
snprintf(tmp, sizeof(tmp), "ID: %s", hexBuf);
snprintf(tmp, sizeof(tmp), "Node ID: %s", hexBuf);
display.print(tmp);
break;
}
@@ -531,6 +576,53 @@ public:
snprintf(tmp, sizeof(tmp), "FW: %s", FIRMWARE_VERSION);
display.print(tmp);
break;
#ifdef HAS_4G_MODEM
case ROW_IMEI: {
const char* imei = modemManager.getIMEI();
snprintf(tmp, sizeof(tmp), "IMEI: %s", imei[0] ? imei : "(unavailable)");
display.print(tmp);
break;
}
case ROW_OPERATOR_INFO: {
const char* op = modemManager.getOperator();
int bars = modemManager.getSignalBars();
if (op[0]) {
// Show carrier name with signal bar count
snprintf(tmp, sizeof(tmp), "Carrier: %s (%d/5)", op, bars);
} else {
snprintf(tmp, sizeof(tmp), "Carrier: (searching)");
}
display.print(tmp);
break;
}
case ROW_APN: {
if (editing && _editMode == EDIT_TEXT) {
snprintf(tmp, sizeof(tmp), "APN: %s_", _editBuf);
} else {
const char* apn = modemManager.getAPN();
const char* src = modemManager.getAPNSource();
if (apn[0]) {
// Truncate APN to fit: "APN: " (5) + apn (max 28) + " [x]" (4) = ~37 chars
char apnShort[29];
strncpy(apnShort, apn, 28);
apnShort[28] = '\0';
// Abbreviate source: auto→A, network→N, user→U, none→?
char srcChar = '?';
if (strcmp(src, "auto") == 0) srcChar = 'A';
else if (strcmp(src, "network") == 0) srcChar = 'N';
else if (strcmp(src, "user") == 0) srcChar = 'U';
snprintf(tmp, sizeof(tmp), "APN: %s [%c]", apnShort, srcChar);
} else {
snprintf(tmp, sizeof(tmp), "APN: (none)");
}
}
display.print(tmp);
break;
}
#endif
}
y += lineHeight;
@@ -647,6 +739,20 @@ public:
}
_editMode = EDIT_NONE;
}
#ifdef HAS_4G_MODEM
else if (type == ROW_APN) {
// Save the edited APN (even if empty — clears user override)
if (_editPos > 0) {
modemManager.setAPN(_editBuf);
Serial.printf("Settings: APN set to '%s'\n", _editBuf);
} else {
// Empty APN: remove user override, revert to auto-detection
ModemManager::saveAPNConfig("");
Serial.println("Settings: APN cleared (will auto-detect on next boot)");
}
_editMode = EDIT_NONE;
}
#endif
return true;
}
if (c == 'q' || c == 'Q' || c == 27) {
@@ -838,6 +944,25 @@ public:
Serial.printf("Settings: Msg flash notify = %s\n",
_prefs->kb_flash_notify ? "ON" : "OFF");
break;
#ifdef HAS_4G_MODEM
case ROW_MODEM_TOGGLE:
_modemEnabled = !_modemEnabled;
ModemManager::saveEnabledConfig(_modemEnabled);
if (_modemEnabled) {
modemManager.begin();
Serial.println("Settings: 4G modem ENABLED (started)");
} else {
modemManager.shutdown();
Serial.println("Settings: 4G modem DISABLED (shutdown)");
}
break;
case ROW_APN: {
// Start text editing with current APN as initial value
const char* currentApn = modemManager.getAPN();
startEditText(currentApn);
break;
}
#endif
case ROW_ADD_CHANNEL:
startEditText("");
break;
@@ -861,7 +986,7 @@ public:
}
}
// Q: back †if radio changed, prompt to apply first
// Q: back — if radio changed, prompt to apply first
if (c == 'q' || c == 'Q') {
if (_radioChanged) {
_editMode = EDIT_CONFIRM;

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

View File

@@ -40,6 +40,10 @@
#ifdef MECK_AUDIO_VARIANT
#include "AudiobookPlayerScreen.h"
#endif
#ifdef HAS_4G_MODEM
#include "SMSScreen.h"
#include "ModemManager.h"
#endif
class SplashScreen : public UIScreen {
UITask* _task;
@@ -114,6 +118,7 @@ class HomeScreen : public UIScreen {
NodePrefs* _node_prefs;
uint8_t _page;
bool _shutdown_init;
unsigned long _shutdown_at; // earliest time to proceed with shutdown (after e-ink refresh)
bool _editing_utc;
int8_t _saved_utc_offset; // for cancel/undo
AdvertPath recent[UI_RECENT_LIST_SIZE];
@@ -217,7 +222,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
public:
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs)
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
_shutdown_init(false), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
_shutdown_init(false), _shutdown_at(0), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
bool isEditingUTC() const { return _editing_utc; }
void cancelEditUTC() {
@@ -228,7 +233,7 @@ public:
}
void poll() override {
if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released
if (_shutdown_init && millis() >= _shutdown_at && !_task->isButtonPressed()) {
_task->shutdown();
}
}
@@ -292,7 +297,7 @@ public:
int y = 20;
display.setColor(DisplayDriver::YELLOW);
display.setTextSize(2);
sprintf(tmp, "MSG: %d", _task->getMsgCount());
sprintf(tmp, "MSG: %d", _task->getUnreadMsgCount());
display.drawTextCentered(display.width() / 2, y, tmp);
y += 18;
@@ -330,10 +335,16 @@ public:
y += 10;
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
y += 10;
#ifdef MECK_AUDIO_VARIANT
#ifdef HAS_4G_MODEM
display.drawTextCentered(display.width() / 2, y, "[E] Reader [T] Phone ");
#elif defined(MECK_AUDIO_VARIANT)
display.drawTextCentered(display.width() / 2, y, "[E] Reader [P] Audiobooks");
#else
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
#endif
#ifdef MECK_WEB_READER
y += 10;
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
#endif
y += 14;
@@ -391,8 +402,19 @@ public:
display.drawXbm((display.width() - 32) / 2, 18,
_task->isSerialEnabled() ? bluetooth_on : bluetooth_off,
32, 32);
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 53, "< Connected >");
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
display.setColor(DisplayDriver::RED);
display.setTextSize(2);
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
display.drawTextCentered(display.width() / 2, 53, tmp);
}
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL);
display.drawTextCentered(display.width() / 2, 72, "toggle: " PRESS_LABEL);
#endif
} else if (_page == HomePage::ADVERT) {
display.setColor(DisplayDriver::GREEN);
@@ -620,11 +642,21 @@ public:
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Remaining capacity
// Remaining capacity (clamped to design capacity — gauge FCC may be
// stale from factory defaults until a full charge cycle re-learns it)
uint16_t remCap = board.getRemainingCapacity();
uint16_t desCap = board.getDesignCapacity();
if (desCap > 0 && remCap > desCap) remCap = desCap;
display.drawTextLeftAlign(0, y, "remaining cap");
sprintf(buf, "%d mAh", remCap);
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Battery temperature
int16_t battTemp = board.getBattTemperature();
display.drawTextLeftAlign(0, y, "temperature");
sprintf(buf, "%d.%d C", battTemp / 10, abs(battTemp % 10));
display.drawTextRightAlign(display.width()-1, y, buf);
#endif
} else if (_page == HomePage::SHUTDOWN) {
display.setColor(DisplayDriver::GREEN);
@@ -723,7 +755,8 @@ public:
}
#endif
if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) {
_shutdown_init = true; // need to wait for button to be released
_shutdown_init = true;
_shutdown_at = millis() + 900; // allow e-ink refresh (644ms) before shutdown
return true;
}
return false;
@@ -881,6 +914,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
#ifdef HAS_4G_MODEM
sms_screen = new SMSScreen(this);
#endif
setCurrScreen(splash);
}
@@ -949,6 +985,13 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
// Add to channel history screen with channel index and path data
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path);
// If user is currently viewing this channel, mark it as read immediately
// (they can see the message arrive in real-time)
if (isOnChannelScreen() &&
((ChannelScreen *) channel_screen)->getViewChannelIdx() == channel_idx) {
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
}
#if defined(LilyGo_TDeck_Pro)
// T-Deck Pro: Don't interrupt user with popup - just show brief notification
// Messages are stored in channel history, accessible via 'M' key
@@ -1029,8 +1072,31 @@ void UITask::shutdown(bool restart){
if (restart) {
_board->reboot();
} else {
_display->turnOff();
// Disable BLE if active
if (_serial != NULL && _serial->isEnabled()) {
_serial->disable();
}
// Disable WiFi if active
#ifdef WIFI_SSID
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
#endif
// Disable GPS if active
#if ENV_INCLUDE_GPS == 1
{
extern GPSDutyCycle gpsDuty;
if (_sensors != NULL && _node_prefs != NULL && _node_prefs->gps_enabled) {
_sensors->setSettingValue("gps", "0");
gpsDuty.disable();
}
}
#endif
// Power off LoRa radio, display, and board
radio_driver.powerOff();
_display->turnOff();
_board->powerOff();
}
}
@@ -1306,6 +1372,10 @@ bool UITask::isEditingHomeScreen() const {
void UITask::gotoChannelScreen() {
((ChannelScreen *) channel_screen)->resetScroll();
// Mark the currently viewed channel as read
((ChannelScreen *) channel_screen)->markChannelRead(
((ChannelScreen *) channel_screen)->getViewChannelIdx()
);
setCurrScreen(channel_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
@@ -1386,10 +1456,27 @@ void UITask::gotoAudiobookPlayer() {
#endif
}
#ifdef HAS_4G_MODEM
void UITask::gotoSMSScreen() {
SMSScreen* smsScr = (SMSScreen*)sms_screen;
smsScr->activate();
setCurrScreen(sms_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#endif
uint8_t UITask::getChannelScreenViewIdx() const {
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
}
int UITask::getUnreadMsgCount() const {
return ((ChannelScreen *) channel_screen)->getTotalUnread();
}
void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {
// Format the message as "Sender: message"
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
@@ -1399,6 +1486,12 @@ void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, cons
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
}
void UITask::markChannelReadFromBLE(uint8_t channel_idx) {
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
// Trigger a refresh so the home screen unread count updates in real-time
_next_refresh = millis() + 200;
}
void UITask::gotoRepeaterAdmin(int contactIdx) {
// Lazy-initialize on first use (same pattern as audiobook player)
if (repeater_admin == nullptr) {
@@ -1424,6 +1517,31 @@ void UITask::gotoRepeaterAdmin(int contactIdx) {
_next_refresh = 100;
}
#ifdef MECK_WEB_READER
void UITask::gotoWebReader() {
// Lazy-initialize on first use (same pattern as audiobook player)
if (web_reader == nullptr) {
Serial.printf("WebReader: lazy init - free heap: %d, largest block: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
web_reader = new WebReaderScreen(this);
Serial.printf("WebReader: init complete - free heap: %d\n", ESP.getFreeHeap());
}
WebReaderScreen* wr = (WebReaderScreen*)web_reader;
if (_display != NULL) {
wr->enter(*_display);
}
// Heap diagnostic — check state after web reader entry (WiFi connects later)
Serial.printf("[HEAP] WebReader enter - free: %u, largest: %u, PSRAM: %u\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap(), ESP.getFreePsram());
setCurrScreen(web_reader);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
#endif
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
@@ -1438,6 +1556,14 @@ void UITask::onAdminCliResponse(const char* from_name, const char* text) {
}
}
void UITask::onAdminTelemetryResult(const uint8_t* data, uint8_t len) {
Serial.printf("[UITask] onAdminTelemetryResult: %d bytes, onAdmin=%d\n", len, isOnRepeaterAdmin());
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onTelemetryResult(data, len);
_next_refresh = 100; // trigger re-render
}
}
#ifdef MECK_AUDIO_VARIANT
bool UITask::isAudioPlayingInBackground() const {
if (!audiobook_screen) return false;

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;
@@ -58,7 +66,13 @@ class UITask : public AbstractUITask {
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();
@@ -90,9 +104,24 @@ public:
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; }
@@ -102,6 +131,9 @@ public:
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)
@@ -123,9 +155,13 @@ public:
// Add a sent message to the channel screen history
void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) override;
// Mark channel as read when BLE companion app syncs messages
void markChannelReadFromBLE(uint8_t channel_idx) override;
// Repeater admin callbacks
void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) override;
void onAdminCliResponse(const char* from_name, const char* text) override;
void onAdminTelemetryResult(const uint8_t* data, uint8_t len) override;
// Get current screen for checking state
UIScreen* getCurrentScreen() const { return curr; }
@@ -138,6 +174,9 @@ public:
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;

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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
) {

View File

@@ -167,8 +167,8 @@ static bool bq27220_writeControl(uint16_t subcmd) {
// 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
// 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
@@ -177,7 +177,85 @@ bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
Serial.printf("BQ27220: Design Capacity = %d mAh (target %d)\n", currentDC, designCapacity_mAh);
if (currentDC == designCapacity_mAh) {
Serial.println("BQ27220: Design Capacity already correct, skipping");
// Design Capacity correct, but check if Full Charge Capacity is sane.
// 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;
}
@@ -281,6 +359,39 @@ bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
Serial.printf("BQ27220: Verify in CFGUPDATE: DC bytes=0x%02X 0x%02X (%d mAh)\n",
verMSB, verLSB, (verMSB << 8) | verLSB);
// Step 4g: Also update Design Energy (address 0x92A1) while in CFG_UPDATE.
// Design Energy = capacity × 3.7V (nominal LiPo voltage).
// The gauge uses both DC and DE to compute Full Charge Capacity.
{
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
Wire.endTransmission();
delay(10);
uint8_t deOldMSB = bq27220_read8(0x40);
uint8_t deOldLSB = bq27220_read8(0x41);
uint8_t deOldChk = bq27220_read8(0x60);
uint8_t deLen = bq27220_read8(0x61);
uint8_t deNewMSB = (designEnergy >> 8) & 0xFF;
uint8_t deNewLSB = designEnergy & 0xFF;
uint8_t deTemp = (255 - deOldChk - deOldMSB - deOldLSB);
uint8_t deNewChk = 255 - ((deTemp + deNewMSB + deNewLSB) & 0xFF);
Serial.printf("BQ27220: Design Energy: old=%d new=%d mWh\n",
(deOldMSB << 8) | deOldLSB, designEnergy);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
Wire.write(deNewMSB); Wire.write(deNewLSB);
Wire.endTransmission();
delay(5);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x60); Wire.write(deNewChk); Wire.write(deLen);
Wire.endTransmission();
delay(10);
}
// Step 5: Exit CFG_UPDATE (with reinit to apply changes immediately)
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
Serial.println("BQ27220: Sent EXIT_CFG_UPDATE_REINIT, waiting...");
@@ -291,13 +402,16 @@ bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
Serial.printf("BQ27220: Design Capacity now reads %d mAh (expected %d)\n",
verifyDC, designCapacity_mAh);
uint16_t newFCC = bq27220_read16(BQ27220_REG_FULL_CAP);
Serial.printf("BQ27220: Full Charge Capacity: %d mAh\n", newFCC);
if (verifyDC == designCapacity_mAh) {
Serial.println("BQ27220: Configuration SUCCESS");
} else {
Serial.println("BQ27220: Configuration FAILED");
}
// Step 7: Seal the device
// Step 6: Seal the device
bq27220_writeControl(0x0030);
delay(5);
@@ -353,4 +467,14 @@ uint16_t TDeckBoard::getDesignCapacity() {
#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,6 +7,7 @@
#include <driver/rtc_io.h>
// BQ27220 Fuel Gauge Registers
#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
@@ -82,6 +83,9 @@ public:
// 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);

View File

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

View File

@@ -80,7 +80,6 @@ build_flags =
-D PIN_DISPLAY_BL=45
-D PIN_USER_BTN=0
-D CST328_PIN_RST=38
-D FIRMWARE_VERSION='"Meck v0.9.1A"'
-D ARDUINO_LOOP_STACK_SIZE=32768
build_src_filter = ${esp32_base.build_src_filter}
+<../variants/LilyGo_TDeck_Pro>
@@ -97,16 +96,18 @@ lib_deps =
; ---------------------------------------------------------------------------
; Audio + BLE companion (audio-player hardware with BLE phone bridging)
; MAX_CONTACTS=500 is near BLE protocol ceiling (MAX_CONTACTS/2 sent as uint8_t, max 510)
[env:meck_audio_ble]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=400
-D MAX_CONTACTS=500
-D MAX_GROUP_CHANNELS=20
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
-D MECK_AUDIO_VARIANT
-D MECK_WEB_READER=1
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -120,13 +121,15 @@ lib_deps =
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=350
-D MAX_GROUP_CHANNELS=40
-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}
@@ -142,15 +145,19 @@ lib_deps =
bitbank2/JPEGDEC
; 4G + BLE companion (4G modem hardware, no audio — GPIO conflict with PCM5102A)
; MAX_CONTACTS=500 is near BLE protocol ceiling (MAX_CONTACTS/2 sent as uint8_t, max 510)
[env:meck_4g_ble]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=400
-D MAX_CONTACTS=500
-D MAX_GROUP_CHANNELS=20
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v0.9.5.4G"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -160,3 +167,29 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
; 4G standalone (4G modem hardware, no BLE — maximum battery + cellular features)
; No BLE_PIN_CODE: BLE never initializes, saving ~30KB heap + radio power.
; MECK_WEB_READER enabled: works better without BLE (no teardown dance needed,
; more free heap from boot). WiFi-first with cellular PPP fallback (future).
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
[env:meck_4g_standalone]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=1500
-D MAX_GROUP_CHANNELS=20
-D OFFLINE_QUEUE_SIZE=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>
+<../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