mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2163a4c56c | ||
|
|
a536196fd7 | ||
|
|
01a7ab80eb | ||
|
|
44fe5da876 | ||
|
|
652d853b0c | ||
|
|
fdfac73427 | ||
|
|
351c23cc44 | ||
|
|
6cad4f8610 | ||
|
|
6d8a01b593 | ||
|
|
d5bc958621 | ||
|
|
14e29eb600 | ||
|
|
7915e5ef0b | ||
|
|
623f3eaec4 | ||
|
|
0b2b7e61b4 | ||
|
|
d159318b00 | ||
|
|
197b6de4a6 | ||
|
|
db7c5778a1 | ||
|
|
db0fb1d4c6 | ||
|
|
90b9045a90 | ||
|
|
fd33aa8d28 | ||
|
|
3652970969 | ||
|
|
7f03d6fbea | ||
|
|
049017cd2d | ||
|
|
2a72723eff | ||
|
|
ccb4280ae2 | ||
|
|
668aff8105 | ||
|
|
47a6dbc74b | ||
|
|
99c686acf2 | ||
|
|
5de518d5f4 | ||
|
|
a9b37ab697 | ||
|
|
28337c41c9 | ||
|
|
c5e10ad8ea | ||
|
|
ad196b7674 | ||
|
|
d7bb0b2024 | ||
|
|
d5b79cf0b4 | ||
|
|
ea04d515ea | ||
|
|
7d9ac3a827 | ||
|
|
241854a707 | ||
|
|
f289788242 | ||
|
|
17347a1e9d | ||
|
|
da3bf06004 | ||
|
|
0d750fbb19 | ||
|
|
7f8f70655d | ||
|
|
6e417d1f3e | ||
|
|
38eb4b854b | ||
|
|
e64011112e | ||
|
|
97f9fc9eee | ||
|
|
4a1fe3b190 | ||
|
|
2024dc2a1b | ||
|
|
27b8ea603f |
78
README.md
78
README.md
@@ -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 5–10 seconds for confirmation popup) |
|
||||
| R | Import contacts from SD card (wait 5–10 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
206
SMS & Phone App Guide.md
Normal 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 | 0–9, *, +, # | 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 (0–5) |
|
||||
| In Call | 0–9, *, # | 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 (0–5). The
|
||||
number keys **0–9**, **\***, and **#** send DTMF tones for navigating phone
|
||||
menus and voicemail systems. Press **Enter** or **Q** to hang up.
|
||||
|
||||
Audio is routed through the A7682E modem's internal codec to the board speaker
|
||||
and microphone — no headphones or external audio hardware are required.
|
||||
|
||||
### Phone Dialer Input Methods
|
||||
|
||||
The phone dialer supports three ways to enter digits:
|
||||
|
||||
1. **Direct key press** — press the keyboard letter that corresponds to each
|
||||
number using the silk-screened labels on the T-Deck Pro keys:
|
||||
|
||||
| Key | Digit | | Key | Digit | | Key | Digit |
|
||||
|-----|-------|-|-----|-------|-|-----|-------|
|
||||
| W | 1 | | S | 4 | | Z | 7 |
|
||||
| E | 2 | | D | 5 | | X | 8 |
|
||||
| R | 3 | | F | 6 | | C | 9 |
|
||||
| A | * | | O | + | | Mic | 0 |
|
||||
|
||||
2. **Touchscreen tap** — tap the on-screen number buttons directly. Note: this
|
||||
currently requires fairly precise taps on the numbers themselves.
|
||||
|
||||
3. **Sym+key** — the standard symbol entry method (e.g. Sym+W for 1, Sym+S for
|
||||
4, etc.)
|
||||
|
||||
### Receiving a Phone Call
|
||||
|
||||
When an incoming call arrives, the app automatically switches to the incoming
|
||||
call screen regardless of which view is active. A short alert and buzzer
|
||||
notification are triggered. The caller's name is shown if saved in contacts,
|
||||
otherwise the raw phone number is displayed.
|
||||
|
||||
Press **Enter** to answer or **Q** to reject the call. If the call is not
|
||||
answered it is logged as a missed call and a "Missed: ..." alert is shown
|
||||
briefly.
|
||||
|
||||
### Contacts
|
||||
|
||||
The contacts directory lets you assign display names to phone numbers.
|
||||
Names appear in the inbox list, conversation headers, call screens, and
|
||||
compose screen instead of raw numbers.
|
||||
|
||||
To add or edit a contact, open a conversation with that number and press **A**.
|
||||
Type the display name and press **Enter** to save. Names can be up to 23
|
||||
characters long.
|
||||
|
||||
Contacts are stored as a plain text file at `/sms/contacts.txt` on the SD card
|
||||
in `phone=Display Name` format — one per line, human-editable. Up to 30
|
||||
contacts are supported.
|
||||
|
||||
### Conversation History
|
||||
|
||||
Messages are saved to the SD card automatically and persist across reboots.
|
||||
Each phone number gets its own file under `/sms/` on the SD card. The inbox
|
||||
shows the most recent 20 conversations sorted by last activity. Within a
|
||||
conversation, the most recent 30 messages are loaded with the newest at the
|
||||
bottom (chat-style). Sent messages are shown with `>>>` and received messages
|
||||
with `<<<`.
|
||||
|
||||
Message timestamps use the cellular network clock (synced via NITZ roughly 15
|
||||
seconds after each modem startup) and display as relative times (e.g. 5m, 2h,
|
||||
1d). If the modem is toggled off and back on, the clock re-syncs automatically.
|
||||
|
||||
### Modem Power Control
|
||||
|
||||
The 4G modem can be toggled on or off from the settings screen. Scroll to
|
||||
**4G Modem: ON/OFF** and press **Enter** to toggle. Switching the modem off
|
||||
kills its red status LED and stops all cellular activity. The setting persists
|
||||
to SD card and is respected on subsequent boots — if disabled, the modem and
|
||||
LED stay off until re-enabled. The SMS & Phone app remains accessible when the
|
||||
modem is off but will not be able to send or receive messages or calls.
|
||||
|
||||
### Signal Indicator
|
||||
|
||||
A signal strength indicator is shown in the top-right corner of all SMS and
|
||||
call screens. Bars are derived from the modem's CSQ (signal quality) reading,
|
||||
updated every 30 seconds. The modem state (REG, READY, OFF, etc.) is shown
|
||||
when not yet connected. During a call, the signal indicator remains visible.
|
||||
|
||||
### IMEI, Carrier & APN
|
||||
|
||||
The 4G modem's IMEI, current carrier name, and APN are displayed at the bottom
|
||||
of the settings screen (press **S** from the home screen), alongside your node
|
||||
ID and firmware version.
|
||||
|
||||
### SD Card Structure
|
||||
|
||||
```
|
||||
SD Card
|
||||
├── sms/
|
||||
│ ├── contacts.txt (plain text, phone=Name format)
|
||||
│ ├── modem.cfg (0 or 1, modem enable state)
|
||||
│ ├── 0412345678.sms (binary message log per phone number)
|
||||
│ └── 0498765432.sms
|
||||
├── books/ (text reader)
|
||||
├── audiobooks/ (audio variant only)
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Symptom | Likely Cause |
|
||||
|---------|-------------|
|
||||
| Modem icon stays at REG / never reaches READY | SIM not inserted, no signal, or SIM requires PIN unlock (not currently supported) |
|
||||
| Timestamps show `---` | Modem clock hasn't synced yet (wait ~15 seconds after modem startup), or messages were saved before clock sync was available |
|
||||
| Red LED stays on after disabling modem | Toggle the setting off, then reboot — the boot sequence ensures power is cut when disabled |
|
||||
| SMS sends but no delivery | Check signal strength; below 5 bars is marginal. Move to better coverage |
|
||||
| Call drops immediately after dialing | Check signal strength and ensure the SIM plan supports voice calls |
|
||||
| No audio during call | The A7682E routes audio through its own codec; ensure the board speaker is not obstructed. Try adjusting volume with W/S |
|
||||
|
||||
> **Note:** The SMS & Phone app is only available on the 4G modem variant of
|
||||
> the T-Deck Pro. It is not present on the audio or standalone BLE builds due
|
||||
> to shared GPIO pin conflicts between the A7682E modem and PCM5102A DAC.
|
||||
116
SMS App Guide.md
116
SMS App Guide.md
@@ -1,116 +0,0 @@
|
||||
## SMS App (4G variant only) - Meck v0.9.2 (Alpha)
|
||||
|
||||
Press **T** from the home screen to open the SMS 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.
|
||||
|
||||
### Key Mapping
|
||||
|
||||
| Context | Key | Action |
|
||||
|---------|-----|--------|
|
||||
| Home screen | T | Open SMS app |
|
||||
| Inbox | W / S | Scroll conversations |
|
||||
| Inbox | Enter | Open conversation |
|
||||
| Inbox | C | Compose new SMS (enter phone number) |
|
||||
| Inbox | D | Open contacts directory |
|
||||
| Inbox | Q | Back to home screen |
|
||||
| Conversation | W / S | Scroll messages |
|
||||
| Conversation | C | Reply to this conversation |
|
||||
| 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 | Q | Back to inbox |
|
||||
| Edit Contact | Enter | Save contact name |
|
||||
| Edit Contact | Shift+Del | Cancel without saving |
|
||||
|
||||
### 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.
|
||||
|
||||
### Contacts
|
||||
|
||||
The contacts directory lets you assign display names to phone numbers.
|
||||
Names appear in the inbox list, conversation headers, 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 app remains accessible when the modem
|
||||
is off but will not be able to send or receive messages.
|
||||
|
||||
### Signal Indicator
|
||||
|
||||
A signal strength indicator is shown in the top-right corner of all SMS
|
||||
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.
|
||||
|
||||
### 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 |
|
||||
|
||||
> **Note:** The SMS 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
181
Web App Guide.md
Normal 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
|
||||
@@ -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) {}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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.2"
|
||||
#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; }
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -68,6 +82,12 @@
|
||||
static bool smsMode = false;
|
||||
#endif
|
||||
|
||||
// Touch input (for phone dialer numpad)
|
||||
#ifdef HAS_TOUCHSCREEN
|
||||
#include "TouchInput.h"
|
||||
TouchInput touchInput(&Wire);
|
||||
#endif
|
||||
|
||||
// Power management
|
||||
#if HAS_GPS
|
||||
GPSDutyCycle gpsDuty;
|
||||
@@ -178,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!
|
||||
@@ -434,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)");
|
||||
|
||||
@@ -446,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
|
||||
@@ -511,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.
|
||||
@@ -677,6 +863,71 @@ void loop() {
|
||||
|
||||
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
|
||||
@@ -691,10 +942,21 @@ void loop() {
|
||||
#else
|
||||
bool smsSuppressLoop = false;
|
||||
#endif
|
||||
if (!composeMode && !notesSuppressLoop && !smsSuppressLoop) {
|
||||
#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) {
|
||||
@@ -708,7 +970,7 @@ void loop() {
|
||||
ui_task.loop();
|
||||
} else if (smsSuppressLoop) {
|
||||
// SMS compose: render directly to display, same as mesh compose
|
||||
#ifdef DISPLAY_CLASS
|
||||
#if defined(DISPLAY_CLASS) && defined(HAS_4G_MODEM)
|
||||
display.startFrame();
|
||||
((SMSScreen*)ui_task.getSMSScreen())->render(display);
|
||||
display.endFrame();
|
||||
@@ -717,6 +979,43 @@ void loop() {
|
||||
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();
|
||||
@@ -739,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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -819,7 +1156,7 @@ void handleKeyboardInput() {
|
||||
if (wasDM) {
|
||||
ui_task.gotoContactsScreen();
|
||||
} else {
|
||||
ui_task.gotoHomeScreen();
|
||||
ui_task.gotoChannelScreen();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -839,7 +1176,7 @@ void handleKeyboardInput() {
|
||||
if (wasDM) {
|
||||
ui_task.gotoContactsScreen();
|
||||
} else {
|
||||
ui_task.gotoHomeScreen();
|
||||
ui_task.gotoChannelScreen();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1147,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();
|
||||
@@ -1170,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 {
|
||||
@@ -1185,13 +1523,46 @@ void handleKeyboardInput() {
|
||||
if (smsMode) {
|
||||
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
|
||||
if (smsScr) {
|
||||
// Q from inbox → go home; Q from inner views is handled by SMSScreen
|
||||
if ((key == 'q' || key == '\b') && smsScr->getSubView() == SMSScreen::INBOX) {
|
||||
// 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
|
||||
@@ -1214,6 +1585,52 @@ void handleKeyboardInput() {
|
||||
}
|
||||
#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':
|
||||
@@ -1258,6 +1675,50 @@ void handleKeyboardInput() {
|
||||
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
|
||||
@@ -1274,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();
|
||||
@@ -1285,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
|
||||
@@ -1341,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 {
|
||||
@@ -1361,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();
|
||||
@@ -1395,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;
|
||||
}
|
||||
|
||||
372
examples/companion_radio/ui-new/ApnDatabase.h
Normal file
372
examples/companion_radio/ui-new/ApnDatabase.h
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,8 +17,12 @@ ModemManager modemManager;
|
||||
#define AT_BUF_SIZE 512
|
||||
static char _atBuf[AT_BUF_SIZE];
|
||||
|
||||
// Config file paths
|
||||
#define MODEM_CONFIG_FILE "/sms/modem.cfg"
|
||||
#define APN_CONFIG_FILE "/sms/apn.cfg"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// Public API - SMS (unchanged)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ModemManager::begin() {
|
||||
@@ -27,11 +31,20 @@ void ModemManager::begin() {
|
||||
_state = ModemState::OFF;
|
||||
_csq = 99;
|
||||
_operator[0] = '\0';
|
||||
_callPhone[0] = '\0';
|
||||
_callStartTime = 0;
|
||||
_urcPos = 0;
|
||||
_imei[0] = '\0';
|
||||
_imsi[0] = '\0';
|
||||
_apn[0] = '\0';
|
||||
strcpy(_apnSource, "none");
|
||||
|
||||
// Create FreeRTOS primitives
|
||||
_sendQueue = xQueueCreate(MODEM_SEND_QUEUE_SIZE, sizeof(SMSOutgoing));
|
||||
_recvQueue = xQueueCreate(MODEM_RECV_QUEUE_SIZE, sizeof(SMSIncoming));
|
||||
_uartMutex = xSemaphoreCreateMutex();
|
||||
_sendQueue = xQueueCreate(MODEM_SEND_QUEUE_SIZE, sizeof(SMSOutgoing));
|
||||
_recvQueue = xQueueCreate(MODEM_RECV_QUEUE_SIZE, sizeof(SMSIncoming));
|
||||
_callCmdQueue = xQueueCreate(MODEM_CALL_CMD_QUEUE_SIZE, sizeof(CallCommand));
|
||||
_callEvtQueue = xQueueCreate(MODEM_CALL_EVT_QUEUE_SIZE, sizeof(CallEvent));
|
||||
_uartMutex = xSemaphoreCreateMutex();
|
||||
|
||||
// Launch background task on Core 0
|
||||
xTaskCreatePinnedToCore(
|
||||
@@ -50,6 +63,15 @@ void ModemManager::shutdown() {
|
||||
|
||||
MESH_DEBUG_PRINTLN("[Modem] shutdown()");
|
||||
|
||||
// Hang up any active call first
|
||||
if (isCallActive()) {
|
||||
CallCommand cmd;
|
||||
memset(&cmd, 0, sizeof(cmd));
|
||||
cmd.cmd = CallCmd::HANGUP;
|
||||
xQueueSend(_callCmdQueue, &cmd, pdMS_TO_TICKS(500));
|
||||
vTaskDelay(pdMS_TO_TICKS(2000)); // Give time for AT+CHUP
|
||||
}
|
||||
|
||||
// Tell modem to power off gracefully
|
||||
if (xSemaphoreTake(_uartMutex, pdMS_TO_TICKS(2000))) {
|
||||
sendAT("AT+CPOF", "OK", 5000);
|
||||
@@ -81,6 +103,74 @@ bool ModemManager::recvSMS(SMSIncoming& out) {
|
||||
return xQueueReceive(_recvQueue, &out, 0) == pdTRUE;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API - Voice Calls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool ModemManager::dialCall(const char* phone) {
|
||||
if (!_callCmdQueue) return false;
|
||||
if (isCallActive()) return false; // Already in a call
|
||||
|
||||
CallCommand cmd;
|
||||
memset(&cmd, 0, sizeof(cmd));
|
||||
cmd.cmd = CallCmd::DIAL;
|
||||
strncpy(cmd.phone, phone, SMS_PHONE_LEN - 1);
|
||||
|
||||
return xQueueSend(_callCmdQueue, &cmd, 0) == pdTRUE;
|
||||
}
|
||||
|
||||
bool ModemManager::answerCall() {
|
||||
if (!_callCmdQueue) return false;
|
||||
|
||||
CallCommand cmd;
|
||||
memset(&cmd, 0, sizeof(cmd));
|
||||
cmd.cmd = CallCmd::ANSWER;
|
||||
|
||||
return xQueueSend(_callCmdQueue, &cmd, 0) == pdTRUE;
|
||||
}
|
||||
|
||||
bool ModemManager::hangupCall() {
|
||||
if (!_callCmdQueue) return false;
|
||||
|
||||
CallCommand cmd;
|
||||
memset(&cmd, 0, sizeof(cmd));
|
||||
cmd.cmd = CallCmd::HANGUP;
|
||||
|
||||
return xQueueSend(_callCmdQueue, &cmd, 0) == pdTRUE;
|
||||
}
|
||||
|
||||
bool ModemManager::sendDTMF(char digit) {
|
||||
if (!_callCmdQueue) return false;
|
||||
if (_state != ModemState::IN_CALL) return false;
|
||||
|
||||
CallCommand cmd;
|
||||
memset(&cmd, 0, sizeof(cmd));
|
||||
cmd.cmd = CallCmd::DTMF;
|
||||
cmd.dtmf = digit;
|
||||
|
||||
return xQueueSend(_callCmdQueue, &cmd, 0) == pdTRUE;
|
||||
}
|
||||
|
||||
bool ModemManager::setCallVolume(uint8_t level) {
|
||||
if (!_callCmdQueue) return false;
|
||||
|
||||
CallCommand cmd;
|
||||
memset(&cmd, 0, sizeof(cmd));
|
||||
cmd.cmd = CallCmd::SET_VOLUME;
|
||||
cmd.volume = level > 5 ? 5 : level;
|
||||
|
||||
return xQueueSend(_callCmdQueue, &cmd, 0) == pdTRUE;
|
||||
}
|
||||
|
||||
bool ModemManager::pollCallEvent(CallEvent& out) {
|
||||
if (!_callEvtQueue) return false;
|
||||
return xQueueReceive(_callEvtQueue, &out, 0) == pdTRUE;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
int ModemManager::getSignalBars() const {
|
||||
if (_csq == 99 || _csq == 0) return 0;
|
||||
if (_csq <= 5) return 1;
|
||||
@@ -99,6 +189,9 @@ const char* ModemManager::stateToString(ModemState s) {
|
||||
case ModemState::READY: return "READY";
|
||||
case ModemState::ERROR: return "ERROR";
|
||||
case ModemState::SENDING_SMS: return "SENDING";
|
||||
case ModemState::DIALING: return "DIALING";
|
||||
case ModemState::RINGING_IN: return "INCOMING";
|
||||
case ModemState::IN_CALL: return "IN CALL";
|
||||
default: return "???";
|
||||
}
|
||||
}
|
||||
@@ -107,8 +200,6 @@ const char* ModemManager::stateToString(ModemState s) {
|
||||
// Persistent modem enable/disable config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#define MODEM_CONFIG_FILE "/sms/modem.cfg"
|
||||
|
||||
bool ModemManager::loadEnabledConfig() {
|
||||
File f = SD.open(MODEM_CONFIG_FILE, FILE_READ);
|
||||
if (!f) {
|
||||
@@ -132,6 +223,388 @@ void ModemManager::saveEnabledConfig(bool enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// APN Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ModemManager::setAPN(const char* apn) {
|
||||
strncpy(_apn, apn, sizeof(_apn) - 1);
|
||||
_apn[sizeof(_apn) - 1] = '\0';
|
||||
strcpy(_apnSource, "user");
|
||||
saveAPNConfig(apn);
|
||||
MESH_DEBUG_PRINTLN("[Modem] APN set by user: %s", _apn);
|
||||
}
|
||||
|
||||
bool ModemManager::loadAPNConfig(char* apnOut, int maxLen) {
|
||||
File f = SD.open(APN_CONFIG_FILE, FILE_READ);
|
||||
if (!f) { return false; }
|
||||
String line = f.readStringUntil('\n');
|
||||
f.close();
|
||||
line.trim();
|
||||
if (line.length() == 0) return false;
|
||||
strncpy(apnOut, line.c_str(), maxLen - 1);
|
||||
apnOut[maxLen - 1] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
void ModemManager::saveAPNConfig(const char* apn) {
|
||||
if (!SD.exists("/sms")) SD.mkdir("/sms");
|
||||
File f = SD.open(APN_CONFIG_FILE, FILE_WRITE);
|
||||
if (f) {
|
||||
f.println(apn);
|
||||
f.close();
|
||||
Serial.printf("[Modem] APN config saved: %s\n", apn);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// APN Resolution — called during init after network registration
|
||||
//
|
||||
// Priority:
|
||||
// 1. User-configured APN (from /sms/apn.cfg)
|
||||
// 2. Network-provisioned APN (AT+CGDCONT? — modem already has one)
|
||||
// 3. Auto-detected from IMSI via embedded ApnDatabase
|
||||
// 4. Blank (some carriers work with empty APN)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ModemManager::resolveAPN() {
|
||||
// 1. Check for user-configured APN on SD card
|
||||
char userApn[64];
|
||||
if (loadAPNConfig(userApn, sizeof(userApn))) {
|
||||
strncpy(_apn, userApn, sizeof(_apn) - 1);
|
||||
strcpy(_apnSource, "user");
|
||||
MESH_DEBUG_PRINTLN("[Modem] APN from user config: %s", _apn);
|
||||
|
||||
// Apply to modem
|
||||
char cmd[80];
|
||||
snprintf(cmd, sizeof(cmd), "AT+CGDCONT=1,\"IP\",\"%s\"", _apn);
|
||||
sendAT(cmd, "OK", 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check if modem already has a network-provisioned APN
|
||||
if (sendAT("AT+CGDCONT?", "OK", 3000)) {
|
||||
// Response: +CGDCONT: 1,"IP","telstra.internet",,0,0
|
||||
char* p = strstr(_atBuf, "+CGDCONT:");
|
||||
if (p) {
|
||||
char* q1 = strchr(p, '"'); // first quote (before IP)
|
||||
if (q1) q1 = strchr(q1 + 1, '"'); // close quote of IP
|
||||
if (q1) q1 = strchr(q1 + 1, '"'); // open quote of APN
|
||||
if (q1) {
|
||||
q1++;
|
||||
char* q2 = strchr(q1, '"');
|
||||
if (q2 && q2 > q1) {
|
||||
int len = q2 - q1;
|
||||
if (len > 0 && len < (int)sizeof(_apn)) {
|
||||
memcpy(_apn, q1, len);
|
||||
_apn[len] = '\0';
|
||||
strcpy(_apnSource, "network");
|
||||
MESH_DEBUG_PRINTLN("[Modem] APN from network/modem: %s", _apn);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Auto-detect from IMSI using embedded database
|
||||
if (_imsi[0]) {
|
||||
const ApnEntry* entry = apnLookupFromIMSI(_imsi);
|
||||
if (entry) {
|
||||
strncpy(_apn, entry->apn, sizeof(_apn) - 1);
|
||||
strcpy(_apnSource, "auto");
|
||||
MESH_DEBUG_PRINTLN("[Modem] APN auto-detected: %s (%s)", _apn, entry->carrier);
|
||||
|
||||
// Apply to modem
|
||||
char cmd[80];
|
||||
snprintf(cmd, sizeof(cmd), "AT+CGDCONT=1,\"IP\",\"%s\"", _apn);
|
||||
sendAT(cmd, "OK", 3000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. No APN found — leave blank
|
||||
_apn[0] = '\0';
|
||||
strcpy(_apnSource, "none");
|
||||
MESH_DEBUG_PRINTLN("[Modem] APN: none detected (IMSI=%s)", _imsi[0] ? _imsi : "unknown");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URC (Unsolicited Result Code) Handling
|
||||
// ---------------------------------------------------------------------------
|
||||
// The modem can send unsolicited messages at any time:
|
||||
// RING — incoming call ringing
|
||||
// +CLIP: "+1234...",145,... — caller ID (after AT+CLIP=1)
|
||||
// NO CARRIER — call ended by remote
|
||||
// BUSY — outgoing call busy
|
||||
// NO ANSWER — outgoing call no answer
|
||||
// +CMTI: "SM",<idx> — new SMS arrived
|
||||
//
|
||||
// drainURCs() accumulates bytes into a line buffer and calls
|
||||
// processURCLine() for each complete line.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ModemManager::drainURCs() {
|
||||
while (MODEM_SERIAL.available()) {
|
||||
char c = MODEM_SERIAL.read();
|
||||
|
||||
// Accumulate into line buffer
|
||||
if (c == '\n') {
|
||||
// End of line — process if non-empty
|
||||
if (_urcPos > 0) {
|
||||
// Trim trailing \r
|
||||
while (_urcPos > 0 && _urcBuf[_urcPos - 1] == '\r') _urcPos--;
|
||||
_urcBuf[_urcPos] = '\0';
|
||||
|
||||
if (_urcPos > 0) {
|
||||
processURCLine(_urcBuf);
|
||||
}
|
||||
}
|
||||
_urcPos = 0;
|
||||
} else if (c != '\r' || _urcPos > 0) {
|
||||
// Accumulate (skip leading \r)
|
||||
if (_urcPos < URC_BUF_SIZE - 1) {
|
||||
_urcBuf[_urcPos++] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ModemManager::processURCLine(const char* line) {
|
||||
// --- RING: incoming call ---
|
||||
if (strcmp(line, "RING") == 0) {
|
||||
MESH_DEBUG_PRINTLN("[Modem] URC: RING");
|
||||
if (_state != ModemState::RINGING_IN && _state != ModemState::IN_CALL) {
|
||||
_state = ModemState::RINGING_IN;
|
||||
// Phone number will be filled by +CLIP if available
|
||||
// Queue event with empty phone (updated by +CLIP)
|
||||
// Only queue on first RING; subsequent RINGs are repeats
|
||||
if (_callPhone[0] == '\0') {
|
||||
queueCallEvent(CallEventType::INCOMING, "");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- +CLIP: caller ID ---
|
||||
// +CLIP: "+61412345678",145,,,,0
|
||||
if (strncmp(line, "+CLIP:", 6) == 0) {
|
||||
char* q1 = strchr(line + 6, '"');
|
||||
if (q1) {
|
||||
q1++;
|
||||
char* q2 = strchr(q1, '"');
|
||||
if (q2) {
|
||||
int len = q2 - q1;
|
||||
if (len >= SMS_PHONE_LEN) len = SMS_PHONE_LEN - 1;
|
||||
memcpy(_callPhone, q1, len);
|
||||
_callPhone[len] = '\0';
|
||||
MESH_DEBUG_PRINTLN("[Modem] URC: CLIP phone=%s", _callPhone);
|
||||
|
||||
// Re-queue INCOMING event with the actual phone number
|
||||
// (replaces the empty-phone event from RING)
|
||||
if (_state == ModemState::RINGING_IN) {
|
||||
queueCallEvent(CallEventType::INCOMING, _callPhone);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- NO CARRIER: call ended ---
|
||||
if (strcmp(line, "NO CARRIER") == 0) {
|
||||
MESH_DEBUG_PRINTLN("[Modem] URC: NO CARRIER");
|
||||
if (_state == ModemState::RINGING_IN) {
|
||||
// Incoming call ended before we answered — missed call
|
||||
queueCallEvent(CallEventType::MISSED, _callPhone);
|
||||
} else if (_state == ModemState::DIALING || _state == ModemState::IN_CALL) {
|
||||
uint32_t duration = 0;
|
||||
if (_state == ModemState::IN_CALL && _callStartTime > 0) {
|
||||
duration = (millis() - _callStartTime) / 1000;
|
||||
}
|
||||
queueCallEvent(CallEventType::ENDED, _callPhone, duration);
|
||||
}
|
||||
_state = ModemState::READY;
|
||||
_callPhone[0] = '\0';
|
||||
_callStartTime = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// --- BUSY ---
|
||||
if (strcmp(line, "BUSY") == 0) {
|
||||
MESH_DEBUG_PRINTLN("[Modem] URC: BUSY");
|
||||
if (_state == ModemState::DIALING) {
|
||||
queueCallEvent(CallEventType::BUSY, _callPhone);
|
||||
_state = ModemState::READY;
|
||||
_callPhone[0] = '\0';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- NO ANSWER ---
|
||||
if (strcmp(line, "NO ANSWER") == 0) {
|
||||
MESH_DEBUG_PRINTLN("[Modem] URC: NO ANSWER");
|
||||
if (_state == ModemState::DIALING) {
|
||||
queueCallEvent(CallEventType::NO_ANSWER, _callPhone);
|
||||
_state = ModemState::READY;
|
||||
_callPhone[0] = '\0';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- +CMTI: new SMS indication ---
|
||||
// +CMTI: "SM",<index>
|
||||
// We don't need to act on this immediately since we poll for SMS,
|
||||
// but we can trigger an early poll
|
||||
if (strncmp(line, "+CMTI:", 6) == 0) {
|
||||
MESH_DEBUG_PRINTLN("[Modem] URC: CMTI (new SMS)");
|
||||
// Next SMS poll will pick it up; we just log it
|
||||
return;
|
||||
}
|
||||
|
||||
// --- VOICE CALL: BEGIN — A76xx-specific: audio path established ---
|
||||
if (strncmp(line, "VOICE CALL: BEGIN", 17) == 0) {
|
||||
MESH_DEBUG_PRINTLN("[Modem] URC: VOICE CALL: BEGIN");
|
||||
if (_state == ModemState::DIALING) {
|
||||
_state = ModemState::IN_CALL;
|
||||
_callStartTime = millis();
|
||||
queueCallEvent(CallEventType::CONNECTED, _callPhone);
|
||||
MESH_DEBUG_PRINTLN("[Modem] Call connected (VOICE CALL: BEGIN)");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- VOICE CALL: END — A76xx-specific: audio path closed ---
|
||||
// Format: "VOICE CALL: END: <duration>"
|
||||
if (strncmp(line, "VOICE CALL: END", 15) == 0) {
|
||||
MESH_DEBUG_PRINTLN("[Modem] URC: %s", line);
|
||||
// Parse duration if present: "VOICE CALL: END: 0:12"
|
||||
uint32_t duration = 0;
|
||||
const char* dp = strstr(line, "END:");
|
||||
if (dp) {
|
||||
dp += 4;
|
||||
while (*dp == ' ') dp++;
|
||||
int mins = 0, secs = 0;
|
||||
if (sscanf(dp, "%d:%d", &mins, &secs) == 2) {
|
||||
duration = mins * 60 + secs;
|
||||
}
|
||||
}
|
||||
if (_state == ModemState::RINGING_IN) {
|
||||
queueCallEvent(CallEventType::MISSED, _callPhone);
|
||||
} else if (_state == ModemState::IN_CALL || _state == ModemState::DIALING) {
|
||||
queueCallEvent(CallEventType::ENDED, _callPhone, duration);
|
||||
}
|
||||
_state = ModemState::READY;
|
||||
_callPhone[0] = '\0';
|
||||
_callStartTime = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void ModemManager::queueCallEvent(CallEventType type, const char* phone, uint32_t duration) {
|
||||
CallEvent evt;
|
||||
memset(&evt, 0, sizeof(evt));
|
||||
evt.type = type;
|
||||
evt.duration = duration;
|
||||
if (phone) {
|
||||
strncpy(evt.phone, phone, SMS_PHONE_LEN - 1);
|
||||
}
|
||||
xQueueSend(_callEvtQueue, &evt, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Call control (executed on modem task)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool ModemManager::doDialCall(const char* phone) {
|
||||
MESH_DEBUG_PRINTLN("[Modem] doDialCall: %s", phone);
|
||||
|
||||
strncpy(_callPhone, phone, SMS_PHONE_LEN - 1);
|
||||
_callPhone[SMS_PHONE_LEN - 1] = '\0';
|
||||
_state = ModemState::DIALING;
|
||||
|
||||
// ATD<number>; — the semicolon makes it a voice call (not data)
|
||||
char cmd[32];
|
||||
snprintf(cmd, sizeof(cmd), "ATD%s;", phone);
|
||||
|
||||
if (!sendAT(cmd, "OK", 30000)) {
|
||||
MESH_DEBUG_PRINTLN("[Modem] ATD failed");
|
||||
queueCallEvent(CallEventType::DIAL_FAILED, phone);
|
||||
_state = ModemState::READY;
|
||||
_callPhone[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
|
||||
// ATD returned OK — call is being set up.
|
||||
// Connection/failure will come as URCs (NO CARRIER, BUSY, etc.)
|
||||
// or we detect active call via AT+CLCC polling.
|
||||
// For now, assume we're dialing and wait for URCs.
|
||||
MESH_DEBUG_PRINTLN("[Modem] ATD OK — dialing...");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ModemManager::doAnswerCall() {
|
||||
MESH_DEBUG_PRINTLN("[Modem] doAnswerCall");
|
||||
|
||||
if (sendAT("ATA", "OK", 10000)) {
|
||||
_state = ModemState::IN_CALL;
|
||||
_callStartTime = millis();
|
||||
queueCallEvent(CallEventType::CONNECTED, _callPhone);
|
||||
MESH_DEBUG_PRINTLN("[Modem] Call answered");
|
||||
return true;
|
||||
}
|
||||
|
||||
MESH_DEBUG_PRINTLN("[Modem] ATA failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ModemManager::doHangup() {
|
||||
MESH_DEBUG_PRINTLN("[Modem] doHangup (state=%d)", (int)_state);
|
||||
|
||||
uint32_t duration = 0;
|
||||
if (_state == ModemState::IN_CALL && _callStartTime > 0) {
|
||||
duration = (millis() - _callStartTime) / 1000;
|
||||
}
|
||||
|
||||
bool wasRinging = (_state == ModemState::RINGING_IN);
|
||||
|
||||
// AT+CHUP is the 3GPP standard hangup for A76xx family (per TinyGSM)
|
||||
if (sendAT("AT+CHUP", "OK", 5000)) {
|
||||
if (wasRinging) {
|
||||
queueCallEvent(CallEventType::MISSED, _callPhone);
|
||||
} else {
|
||||
queueCallEvent(CallEventType::ENDED, _callPhone, duration);
|
||||
}
|
||||
_state = ModemState::READY;
|
||||
_callPhone[0] = '\0';
|
||||
_callStartTime = 0;
|
||||
MESH_DEBUG_PRINTLN("[Modem] Hangup OK");
|
||||
return true;
|
||||
}
|
||||
|
||||
MESH_DEBUG_PRINTLN("[Modem] AT+CHUP failed");
|
||||
// Force state back to READY even if hangup fails
|
||||
_state = ModemState::READY;
|
||||
_callPhone[0] = '\0';
|
||||
_callStartTime = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ModemManager::doSendDTMF(char digit) {
|
||||
char cmd[16];
|
||||
snprintf(cmd, sizeof(cmd), "AT+VTS=%c", digit);
|
||||
bool ok = sendAT(cmd, "OK", 3000);
|
||||
MESH_DEBUG_PRINTLN("[Modem] DTMF '%c' %s", digit, ok ? "OK" : "FAIL");
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool ModemManager::doSetVolume(uint8_t level) {
|
||||
char cmd[16];
|
||||
snprintf(cmd, sizeof(cmd), "AT+CLVL=%d", level);
|
||||
bool ok = sendAT(cmd, "OK", 2000);
|
||||
MESH_DEBUG_PRINTLN("[Modem] Volume %d %s", level, ok ? "OK" : "FAIL");
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FreeRTOS Task
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -176,6 +649,28 @@ restart:
|
||||
// Disable echo
|
||||
sendAT("ATE0", "OK");
|
||||
|
||||
// --- Query device identity ---
|
||||
// IMEI (International Mobile Equipment Identity)
|
||||
if (sendAT("AT+GSN", "OK", 3000)) {
|
||||
// Response is just the IMEI number on its own line
|
||||
char* p = _atBuf;
|
||||
while (*p && !isdigit(*p)) p++; // skip to first digit
|
||||
int i = 0;
|
||||
while (isdigit(p[i]) && i < 19) { _imei[i] = p[i]; i++; }
|
||||
_imei[i] = '\0';
|
||||
MESH_DEBUG_PRINTLN("[Modem] IMEI: %s", _imei);
|
||||
}
|
||||
|
||||
// IMSI (International Mobile Subscriber Identity) — for APN auto-detection
|
||||
if (sendAT("AT+CIMI", "OK", 3000)) {
|
||||
char* p = _atBuf;
|
||||
while (*p && !isdigit(*p)) p++;
|
||||
int i = 0;
|
||||
while (isdigit(p[i]) && i < 19) { _imsi[i] = p[i]; i++; }
|
||||
_imsi[i] = '\0';
|
||||
MESH_DEBUG_PRINTLN("[Modem] IMSI: %s", _imsi);
|
||||
}
|
||||
|
||||
// Set SMS text mode
|
||||
sendAT("AT+CMGF=1", "OK");
|
||||
|
||||
@@ -188,6 +683,17 @@ restart:
|
||||
// Enable automatic time zone update from network (needed for AT+CCLK)
|
||||
sendAT("AT+CTZU=1", "OK");
|
||||
|
||||
// --- Voice call setup ---
|
||||
// Enable caller ID presentation (CLIP) so we get +CLIP URCs on incoming calls
|
||||
sendAT("AT+CLIP=1", "OK");
|
||||
|
||||
// Set audio output to loudspeaker mode (device speaker)
|
||||
// 1=earpiece, 3=loudspeaker — use loudspeaker for T-Deck Pro
|
||||
sendAT("AT+CSDVC=3", "OK", 1000);
|
||||
|
||||
// Set initial call volume (mid-level)
|
||||
sendAT("AT+CLVL=3", "OK", 1000);
|
||||
|
||||
// ---- Phase 3: Wait for network registration ----
|
||||
_state = ModemState::REGISTERING;
|
||||
MESH_DEBUG_PRINTLN("[Modem] waiting for network registration...");
|
||||
@@ -196,7 +702,6 @@ restart:
|
||||
for (int i = 0; i < 60; i++) { // up to 60 seconds
|
||||
if (sendAT("AT+CREG?", "OK", 2000)) {
|
||||
// Full response now in _atBuf, e.g.: "\r\n+CREG: 0,1\r\n\r\nOK\r\n"
|
||||
// stat: 1=registered home, 5=registered roaming
|
||||
char* p = strstr(_atBuf, "+CREG:");
|
||||
if (p) {
|
||||
int n, stat;
|
||||
@@ -215,12 +720,14 @@ restart:
|
||||
|
||||
if (!registered) {
|
||||
MESH_DEBUG_PRINTLN("[Modem] registration timeout - continuing anyway");
|
||||
// Don't set ERROR; some networks are slow but SMS may still work
|
||||
}
|
||||
|
||||
// Query operator name
|
||||
// AT+COPS=3,0 sets the format to "long alphanumeric" so AT+COPS?
|
||||
// returns "Optus" instead of "50502"
|
||||
sendAT("AT+COPS=3,0", "OK", 2000);
|
||||
|
||||
if (sendAT("AT+COPS?", "OK", 5000)) {
|
||||
// +COPS: 0,0,"Operator Name",7
|
||||
char* p = strchr(_atBuf, '"');
|
||||
if (p) {
|
||||
p++;
|
||||
@@ -235,40 +742,56 @@ restart:
|
||||
}
|
||||
}
|
||||
|
||||
// If operator is still numeric (all digits), look up friendly name from IMSI
|
||||
if (_operator[0] && isdigit(_operator[0])) {
|
||||
bool allDigits = true;
|
||||
for (int i = 0; _operator[i]; i++) {
|
||||
if (!isdigit(_operator[i])) { allDigits = false; break; }
|
||||
}
|
||||
if (allDigits && _imsi[0]) {
|
||||
const ApnEntry* entry = apnLookupFromIMSI(_imsi);
|
||||
if (entry && entry->carrier) {
|
||||
strncpy(_operator, entry->carrier, sizeof(_operator) - 1);
|
||||
_operator[sizeof(_operator) - 1] = '\0';
|
||||
MESH_DEBUG_PRINTLN("[Modem] operator (from IMSI lookup): %s", _operator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial signal query
|
||||
pollCSQ();
|
||||
|
||||
// Resolve APN (user config → network provisioned → IMSI auto-detect)
|
||||
resolveAPN();
|
||||
|
||||
// Sync ESP32 system clock from modem network time
|
||||
// Network time may take a few seconds to arrive after registration
|
||||
bool clockSet = false;
|
||||
for (int attempt = 0; attempt < 5 && !clockSet; attempt++) {
|
||||
if (attempt > 0) vTaskDelay(pdMS_TO_TICKS(2000));
|
||||
if (sendAT("AT+CCLK?", "OK", 3000)) {
|
||||
// Response: +CCLK: "YY/MM/DD,HH:MM:SS±TZ" (TZ in quarter-hours)
|
||||
char* p = strstr(_atBuf, "+CCLK:");
|
||||
if (p) {
|
||||
int yy = 0, mo = 0, dd = 0, hh = 0, mm = 0, ss = 0, tz = 0;
|
||||
if (sscanf(p, "+CCLK: \"%d/%d/%d,%d:%d:%d", &yy, &mo, &dd, &hh, &mm, &ss) >= 6) {
|
||||
// Skip if modem clock not synced (default is 1970 = yy 70, or yy 0)
|
||||
if (yy < 24 || yy > 50) {
|
||||
MESH_DEBUG_PRINTLN("[Modem] CCLK not synced yet (yy=%d), retrying...", yy);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse timezone offset (e.g. "+40" = UTC+10 in quarter-hours)
|
||||
char* tzp = p + 7; // skip "+CCLK: "
|
||||
// Parse timezone offset
|
||||
char* tzp = p + 7;
|
||||
while (*tzp && *tzp != '+' && *tzp != '-') tzp++;
|
||||
if (*tzp) tz = atoi(tzp);
|
||||
|
||||
struct tm t = {};
|
||||
t.tm_year = yy + 100; // years since 1900
|
||||
t.tm_mon = mo - 1; // 0-based
|
||||
t.tm_year = yy + 100;
|
||||
t.tm_mon = mo - 1;
|
||||
t.tm_mday = dd;
|
||||
t.tm_hour = hh;
|
||||
t.tm_min = mm;
|
||||
t.tm_sec = ss;
|
||||
time_t epoch = mktime(&t); // treats input as UTC (no TZ set on ESP32)
|
||||
epoch -= (tz * 15 * 60); // subtract local offset to get real UTC
|
||||
time_t epoch = mktime(&t);
|
||||
epoch -= (tz * 15 * 60);
|
||||
|
||||
struct timeval tv = { .tv_sec = epoch, .tv_usec = 0 };
|
||||
settimeofday(&tv, nullptr);
|
||||
@@ -284,40 +807,142 @@ restart:
|
||||
}
|
||||
|
||||
// Delete any stale SMS on SIM to free slots
|
||||
sendAT("AT+CMGD=1,4", "OK", 5000); // Delete all read messages
|
||||
sendAT("AT+CMGD=1,4", "OK", 5000);
|
||||
|
||||
_state = ModemState::READY;
|
||||
MESH_DEBUG_PRINTLN("[Modem] READY (CSQ=%d, operator=%s)", _csq, _operator);
|
||||
MESH_DEBUG_PRINTLN("[Modem] READY (CSQ=%d, operator=%s, APN=%s [%s], IMEI=%s)",
|
||||
_csq, _operator, _apn[0] ? _apn : "(none)", _apnSource, _imei);
|
||||
|
||||
// ---- Phase 4: Main loop ----
|
||||
unsigned long lastCSQPoll = 0;
|
||||
unsigned long lastSMSPoll = 0;
|
||||
const unsigned long CSQ_POLL_INTERVAL = 30000; // 30s
|
||||
const unsigned long SMS_POLL_INTERVAL = 10000; // 10s
|
||||
unsigned long lastCLCCPoll = 0;
|
||||
const unsigned long CSQ_POLL_INTERVAL = 30000; // 30s
|
||||
const unsigned long SMS_POLL_INTERVAL = 10000; // 10s
|
||||
const unsigned long CLCC_POLL_INTERVAL = 2000; // 2s (during dialing only)
|
||||
|
||||
while (true) {
|
||||
// Check for outgoing SMS in queue
|
||||
SMSOutgoing outMsg;
|
||||
if (xQueueReceive(_sendQueue, &outMsg, 0) == pdTRUE) {
|
||||
_state = ModemState::SENDING_SMS;
|
||||
bool ok = doSendSMS(outMsg.phone, outMsg.body);
|
||||
MESH_DEBUG_PRINTLN("[Modem] SMS send %s to %s", ok ? "OK" : "FAIL", outMsg.phone);
|
||||
_state = ModemState::READY;
|
||||
// ================================================================
|
||||
// Step 1: Drain URCs — catch RING, NO CARRIER, +CLIP, etc.
|
||||
// This must run every iteration to avoid missing time-sensitive
|
||||
// events like incoming calls or call-ended notifications.
|
||||
// ================================================================
|
||||
drainURCs();
|
||||
|
||||
// ================================================================
|
||||
// Step 2: Process call commands from main loop
|
||||
// ================================================================
|
||||
CallCommand callCmd;
|
||||
if (xQueueReceive(_callCmdQueue, &callCmd, 0) == pdTRUE) {
|
||||
switch (callCmd.cmd) {
|
||||
case CallCmd::DIAL:
|
||||
if (_state == ModemState::READY) {
|
||||
doDialCall(callCmd.phone);
|
||||
} else {
|
||||
MESH_DEBUG_PRINTLN("[Modem] Can't dial — state=%d", (int)_state);
|
||||
queueCallEvent(CallEventType::DIAL_FAILED, callCmd.phone);
|
||||
}
|
||||
break;
|
||||
|
||||
case CallCmd::ANSWER:
|
||||
if (_state == ModemState::RINGING_IN) {
|
||||
doAnswerCall();
|
||||
}
|
||||
break;
|
||||
|
||||
case CallCmd::HANGUP:
|
||||
if (isCallActive()) {
|
||||
doHangup();
|
||||
}
|
||||
break;
|
||||
|
||||
case CallCmd::DTMF:
|
||||
if (_state == ModemState::IN_CALL) {
|
||||
doSendDTMF(callCmd.dtmf);
|
||||
}
|
||||
break;
|
||||
|
||||
case CallCmd::SET_VOLUME:
|
||||
doSetVolume(callCmd.volume);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Poll for incoming SMS periodically (not every loop iteration)
|
||||
if (millis() - lastSMSPoll > SMS_POLL_INTERVAL) {
|
||||
pollIncomingSMS();
|
||||
lastSMSPoll = millis();
|
||||
// ================================================================
|
||||
// Step 3: Poll AT+CLCC during DIALING as fallback.
|
||||
// Primary detection is via "VOICE CALL: BEGIN" URC (handled by
|
||||
// drainURCs/processURCLine above). CLCC polling is a safety net
|
||||
// in case the URC is missed or delayed.
|
||||
// Skip when paused to avoid Core 0 contention with WiFi TLS.
|
||||
// ================================================================
|
||||
if (!_paused &&
|
||||
_state == ModemState::DIALING &&
|
||||
millis() - lastCLCCPoll > CLCC_POLL_INTERVAL) {
|
||||
if (sendAT("AT+CLCC", "OK", 2000)) {
|
||||
// +CLCC: 1,0,0,0,0,"number",129 — stat field:
|
||||
// 0=active, 1=held, 2=dialing, 3=alerting, 4=incoming, 5=waiting
|
||||
char* p = strstr(_atBuf, "+CLCC:");
|
||||
if (p) {
|
||||
int idx, dir, stat, mode, mpty;
|
||||
if (sscanf(p, "+CLCC: %d,%d,%d,%d,%d", &idx, &dir, &stat, &mode, &mpty) >= 3) {
|
||||
MESH_DEBUG_PRINTLN("[Modem] CLCC: stat=%d", stat);
|
||||
if (stat == 0) {
|
||||
// Call is active — remote answered
|
||||
_state = ModemState::IN_CALL;
|
||||
_callStartTime = millis();
|
||||
queueCallEvent(CallEventType::CONNECTED, _callPhone);
|
||||
MESH_DEBUG_PRINTLN("[Modem] Call connected (detected via CLCC)");
|
||||
}
|
||||
// stat 2=dialing, 3=alerting — still setting up, keep polling
|
||||
}
|
||||
} else {
|
||||
// No +CLCC line in response — no active calls
|
||||
// This shouldn't happen during DIALING unless the call ended
|
||||
// and we missed the URC. Check state and clean up.
|
||||
// (NO CARRIER URC should have been caught by drainURCs)
|
||||
}
|
||||
}
|
||||
lastCLCCPoll = millis();
|
||||
}
|
||||
|
||||
// Periodic signal strength update
|
||||
if (millis() - lastCSQPoll > CSQ_POLL_INTERVAL) {
|
||||
pollCSQ();
|
||||
// ================================================================
|
||||
// Step 4: SMS and signal polling (only when not in a call)
|
||||
// Skip when paused to avoid Core 0 contention with WiFi/TLS.
|
||||
// The modem task's sendAT() calls (AT+CMGL 5s, AT+CSQ 2s) do
|
||||
// tight UART poll loops that disrupt WiFi packet timing.
|
||||
// ================================================================
|
||||
if (!_paused && !isCallActive()) {
|
||||
// Check for outgoing SMS in queue
|
||||
SMSOutgoing outMsg;
|
||||
if (xQueueReceive(_sendQueue, &outMsg, 0) == pdTRUE) {
|
||||
_state = ModemState::SENDING_SMS;
|
||||
bool ok = doSendSMS(outMsg.phone, outMsg.body);
|
||||
MESH_DEBUG_PRINTLN("[Modem] SMS send %s to %s", ok ? "OK" : "FAIL", outMsg.phone);
|
||||
_state = ModemState::READY;
|
||||
}
|
||||
|
||||
// Poll for incoming SMS periodically
|
||||
if (millis() - lastSMSPoll > SMS_POLL_INTERVAL) {
|
||||
pollIncomingSMS();
|
||||
lastSMSPoll = millis();
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic signal strength update (always, even during calls)
|
||||
if (!_paused && millis() - lastCSQPoll > CSQ_POLL_INTERVAL) {
|
||||
// Only poll CSQ if not actively in a call (avoid interrupting audio)
|
||||
if (!isCallActive()) {
|
||||
pollCSQ();
|
||||
}
|
||||
lastCSQPoll = millis();
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(500)); // 500ms loop — responsive for sends, calm for polls
|
||||
// Shorter delay during active call states for responsive URC handling
|
||||
if (isCallActive()) {
|
||||
vTaskDelay(pdMS_TO_TICKS(100)); // 100ms — responsive to URCs
|
||||
} else {
|
||||
vTaskDelay(pdMS_TO_TICKS(500)); // 500ms — normal idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,8 +959,7 @@ bool ModemManager::modemPowerOn() {
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
MESH_DEBUG_PRINTLN("[Modem] power supply enabled (GPIO %d HIGH)", MODEM_POWER_EN);
|
||||
|
||||
// Reset pulse — drive RST low briefly then release
|
||||
// (Some A7682E boards need this to clear stuck states)
|
||||
// Reset pulse
|
||||
pinMode(MODEM_RST, OUTPUT);
|
||||
digitalWrite(MODEM_RST, LOW);
|
||||
vTaskDelay(pdMS_TO_TICKS(200));
|
||||
@@ -343,29 +967,23 @@ bool ModemManager::modemPowerOn() {
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
MESH_DEBUG_PRINTLN("[Modem] reset pulse done (GPIO %d)", MODEM_RST);
|
||||
|
||||
// PWRKEY toggle: pull low for ≥1.5s then release
|
||||
// A7682E datasheet: PWRKEY low >1s triggers power-on
|
||||
// PWRKEY toggle
|
||||
pinMode(MODEM_PWRKEY, OUTPUT);
|
||||
digitalWrite(MODEM_PWRKEY, HIGH); // Start high (idle state)
|
||||
digitalWrite(MODEM_PWRKEY, HIGH);
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
digitalWrite(MODEM_PWRKEY, LOW); // Active-low trigger
|
||||
digitalWrite(MODEM_PWRKEY, LOW);
|
||||
vTaskDelay(pdMS_TO_TICKS(1500));
|
||||
digitalWrite(MODEM_PWRKEY, HIGH); // Release
|
||||
digitalWrite(MODEM_PWRKEY, HIGH);
|
||||
MESH_DEBUG_PRINTLN("[Modem] PWRKEY toggled, waiting for boot...");
|
||||
|
||||
// Wait for modem to boot — A7682E needs 3-5 seconds after PWRKEY
|
||||
vTaskDelay(pdMS_TO_TICKS(5000));
|
||||
|
||||
// Assert DTR LOW — many cellular modems require DTR active (LOW) for AT mode
|
||||
// Assert DTR LOW
|
||||
pinMode(MODEM_DTR, OUTPUT);
|
||||
digitalWrite(MODEM_DTR, LOW);
|
||||
MESH_DEBUG_PRINTLN("[Modem] DTR asserted LOW (GPIO %d)", MODEM_DTR);
|
||||
|
||||
// Configure UART
|
||||
// NOTE: variant.h pin names are modem-perspective, so:
|
||||
// MODEM_RX (GPIO 10) = modem receives = ESP32 TX out
|
||||
// MODEM_TX (GPIO 11) = modem transmits = ESP32 RX in
|
||||
// Serial1.begin(baud, config, ESP32_RX, ESP32_TX)
|
||||
MODEM_SERIAL.begin(MODEM_BAUD, SERIAL_8N1, MODEM_TX, MODEM_RX);
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
MESH_DEBUG_PRINTLN("[Modem] UART started (ESP32 RX=%d TX=%d @ %d)", MODEM_TX, MODEM_RX, MODEM_BAUD);
|
||||
@@ -373,7 +991,7 @@ bool ModemManager::modemPowerOn() {
|
||||
// Drain any boot garbage from UART
|
||||
while (MODEM_SERIAL.available()) MODEM_SERIAL.read();
|
||||
|
||||
// Test communication — generous attempts
|
||||
// Test communication
|
||||
for (int i = 0; i < 10; i++) {
|
||||
MESH_DEBUG_PRINTLN("[Modem] AT probe attempt %d/10", i + 1);
|
||||
if (sendAT("AT", "OK", 1500)) {
|
||||
@@ -392,14 +1010,13 @@ bool ModemManager::modemPowerOn() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool ModemManager::sendAT(const char* cmd, const char* expect, uint32_t timeout_ms) {
|
||||
// Flush any pending data
|
||||
while (MODEM_SERIAL.available()) MODEM_SERIAL.read();
|
||||
// Before flushing, drain any pending URCs so we don't lose them
|
||||
drainURCs();
|
||||
|
||||
Serial.printf("[Modem] TX: %s\n", cmd);
|
||||
MODEM_SERIAL.println(cmd);
|
||||
bool ok = waitResponse(expect, timeout_ms, _atBuf, AT_BUF_SIZE);
|
||||
if (_atBuf[0]) {
|
||||
// Trim trailing whitespace for cleaner log output
|
||||
int len = strlen(_atBuf);
|
||||
while (len > 0 && (_atBuf[len-1] == '\r' || _atBuf[len-1] == '\n')) _atBuf[--len] = '\0';
|
||||
Serial.printf("[Modem] RX: %s [%s]\n", _atBuf, ok ? "OK" : "FAIL");
|
||||
@@ -427,6 +1044,17 @@ bool ModemManager::waitResponse(const char* expect, uint32_t timeout_ms,
|
||||
if (buf && expect && strstr(buf, expect)) {
|
||||
return true;
|
||||
}
|
||||
// Also check for call-related URCs embedded in AT responses
|
||||
// (e.g. NO CARRIER can arrive during an AT+CLCC response)
|
||||
if (buf && strstr(buf, "NO CARRIER")) {
|
||||
processURCLine("NO CARRIER");
|
||||
}
|
||||
if (buf && strstr(buf, "BUSY")) {
|
||||
// Only process if we're in a call-related state
|
||||
if (_state == ModemState::DIALING) {
|
||||
processURCLine("BUSY");
|
||||
}
|
||||
}
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
@@ -457,23 +1085,21 @@ void ModemManager::pollIncomingSMS() {
|
||||
char* p = _atBuf;
|
||||
while ((p = strstr(p, "+CMGL:")) != nullptr) {
|
||||
int idx;
|
||||
char stat[16], phone[SMS_PHONE_LEN], timestamp[24];
|
||||
|
||||
char phone[SMS_PHONE_LEN];
|
||||
|
||||
// Parse header line
|
||||
// +CMGL: 1,"REC UNREAD","+1234567890","","26/02/15,10:30:00+00"
|
||||
char* lineEnd = strchr(p, '\n');
|
||||
if (!lineEnd) break;
|
||||
|
||||
// Extract index
|
||||
if (sscanf(p, "+CMGL: %d", &idx) != 1) { p = lineEnd + 1; continue; }
|
||||
|
||||
// Extract phone number (between first and second quote pair after stat)
|
||||
char* q1 = strchr(p + 7, '"'); // skip "+CMGL: N,"
|
||||
// Extract phone number
|
||||
char* q1 = strchr(p + 7, '"');
|
||||
if (!q1) { p = lineEnd + 1; continue; }
|
||||
q1++; // skip opening quote of stat
|
||||
char* q2 = strchr(q1, '"'); // end of stat
|
||||
q1++;
|
||||
char* q2 = strchr(q1, '"');
|
||||
if (!q2) { p = lineEnd + 1; continue; }
|
||||
// Next quoted field is the phone number
|
||||
char* q3 = strchr(q2 + 1, '"');
|
||||
if (!q3) { p = lineEnd + 1; continue; }
|
||||
q3++;
|
||||
@@ -497,7 +1123,7 @@ void ModemManager::pollIncomingSMS() {
|
||||
if (bodyLen >= SMS_BODY_LEN) bodyLen = SMS_BODY_LEN - 1;
|
||||
memcpy(incoming.body, p, bodyLen);
|
||||
incoming.body[bodyLen] = '\0';
|
||||
incoming.timestamp = (uint32_t)time(nullptr); // Real epoch from modem-synced clock
|
||||
incoming.timestamp = (uint32_t)time(nullptr);
|
||||
|
||||
// Queue for main loop
|
||||
xQueueSend(_recvQueue, &incoming, 0);
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
// 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)
|
||||
// =============================================================================
|
||||
|
||||
@@ -20,6 +22,7 @@
|
||||
#include <freertos/queue.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include "variant.h"
|
||||
#include "ApnDatabase.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modem pins (from variant.h, always defined for reference)
|
||||
@@ -38,14 +41,18 @@
|
||||
|
||||
// Task configuration
|
||||
#define MODEM_TASK_PRIORITY 1 // Below mesh (default loop = priority 1 on core 1)
|
||||
#define MODEM_TASK_STACK_SIZE 4096
|
||||
#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,
|
||||
@@ -53,9 +60,17 @@ enum class ModemState {
|
||||
REGISTERING,
|
||||
READY,
|
||||
ERROR,
|
||||
SENDING_SMS
|
||||
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];
|
||||
@@ -69,40 +84,142 @@ struct SMSIncoming {
|
||||
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();
|
||||
|
||||
// Non-blocking: queue an SMS for sending (returns false if queue full)
|
||||
// --- SMS API (unchanged) ---
|
||||
bool sendSMS(const char* phone, const char* body);
|
||||
|
||||
// Non-blocking: poll for received SMS (returns true if one was dequeued)
|
||||
bool recvSMS(SMSIncoming& out);
|
||||
|
||||
// State queries (lock-free reads)
|
||||
// --- 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(); // returns true if enabled (default)
|
||||
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);
|
||||
@@ -111,6 +228,21 @@ private:
|
||||
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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// SMSScreen - SMS messaging UI for T-Deck Pro (4G variant)
|
||||
// SMSScreen - SMS & Phone UI for T-Deck Pro (4G variant)
|
||||
//
|
||||
// Sub-views:
|
||||
// INBOX — list of conversations (names resolved via SMSContacts)
|
||||
// CONVERSATION — messages for a selected contact, scrollable
|
||||
// COMPOSE — text input for new SMS
|
||||
// CONTACTS — browsable contacts list, pick to compose
|
||||
// EDIT_CONTACT — add or edit a contact name for a phone number
|
||||
// APP_MENU — landing screen: choose Phone or SMS Inbox
|
||||
// INBOX — list of conversations (names resolved via SMSContacts)
|
||||
// CONVERSATION — messages for a selected contact, scrollable
|
||||
// COMPOSE — text input for new SMS
|
||||
// CONTACTS — browsable contacts list, pick to compose or call
|
||||
// EDIT_CONTACT — add or edit a contact name for a phone number
|
||||
// PHONE_DIALER — enter arbitrary phone number and call
|
||||
// DIALING_OUT — outgoing call in progress (waiting for answer)
|
||||
// INCOMING_CALL — incoming call ringing (answer or reject)
|
||||
// IN_CALL — active voice call with timer, DTMF, volume
|
||||
//
|
||||
// Navigation mirrors ChannelScreen conventions:
|
||||
// W/S: scroll Enter: select/send C: compose new/reply
|
||||
// Q: back Sh+Del: cancel compose
|
||||
// D: contacts (from inbox)
|
||||
// A: add/edit contact (from conversation)
|
||||
// F: call (from conversation, contacts, or phone dialer)
|
||||
//
|
||||
// Guard: HAS_4G_MODEM
|
||||
// =============================================================================
|
||||
@@ -40,12 +46,16 @@ class UITask; // forward declaration
|
||||
|
||||
class SMSScreen : public UIScreen {
|
||||
public:
|
||||
enum SubView { INBOX, CONVERSATION, COMPOSE, CONTACTS, EDIT_CONTACT };
|
||||
enum SubView { APP_MENU, INBOX, CONVERSATION, COMPOSE, CONTACTS, EDIT_CONTACT, PHONE_DIALER,
|
||||
DIALING_OUT, INCOMING_CALL, IN_CALL };
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
SubView _view;
|
||||
|
||||
// App menu state
|
||||
int _menuCursor; // 0 = Phone, 1 = SMS Inbox
|
||||
|
||||
// Inbox state
|
||||
SMSConversation _conversations[SMS_MAX_CONVERSATIONS];
|
||||
int _convCount;
|
||||
@@ -64,7 +74,7 @@ private:
|
||||
char _composePhone[SMS_PHONE_LEN];
|
||||
bool _composeNewConversation;
|
||||
|
||||
// Phone input state (for new conversation)
|
||||
// Phone input state (for new conversation and phone dialer)
|
||||
char _phoneInputBuf[SMS_PHONE_LEN];
|
||||
int _phoneInputPos;
|
||||
bool _enteringPhone;
|
||||
@@ -80,6 +90,13 @@ private:
|
||||
bool _editIsNew; // true = adding new, false = editing existing
|
||||
SubView _editReturnView; // where to return after save/cancel
|
||||
|
||||
// Voice call UI state
|
||||
char _callPhone[SMS_PHONE_LEN]; // Phone number for active call
|
||||
SubView _callReturnView; // View to return to after call ends
|
||||
unsigned long _callConnectTime; // millis() when call connected (UI timer)
|
||||
uint8_t _callVolume; // Current speaker volume (0-5)
|
||||
uint8_t _callDotAnim; // Animation frame for dialing dots
|
||||
|
||||
// Refresh debounce
|
||||
bool _needsRefresh;
|
||||
unsigned long _lastRefresh;
|
||||
@@ -101,13 +118,15 @@ private:
|
||||
|
||||
public:
|
||||
SMSScreen(UITask* task)
|
||||
: _task(task), _view(INBOX)
|
||||
: _task(task), _view(APP_MENU)
|
||||
, _menuCursor(0)
|
||||
, _convCount(0), _inboxCursor(0), _inboxScrollTop(0)
|
||||
, _msgCount(0), _msgScrollPos(0)
|
||||
, _composePos(0), _composeNewConversation(false)
|
||||
, _phoneInputPos(0), _enteringPhone(false)
|
||||
, _contactsCursor(0), _contactsScrollTop(0)
|
||||
, _editNamePos(0), _editIsNew(false), _editReturnView(INBOX)
|
||||
, _callReturnView(APP_MENU), _callConnectTime(0), _callVolume(3), _callDotAnim(0)
|
||||
, _needsRefresh(false), _lastRefresh(0)
|
||||
, _sdReady(false)
|
||||
{
|
||||
@@ -117,20 +136,101 @@ public:
|
||||
memset(_activePhone, 0, sizeof(_activePhone));
|
||||
memset(_editPhone, 0, sizeof(_editPhone));
|
||||
memset(_editNameBuf, 0, sizeof(_editNameBuf));
|
||||
memset(_callPhone, 0, sizeof(_callPhone));
|
||||
}
|
||||
|
||||
void setSDReady(bool ready) { _sdReady = ready; }
|
||||
|
||||
void activate() {
|
||||
_view = INBOX;
|
||||
_inboxCursor = 0;
|
||||
_inboxScrollTop = 0;
|
||||
_view = APP_MENU;
|
||||
_menuCursor = 0;
|
||||
if (_sdReady) refreshInbox();
|
||||
}
|
||||
|
||||
SubView getSubView() const { return _view; }
|
||||
bool isComposing() const { return _view == COMPOSE; }
|
||||
bool isEnteringPhone() const { return _enteringPhone; }
|
||||
bool isEnteringPhone() const { return _enteringPhone || _view == PHONE_DIALER; }
|
||||
bool isInCallView() const {
|
||||
return _view == DIALING_OUT || _view == INCOMING_CALL || _view == IN_CALL;
|
||||
}
|
||||
|
||||
// Transition to dialing screen — used by all dial callsites
|
||||
void startCall(const char* phone) {
|
||||
strncpy(_callPhone, phone, SMS_PHONE_LEN - 1);
|
||||
_callPhone[SMS_PHONE_LEN - 1] = '\0';
|
||||
_callReturnView = _view;
|
||||
_callConnectTime = 0;
|
||||
_callVolume = 3;
|
||||
_callDotAnim = 0;
|
||||
_view = DIALING_OUT;
|
||||
modemManager.dialCall(phone);
|
||||
}
|
||||
|
||||
// Handle call events from modem (incoming, connected, ended, etc.)
|
||||
void onCallEvent(const CallEvent& evt) {
|
||||
switch (evt.type) {
|
||||
case CallEventType::INCOMING:
|
||||
Serial.printf("[SMSScreen] Incoming call from %s\n", evt.phone);
|
||||
strncpy(_callPhone, evt.phone, SMS_PHONE_LEN - 1);
|
||||
_callPhone[SMS_PHONE_LEN - 1] = '\0';
|
||||
_callConnectTime = 0;
|
||||
_callVolume = 3;
|
||||
if (!isInCallView()) {
|
||||
_callReturnView = _view;
|
||||
}
|
||||
_view = INCOMING_CALL;
|
||||
_needsRefresh = true;
|
||||
break;
|
||||
|
||||
case CallEventType::CONNECTED:
|
||||
Serial.printf("[SMSScreen] Call connected: %s\n", evt.phone);
|
||||
_callConnectTime = millis();
|
||||
_view = IN_CALL;
|
||||
_needsRefresh = true;
|
||||
break;
|
||||
|
||||
case CallEventType::ENDED:
|
||||
Serial.printf("[SMSScreen] Call ended (%lus)\n", (unsigned long)evt.duration);
|
||||
if (_view == IN_CALL || _view == DIALING_OUT) {
|
||||
// Remote hangup or network drop — return to previous view
|
||||
// "Call Ended" alert is shown by main.cpp via showAlert()
|
||||
_view = _callReturnView;
|
||||
}
|
||||
_callPhone[0] = '\0';
|
||||
_callConnectTime = 0;
|
||||
_needsRefresh = true;
|
||||
break;
|
||||
|
||||
case CallEventType::MISSED:
|
||||
Serial.printf("[SMSScreen] Missed call from %s\n", evt.phone);
|
||||
_view = _callReturnView;
|
||||
_callPhone[0] = '\0';
|
||||
_callConnectTime = 0;
|
||||
_needsRefresh = true;
|
||||
break;
|
||||
|
||||
case CallEventType::BUSY:
|
||||
Serial.printf("[SMSScreen] Busy: %s\n", evt.phone);
|
||||
_view = _callReturnView;
|
||||
_callPhone[0] = '\0';
|
||||
_needsRefresh = true;
|
||||
break;
|
||||
|
||||
case CallEventType::NO_ANSWER:
|
||||
Serial.printf("[SMSScreen] No answer: %s\n", evt.phone);
|
||||
_view = _callReturnView;
|
||||
_callPhone[0] = '\0';
|
||||
_needsRefresh = true;
|
||||
break;
|
||||
|
||||
case CallEventType::DIAL_FAILED:
|
||||
Serial.printf("[SMSScreen] Dial failed: %s\n", evt.phone);
|
||||
_view = _callReturnView;
|
||||
_callPhone[0] = '\0';
|
||||
_needsRefresh = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Called from main loop when an SMS arrives (saves to store + refreshes)
|
||||
void onIncomingSMS(const char* phone, const char* body, uint32_t timestamp) {
|
||||
@@ -197,15 +297,236 @@ public:
|
||||
_lastRefresh = millis();
|
||||
|
||||
switch (_view) {
|
||||
case INBOX: return renderInbox(display);
|
||||
case CONVERSATION: return renderConversation(display);
|
||||
case COMPOSE: return renderCompose(display);
|
||||
case CONTACTS: return renderContacts(display);
|
||||
case EDIT_CONTACT: return renderEditContact(display);
|
||||
case APP_MENU: return renderAppMenu(display);
|
||||
case INBOX: return renderInbox(display);
|
||||
case CONVERSATION: return renderConversation(display);
|
||||
case COMPOSE: return renderCompose(display);
|
||||
case CONTACTS: return renderContacts(display);
|
||||
case EDIT_CONTACT: return renderEditContact(display);
|
||||
case PHONE_DIALER: return renderPhoneDialer(display);
|
||||
case DIALING_OUT: return renderDialingOut(display);
|
||||
case INCOMING_CALL: return renderIncomingCall(display);
|
||||
case IN_CALL: return renderInCall(display);
|
||||
}
|
||||
return 1000;
|
||||
}
|
||||
|
||||
// ---- App menu (landing screen) ----
|
||||
int renderAppMenu(DisplayDriver& display) {
|
||||
// Header
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
display.print("Phone & SMS");
|
||||
|
||||
// Signal strength at top-right
|
||||
renderSignalIndicator(display, display.width() - 2, 0);
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// Menu items
|
||||
display.setTextSize(1);
|
||||
int y = 24;
|
||||
int lineHeight = 16;
|
||||
|
||||
// Item 0: Phone
|
||||
display.setCursor(4, y);
|
||||
display.setColor(_menuCursor == 0 ? DisplayDriver::GREEN : DisplayDriver::LIGHT);
|
||||
if (_menuCursor == 0) display.print("> ");
|
||||
else display.print(" ");
|
||||
display.print("Phone");
|
||||
|
||||
y += lineHeight;
|
||||
|
||||
// Item 1: SMS Inbox
|
||||
display.setCursor(4, y);
|
||||
display.setColor(_menuCursor == 1 ? DisplayDriver::GREEN : DisplayDriver::LIGHT);
|
||||
if (_menuCursor == 1) display.print("> ");
|
||||
else display.print(" ");
|
||||
display.print("SMS Inbox");
|
||||
|
||||
// Show conversation count hint
|
||||
if (_convCount > 0) {
|
||||
char countHint[12];
|
||||
snprintf(countHint, sizeof(countHint), " [%d]", _convCount);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print(countHint);
|
||||
}
|
||||
|
||||
// Modem status indicator
|
||||
ModemState ms = modemManager.getState();
|
||||
display.setTextSize(0);
|
||||
display.setCursor(4, y + lineHeight + 8);
|
||||
if (ms == ModemState::OFF || ms == ModemState::POWERING_ON ||
|
||||
ms == ModemState::INITIALIZING) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("Please wait...");
|
||||
} else if (ms == ModemState::ERROR) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
char statBuf[40];
|
||||
snprintf(statBuf, sizeof(statBuf), "Modem: %s", ModemManager::stateToString(ms));
|
||||
display.print(statBuf);
|
||||
} else if (ms == ModemState::REGISTERING || ms == ModemState::READY ||
|
||||
ms == ModemState::SENDING_SMS) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.print("Ready!");
|
||||
}
|
||||
display.setTextSize(1);
|
||||
|
||||
// Footer
|
||||
display.setTextSize(1);
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Back");
|
||||
const char* rt = "Ent:Open";
|
||||
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
|
||||
display.print(rt);
|
||||
|
||||
if (ms != ModemState::READY && ms != ModemState::SENDING_SMS) {
|
||||
return 1000;
|
||||
}
|
||||
return 5000;
|
||||
}
|
||||
|
||||
// ---- Phone dialer with touch numpad ----
|
||||
|
||||
// Numpad layout constants — computed dynamically from display dimensions
|
||||
static const int NUMPAD_ROWS = 5;
|
||||
static const int NUMPAD_COLS = 3;
|
||||
|
||||
// Button labels: [row][col]
|
||||
const char* numpadLabel(int row, int col) const {
|
||||
static const char* labels[5][3] = {
|
||||
{"1", "2", "3"},
|
||||
{"4", "5", "6"},
|
||||
{"7", "8", "9"},
|
||||
{"*", "0", "#"},
|
||||
{"+", "DEL", "CALL"}
|
||||
};
|
||||
return labels[row][col];
|
||||
}
|
||||
|
||||
// Button character values: '\b' = backspace, '\r' = call
|
||||
char numpadChar(int row, int col) const {
|
||||
static const char chars[5][3] = {
|
||||
{'1', '2', '3'},
|
||||
{'4', '5', '6'},
|
||||
{'7', '8', '9'},
|
||||
{'*', '0', '#'},
|
||||
{'+', '\b', '\r'}
|
||||
};
|
||||
return chars[row][col];
|
||||
}
|
||||
|
||||
int renderPhoneDialer(DisplayDriver& display) {
|
||||
int W = display.width();
|
||||
int H = display.height();
|
||||
|
||||
// Layout regions (dynamic based on display size)
|
||||
int headerH = 12;
|
||||
int phoneFieldH = 14;
|
||||
int footerH = 12;
|
||||
int numpadTop = headerH + phoneFieldH;
|
||||
int numpadH = H - numpadTop - footerH;
|
||||
int rowH = numpadH / NUMPAD_ROWS;
|
||||
int colW = W / NUMPAD_COLS;
|
||||
|
||||
// Header
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
display.print("Dial Number");
|
||||
|
||||
// Signal strength at top-right
|
||||
renderSignalIndicator(display, W - 2, 0);
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, headerH - 1, W, 1);
|
||||
|
||||
// Phone number field
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(2, headerH + 2);
|
||||
if (_phoneInputPos > 0) {
|
||||
display.print(_phoneInputBuf);
|
||||
}
|
||||
display.print("_");
|
||||
|
||||
// Separator above numpad
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, numpadTop - 1, W, 1);
|
||||
|
||||
// Draw numpad grid
|
||||
for (int row = 0; row < NUMPAD_ROWS; row++) {
|
||||
int y = numpadTop + row * rowH;
|
||||
|
||||
// Row separator
|
||||
if (row > 0) {
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.drawRect(0, y, W, 1);
|
||||
}
|
||||
|
||||
for (int col = 0; col < NUMPAD_COLS; col++) {
|
||||
int x = col * colW;
|
||||
|
||||
// Column separator
|
||||
if (col > 0) {
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.drawRect(x, y, 1, rowH);
|
||||
}
|
||||
|
||||
// Button label - centered in cell
|
||||
const char* label = numpadLabel(row, col);
|
||||
bool isAction = (row == 4); // Bottom row has action buttons
|
||||
|
||||
if (isAction) {
|
||||
display.setTextSize(0);
|
||||
if (col == 2 && _phoneInputPos > 0) {
|
||||
display.setColor(DisplayDriver::GREEN); // CALL
|
||||
} else if (col == 1) {
|
||||
display.setColor(DisplayDriver::YELLOW); // DEL
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
} else {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
uint16_t textW = display.getTextWidth(label);
|
||||
int textH = isAction ? 7 : 8;
|
||||
int cx = x + (colW - textW) / 2;
|
||||
int cy = y + (rowH - textH) / 2;
|
||||
display.setCursor(cx, cy);
|
||||
display.print(label);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
display.setTextSize(1);
|
||||
int footerY = H - footerH;
|
||||
display.drawRect(0, footerY - 1, W, 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bk");
|
||||
if (_phoneInputPos > 0) {
|
||||
const char* rt = "Ent:Call";
|
||||
display.setCursor(W - display.getTextWidth(rt) - 2, footerY);
|
||||
display.print(rt);
|
||||
} else {
|
||||
// Hint: letter keys type numbers directly
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
const char* hint = "W-C=1-9";
|
||||
display.setCursor(W - display.getTextWidth(hint) - 2, footerY);
|
||||
display.print(hint);
|
||||
}
|
||||
|
||||
return 2000;
|
||||
}
|
||||
|
||||
// ---- Inbox ----
|
||||
int renderInbox(DisplayDriver& display) {
|
||||
ModemState ms = modemManager.getState();
|
||||
@@ -561,7 +882,7 @@ public:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Back");
|
||||
const char* rt = "Ent:SMS";
|
||||
const char* rt = "Ent:SMS F:Call";
|
||||
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
|
||||
display.print(rt);
|
||||
|
||||
@@ -609,21 +930,376 @@ public:
|
||||
return 2000;
|
||||
}
|
||||
|
||||
// ---- Dialing out (waiting for remote answer) ----
|
||||
int renderDialingOut(DisplayDriver& display) {
|
||||
int W = display.width();
|
||||
int H = display.height();
|
||||
|
||||
// Header
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
display.print("Calling");
|
||||
|
||||
renderSignalIndicator(display, W - 2, 0);
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, 11, W, 1);
|
||||
|
||||
// Contact name (left-aligned)
|
||||
char dispName[SMS_CONTACT_NAME_LEN];
|
||||
smsContacts.displayName(_callPhone, dispName, sizeof(dispName));
|
||||
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 20);
|
||||
display.print(dispName);
|
||||
|
||||
// Phone number below name (smaller, dimmer)
|
||||
display.setTextSize(0);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 36);
|
||||
display.print(_callPhone);
|
||||
|
||||
// Animated dots (centered)
|
||||
_callDotAnim = (_callDotAnim + 1) % 4;
|
||||
char dots[4] = {0};
|
||||
for (int i = 0; i < (int)_callDotAnim; i++) dots[i] = '.';
|
||||
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
const char* dialLabel = "Dialing";
|
||||
uint16_t dialW = display.getTextWidth(dialLabel);
|
||||
display.setCursor((W - dialW) / 2 - 6, H / 2 + 4);
|
||||
display.print(dialLabel);
|
||||
display.print(dots);
|
||||
|
||||
// Footer
|
||||
display.setTextSize(1);
|
||||
int footerY = H - 12;
|
||||
display.drawRect(0, footerY - 2, W, 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Ent/Q:Hang up");
|
||||
|
||||
return 800; // Fast refresh for dot animation
|
||||
}
|
||||
|
||||
// ---- Incoming call (ringing) ----
|
||||
int renderIncomingCall(DisplayDriver& display) {
|
||||
int W = display.width();
|
||||
int H = display.height();
|
||||
|
||||
// Header
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
display.print("Incoming Call");
|
||||
|
||||
renderSignalIndicator(display, W - 2, 0);
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, 11, W, 1);
|
||||
|
||||
// Caller name (left-aligned)
|
||||
char dispName[SMS_CONTACT_NAME_LEN];
|
||||
smsContacts.displayName(_callPhone, dispName, sizeof(dispName));
|
||||
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 20);
|
||||
display.print(dispName);
|
||||
|
||||
// Phone number below name (smaller, dimmer)
|
||||
display.setTextSize(0);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 36);
|
||||
display.print(_callPhone);
|
||||
|
||||
// Ringing indicator (centered)
|
||||
_callDotAnim = (_callDotAnim + 1) % 4;
|
||||
char dots[4] = {0};
|
||||
for (int i = 0; i < (int)_callDotAnim; i++) dots[i] = '.';
|
||||
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
const char* ringLabel = "Ringing";
|
||||
uint16_t ringW = display.getTextWidth(ringLabel);
|
||||
display.setCursor((W - ringW) / 2 - 6, H / 2 + 4);
|
||||
display.print(ringLabel);
|
||||
display.print(dots);
|
||||
|
||||
// Footer
|
||||
display.setTextSize(1);
|
||||
int footerY = H - 12;
|
||||
display.drawRect(0, footerY - 2, W, 1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Ent:Answer");
|
||||
const char* rt = "Q:Reject";
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(W - display.getTextWidth(rt) - 2, footerY);
|
||||
display.print(rt);
|
||||
|
||||
return 800; // Fast refresh for ring animation
|
||||
}
|
||||
|
||||
// ---- In call (active voice call) ----
|
||||
int renderInCall(DisplayDriver& display) {
|
||||
int W = display.width();
|
||||
int H = display.height();
|
||||
|
||||
// Header
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
display.print("In Call");
|
||||
|
||||
renderSignalIndicator(display, W - 2, 0);
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, 11, W, 1);
|
||||
|
||||
// Contact name (left-aligned)
|
||||
char dispName[SMS_CONTACT_NAME_LEN];
|
||||
smsContacts.displayName(_callPhone, dispName, sizeof(dispName));
|
||||
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 20);
|
||||
display.print(dispName);
|
||||
|
||||
// Phone number below name (smaller, dimmer)
|
||||
display.setTextSize(0);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 36);
|
||||
display.print(_callPhone);
|
||||
|
||||
// Call timer (centered)
|
||||
unsigned long elapsed = 0;
|
||||
if (_callConnectTime > 0) {
|
||||
elapsed = (millis() - _callConnectTime) / 1000;
|
||||
}
|
||||
char timeBuf[12];
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%02lu:%02lu", elapsed / 60, elapsed % 60);
|
||||
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
uint16_t timerW = display.getTextWidth(timeBuf);
|
||||
display.setCursor((W - timerW) / 2, H / 2 + 4);
|
||||
display.print(timeBuf);
|
||||
|
||||
// Volume (left-aligned)
|
||||
display.setTextSize(0);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
char volLabel[12];
|
||||
snprintf(volLabel, sizeof(volLabel), "Vol: %d/5", _callVolume);
|
||||
display.setCursor(4, H / 2 + 28);
|
||||
display.print(volLabel);
|
||||
display.setTextSize(1);
|
||||
|
||||
// Footer
|
||||
display.setTextSize(1);
|
||||
int footerY = H - 12;
|
||||
display.drawRect(0, footerY - 2, W, 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Ent:Hang W/S:Vol 0-9:DTMF");
|
||||
|
||||
return 1000; // 1s refresh for timer
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// INPUT HANDLING
|
||||
// =========================================================================
|
||||
|
||||
bool handleInput(char c) override {
|
||||
switch (_view) {
|
||||
case INBOX: return handleInboxInput(c);
|
||||
case CONVERSATION: return handleConversationInput(c);
|
||||
case COMPOSE: return handleComposeInput(c);
|
||||
case CONTACTS: return handleContactsInput(c);
|
||||
case EDIT_CONTACT: return handleEditContactInput(c);
|
||||
case APP_MENU: return handleAppMenuInput(c);
|
||||
case INBOX: return handleInboxInput(c);
|
||||
case CONVERSATION: return handleConversationInput(c);
|
||||
case COMPOSE: return handleComposeInput(c);
|
||||
case CONTACTS: return handleContactsInput(c);
|
||||
case EDIT_CONTACT: return handleEditContactInput(c);
|
||||
case PHONE_DIALER: return handlePhoneDialerInput(c);
|
||||
case DIALING_OUT: return handleDialingOutInput(c);
|
||||
case INCOMING_CALL: return handleIncomingCallInput(c);
|
||||
case IN_CALL: return handleInCallInput(c);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- App menu input ----
|
||||
bool handleAppMenuInput(char c) {
|
||||
switch (c) {
|
||||
case 'w': case 'W':
|
||||
_menuCursor = 0;
|
||||
return true;
|
||||
|
||||
case 's': case 'S':
|
||||
_menuCursor = 1;
|
||||
return true;
|
||||
|
||||
case '\r': // Enter - select menu item
|
||||
if (_menuCursor == 0) {
|
||||
// Phone dialer
|
||||
_phoneInputBuf[0] = '\0';
|
||||
_phoneInputPos = 0;
|
||||
_view = PHONE_DIALER;
|
||||
} else {
|
||||
// SMS Inbox
|
||||
if (_sdReady) refreshInbox();
|
||||
_inboxCursor = 0;
|
||||
_inboxScrollTop = 0;
|
||||
_view = INBOX;
|
||||
}
|
||||
return true;
|
||||
|
||||
case 'q': case 'Q': // Back to home (handled by main.cpp)
|
||||
return false;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Phone dialer input (keyboard) ----
|
||||
//
|
||||
// Three ways to enter digits:
|
||||
// 1. Touch the on-screen numpad
|
||||
// 2. Sym+key (normal keyboard number entry)
|
||||
// 3. Just press the letter key — the dialer maps it automatically
|
||||
// using the silk-screened number labels on the keyboard:
|
||||
// w=1 e=2 r=3 | s=4 d=5 f=6 | z=7 x=8 c=9
|
||||
// q=# a=* o=+ | 0=mic key (arrives as sym+'0')
|
||||
|
||||
// Map a letter key to its dialer equivalent (0 = no mapping)
|
||||
// Note: 'q' is reserved for back navigation, use sym+q or touch for '#'
|
||||
char dialerKeyMap(char c) {
|
||||
switch (c) {
|
||||
case 'w': return '1'; case 'e': return '2'; case 'r': return '3';
|
||||
case 's': return '4'; case 'd': return '5'; case 'f': return '6';
|
||||
case 'z': return '7'; case 'x': return '8'; case 'c': return '9';
|
||||
case 'a': return '*'; case 'o': return '+';
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
bool handlePhoneDialerInput(char c) {
|
||||
switch (c) {
|
||||
case '\r': // Enter - place call
|
||||
if (_phoneInputPos > 0) {
|
||||
_phoneInputBuf[_phoneInputPos] = '\0';
|
||||
startCall(_phoneInputBuf);
|
||||
}
|
||||
return true;
|
||||
|
||||
case '\b': // Backspace
|
||||
if (_phoneInputPos > 0) {
|
||||
_phoneInputPos--;
|
||||
_phoneInputBuf[_phoneInputPos] = '\0';
|
||||
}
|
||||
return true;
|
||||
|
||||
case 'q': // Back to app menu
|
||||
_phoneInputBuf[0] = '\0';
|
||||
_phoneInputPos = 0;
|
||||
_view = APP_MENU;
|
||||
return true;
|
||||
|
||||
default:
|
||||
// Accept phone number characters directly (from sym+key)
|
||||
if ((c >= '0' && c <= '9') || c == '+' || c == '*' || c == '#') {
|
||||
if (_phoneInputPos < SMS_PHONE_LEN - 1) {
|
||||
_phoneInputBuf[_phoneInputPos++] = c;
|
||||
_phoneInputBuf[_phoneInputPos] = '\0';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Map plain letter keys to their silk-screened number equivalents
|
||||
char mapped = dialerKeyMap(c);
|
||||
if (mapped) {
|
||||
if (_phoneInputPos < SMS_PHONE_LEN - 1) {
|
||||
_phoneInputBuf[_phoneInputPos++] = mapped;
|
||||
_phoneInputBuf[_phoneInputPos] = '\0';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Touch numpad input (called from main loop when touch detected) ----
|
||||
|
||||
// Process a touch event at PHYSICAL display coordinates (px, py).
|
||||
// The touch controller reports 240x320; display draws in virtual coords.
|
||||
// Returns true if the touch was consumed (a button was pressed).
|
||||
// Caller should call forceRefresh() after this returns true.
|
||||
//
|
||||
// dispW/dispH: virtual display dimensions (display.width()/height())
|
||||
// physW/physH: physical touch panel dimensions (240/320)
|
||||
bool handleTouch(int16_t px, int16_t py, int dispW = 128, int dispH = 128,
|
||||
int physW = 240, int physH = 320) {
|
||||
if (_view != PHONE_DIALER) return false;
|
||||
|
||||
// Map physical touch coordinates to virtual display coordinates
|
||||
int x = (int)px * dispW / physW;
|
||||
int y = (int)py * dispH / physH;
|
||||
|
||||
// Compute layout (must match renderPhoneDialer)
|
||||
int headerH = 12;
|
||||
int phoneFieldH = 14;
|
||||
int footerH = 12;
|
||||
int numpadTop = headerH + phoneFieldH;
|
||||
int numpadH = dispH - numpadTop - footerH;
|
||||
int rowH = numpadH / NUMPAD_ROWS;
|
||||
int colW = dispW / NUMPAD_COLS;
|
||||
|
||||
// Check bounds: must be within numpad grid
|
||||
if (y < numpadTop || y >= numpadTop + NUMPAD_ROWS * rowH) return false;
|
||||
if (x < 0 || x >= NUMPAD_COLS * colW) return false;
|
||||
|
||||
// Map coordinates to grid cell
|
||||
int col = x / colW;
|
||||
int row = (y - numpadTop) / rowH;
|
||||
|
||||
if (col < 0 || col >= NUMPAD_COLS || row < 0 || row >= NUMPAD_ROWS) return false;
|
||||
|
||||
char c = numpadChar(row, col);
|
||||
bool changed = false;
|
||||
|
||||
if (c == '\r') {
|
||||
// CALL button
|
||||
if (_phoneInputPos > 0) {
|
||||
_phoneInputBuf[_phoneInputPos] = '\0';
|
||||
startCall(_phoneInputBuf);
|
||||
changed = true;
|
||||
}
|
||||
} else if (c == '\b') {
|
||||
// DEL button
|
||||
if (_phoneInputPos > 0) {
|
||||
_phoneInputPos--;
|
||||
_phoneInputBuf[_phoneInputPos] = '\0';
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
// Digit/symbol button
|
||||
if (_phoneInputPos < SMS_PHONE_LEN - 1) {
|
||||
_phoneInputBuf[_phoneInputPos++] = c;
|
||||
_phoneInputBuf[_phoneInputPos] = '\0';
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("[Touch] Numpad: phys=(%d,%d) virt=(%d,%d) row=%d col=%d btn=%s %s\n",
|
||||
px, py, x, y, row, col, numpadLabel(row, col),
|
||||
changed ? "OK" : "NOOP");
|
||||
return changed;
|
||||
}
|
||||
|
||||
// clearTouch() is a no-op with time-based debounce, kept for API compat
|
||||
void clearTouch() { }
|
||||
|
||||
// ---- Inbox input ----
|
||||
bool handleInboxInput(char c) {
|
||||
switch (c) {
|
||||
@@ -659,8 +1335,10 @@ public:
|
||||
_view = CONTACTS;
|
||||
return true;
|
||||
|
||||
case 'q': case 'Q': // Back to home (handled by main.cpp)
|
||||
return false;
|
||||
case 'q': case 'Q': // Back to app menu
|
||||
_view = APP_MENU;
|
||||
_menuCursor = 0;
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
@@ -687,6 +1365,12 @@ public:
|
||||
_view = COMPOSE;
|
||||
return true;
|
||||
|
||||
case 'f': case 'F': // Call this number
|
||||
if (_activePhone[0] != '\0') {
|
||||
startCall(_activePhone);
|
||||
}
|
||||
return true;
|
||||
|
||||
case 'a': case 'A': { // Add/edit contact for this number
|
||||
strncpy(_editPhone, _activePhone, SMS_PHONE_LEN - 1);
|
||||
_editPhone[SMS_PHONE_LEN - 1] = '\0';
|
||||
@@ -765,7 +1449,7 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Phone number input ----
|
||||
// ---- Phone number input (for compose) ----
|
||||
bool handlePhoneInput(char c) {
|
||||
switch (c) {
|
||||
case '\r': // Done entering phone, move to body
|
||||
@@ -828,6 +1512,13 @@ public:
|
||||
}
|
||||
return true;
|
||||
|
||||
case 'f': case 'F': // Call selected contact
|
||||
if (cnt > 0 && _contactsCursor < cnt) {
|
||||
const SMSContact& ct = smsContacts.get(_contactsCursor);
|
||||
startCall(ct.phone);
|
||||
}
|
||||
return true;
|
||||
|
||||
case 'q': case 'Q': // Back to inbox
|
||||
refreshInbox();
|
||||
_view = INBOX;
|
||||
@@ -879,6 +1570,73 @@ public:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Dialing out input (Enter or Q to cancel/hang up) ----
|
||||
bool handleDialingOutInput(char c) {
|
||||
switch (c) {
|
||||
case '\r': // Enter - hang up
|
||||
case 'q': case 'Q':
|
||||
modemManager.hangupCall();
|
||||
_view = _callReturnView;
|
||||
_callPhone[0] = '\0';
|
||||
return true;
|
||||
|
||||
default:
|
||||
return true; // Absorb all other keys
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Incoming call input (Enter to answer, Q to reject) ----
|
||||
bool handleIncomingCallInput(char c) {
|
||||
switch (c) {
|
||||
case '\r': // Enter - answer call
|
||||
modemManager.answerCall();
|
||||
return true;
|
||||
|
||||
case 'q': case 'Q': // Reject call
|
||||
modemManager.hangupCall();
|
||||
_view = _callReturnView;
|
||||
_callPhone[0] = '\0';
|
||||
return true;
|
||||
|
||||
default:
|
||||
return true; // Absorb all other keys
|
||||
}
|
||||
}
|
||||
|
||||
// ---- In call input (hangup, volume, DTMF) ----
|
||||
bool handleInCallInput(char c) {
|
||||
switch (c) {
|
||||
case '\r': // Enter - hang up
|
||||
case 'q': case 'Q':
|
||||
modemManager.hangupCall();
|
||||
_view = _callReturnView;
|
||||
_callPhone[0] = '\0';
|
||||
_callConnectTime = 0;
|
||||
return true;
|
||||
|
||||
case 'w': case 'W': // Volume up
|
||||
if (_callVolume < 5) {
|
||||
_callVolume++;
|
||||
modemManager.setCallVolume(_callVolume);
|
||||
}
|
||||
return true;
|
||||
|
||||
case 's': case 'S': // Volume down
|
||||
if (_callVolume > 0) {
|
||||
_callVolume--;
|
||||
modemManager.setCallVolume(_callVolume);
|
||||
}
|
||||
return true;
|
||||
|
||||
default:
|
||||
// DTMF tones: 0-9, *, #
|
||||
if ((c >= '0' && c <= '9') || c == '*' || c == '#') {
|
||||
modemManager.sendDTMF(c);
|
||||
}
|
||||
return true; // Absorb all keys during call
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#endif // SMS_SCREEN_H
|
||||
|
||||
@@ -69,6 +69,11 @@ enum SettingsRowType : uint8_t {
|
||||
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
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -83,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 {
|
||||
@@ -160,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;
|
||||
@@ -177,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) {
|
||||
@@ -548,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;
|
||||
}
|
||||
@@ -557,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;
|
||||
@@ -673,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) {
|
||||
@@ -876,6 +956,12 @@ public:
|
||||
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("");
|
||||
|
||||
128
examples/companion_radio/ui-new/Touchinput.h
Normal file
128
examples/companion_radio/ui-new/Touchinput.h
Normal 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
|
||||
@@ -118,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];
|
||||
@@ -221,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() {
|
||||
@@ -232,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();
|
||||
}
|
||||
}
|
||||
@@ -296,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;
|
||||
|
||||
@@ -335,11 +336,15 @@ public:
|
||||
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
|
||||
y += 10;
|
||||
#ifdef HAS_4G_MODEM
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader [T] SMS ");
|
||||
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;
|
||||
|
||||
@@ -397,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);
|
||||
@@ -626,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);
|
||||
@@ -729,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;
|
||||
@@ -958,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
|
||||
@@ -1038,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();
|
||||
}
|
||||
}
|
||||
@@ -1315,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();
|
||||
@@ -1412,6 +1473,10 @@ 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];
|
||||
@@ -1421,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) {
|
||||
@@ -1446,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);
|
||||
@@ -1460,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;
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
#include "SMSScreen.h"
|
||||
#endif
|
||||
|
||||
#ifdef MECK_WEB_READER
|
||||
#include "WebReaderScreen.h"
|
||||
#endif
|
||||
|
||||
class UITask : public AbstractUITask {
|
||||
DisplayDriver* _display;
|
||||
SensorManager* _sensors;
|
||||
@@ -66,6 +70,9 @@ class UITask : public AbstractUITask {
|
||||
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();
|
||||
@@ -97,6 +104,9 @@ 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; }
|
||||
@@ -104,7 +114,14 @@ public:
|
||||
#endif
|
||||
void showAlert(const char* text, int duration_millis) override;
|
||||
void forceRefresh() override { _next_refresh = 100; }
|
||||
// Wake display and extend auto-off timer. Call this when handling keys
|
||||
// outside of injectKey() to prevent display auto-off during direct input.
|
||||
void keepAlive() {
|
||||
if (_display != NULL && !_display->isOn()) _display->turnOn();
|
||||
_auto_off = millis() + 15000; // matches AUTO_OFF_MILLIS default
|
||||
}
|
||||
int getMsgCount() const { return _msgcount; }
|
||||
int getUnreadMsgCount() const; // Per-channel unread tracking (standalone)
|
||||
bool hasDisplay() const { return _display != NULL; }
|
||||
bool isButtonPressed() const;
|
||||
bool isOnChannelScreen() const { return curr == channel_screen; }
|
||||
@@ -114,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)
|
||||
@@ -135,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; }
|
||||
@@ -150,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;
|
||||
|
||||
5278
examples/companion_radio/ui-new/Webreaderscreen.h
Normal file
5278
examples/companion_radio/ui-new/Webreaderscreen.h
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
16
examples/companion_radio/ui-new/webreaderdeps.cpp
Normal file
16
examples/companion_radio/ui-new/webreaderdeps.cpp
Normal 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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -46,10 +46,9 @@ void TDeckBoard::begin() {
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - GPS Serial2 initialized at %d baud", GPS_BAUDRATE);
|
||||
#endif
|
||||
|
||||
// 4G Modem power management
|
||||
// On 4G builds, ModemManager::begin() handles power-on — don't kill it here.
|
||||
// On non-4G builds, disable modem power to save current and turn off red LED.
|
||||
#if defined(MODEM_POWER_EN) && !defined(HAS_4G_MODEM)
|
||||
// Disable 4G modem power (only present on 4G version, not audio version)
|
||||
// This turns off the red status LED on the modem module
|
||||
#ifdef MODEM_POWER_EN
|
||||
pinMode(MODEM_POWER_EN, OUTPUT);
|
||||
digitalWrite(MODEM_POWER_EN, LOW); // Cut power to modem
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - 4G modem power disabled");
|
||||
@@ -178,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;
|
||||
}
|
||||
|
||||
@@ -282,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...");
|
||||
@@ -292,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);
|
||||
|
||||
@@ -354,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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,17 +145,45 @@ 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 FIRMWARE_VERSION='"Meck v0.9.2-4G"'
|
||||
-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>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
; 4G standalone (4G modem hardware, no BLE — maximum battery + cellular features)
|
||||
; No BLE_PIN_CODE: BLE never initializes, saving ~30KB heap + radio power.
|
||||
; MECK_WEB_READER enabled: works better without BLE (no teardown dance needed,
|
||||
; more free heap from boot). WiFi-first with cellular PPP fallback (future).
|
||||
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
|
||||
[env:meck_4g_standalone]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=1500
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D OFFLINE_QUEUE_SIZE=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>
|
||||
|
||||
Reference in New Issue
Block a user