mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e91ad4bac4 | ||
|
|
db58f8cf87 | ||
|
|
a74b1c3f7a | ||
|
|
12477af8c7 | ||
|
|
49d399c4d6 | ||
|
|
33f2e0fc6e | ||
|
|
7685de4be6 | ||
|
|
3f4da4bc2b | ||
|
|
fe949235d9 | ||
|
|
d92fdc9ffe | ||
|
|
3a6673edea | ||
|
|
e2a04892f4 | ||
|
|
31db349305 | ||
|
|
b444a664c5 | ||
|
|
4e4c6cba80 | ||
|
|
a178d43046 | ||
|
|
36c5fafec6 | ||
|
|
5260f0ccea | ||
|
|
edf3fb7fff | ||
|
|
129a75ed4e | ||
|
|
1ecda1a8f5 | ||
|
|
4bb721e060 | ||
|
|
4646fd6bd9 | ||
|
|
d1104d0b9c | ||
|
|
513715e472 | ||
|
|
1dfab7d9a6 | ||
|
|
4724cded26 | ||
|
|
74d5bfef70 | ||
|
|
e9540bcf23 | ||
|
|
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 | ||
|
|
b812ff75a9 | ||
|
|
4477d5c812 | ||
|
|
f06a1f5499 | ||
|
|
458db8d4c4 | ||
|
|
2576a6590b | ||
|
|
5cc9feb3e9 | ||
|
|
d76fa04613 | ||
|
|
5473f29eec | ||
|
|
b85172bcc4 | ||
|
|
3a32555add | ||
|
|
034cc64f8c | ||
|
|
16bc0ed69d | ||
|
|
644eb432b5 | ||
|
|
f2956e9d26 | ||
|
|
8e83155698 | ||
|
|
cd594c4116 | ||
|
|
b43ffe9578 | ||
|
|
d4b1824b1c | ||
|
|
9809f47d29 | ||
|
|
bf89da0eb5 | ||
|
|
aa2e1af999 | ||
|
|
472b0ee662 | ||
|
|
1f5cbbd4db | ||
|
|
f451b49226 | ||
|
|
d10aa2c571 | ||
|
|
a2e099f095 | ||
|
|
e5e41ff50b | ||
|
|
2dc6977c20 | ||
|
|
5c540e9092 | ||
|
|
670efa75b0 | ||
|
|
3a486832c8 | ||
|
|
0a892f2dad | ||
|
|
7f75ea8309 | ||
|
|
b1e3f2ac28 | ||
|
|
ddfe05ad20 | ||
|
|
d51ca6db0b | ||
|
|
3ab8191d19 | ||
|
|
546ce55c2b | ||
|
|
1f46bc1970 | ||
|
|
db8a73004e | ||
|
|
209a2f1693 | ||
|
|
4683711877 | ||
|
|
9610277b83 | ||
|
|
745efc4cc1 | ||
|
|
7223395740 | ||
|
|
9ef1fa4f1b |
78
Audiobook Player Guide.md
Normal file
78
Audiobook Player Guide.md
Normal file
@@ -0,0 +1,78 @@
|
||||
## Audiobook Player (Audio variant only)
|
||||
|
||||
Press **P** from the home screen to open the audiobook player.
|
||||
Place `.mp3`, `.m4b`, `.m4a`, or `.wav` files in `/audiobooks/` on the SD card.
|
||||
Files can be organised into subfolders (e.g. by author) — use **Enter** to
|
||||
browse into folders and **.. (up)** to go back.
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| W / S | Scroll file list / Volume up-down |
|
||||
| Enter | Select book or folder / Play-Pause |
|
||||
| A | Seek back 30 seconds |
|
||||
| D | Seek forward 30 seconds |
|
||||
| [ | Previous chapter (M4B only) |
|
||||
| ] | Next chapter (M4B only) |
|
||||
| Q | Leave player (audio continues) / Close book (when paused) / Exit (from file list) |
|
||||
|
||||
### Recommended Format
|
||||
|
||||
**MP3 is the recommended format.** M4B/M4A files are supported but currently
|
||||
have playback issues with the ESP32-audioI2S library — some files may fail to
|
||||
decode or produce silence. MP3 files play reliably and are the safest choice.
|
||||
|
||||
MP3 files should be encoded at a **44100 Hz sample rate**. Lower sample rates
|
||||
(e.g. 22050 Hz) can cause distortion or playback failure due to ESP32-S3 I2S
|
||||
hardware limitations.
|
||||
|
||||
**Bookmarks** are saved automatically every 30 seconds during playback and when
|
||||
you stop or exit. Reopening a book resumes from your last position.
|
||||
|
||||
**Cover art** from M4B files is displayed as dithered monochrome on the e-ink
|
||||
screen, along with title, author, and chapter information.
|
||||
|
||||
**Metadata caching** — the first time you open the audiobook player, it reads
|
||||
title and author tags from each file (which can take a few seconds with many
|
||||
files). This metadata is cached to the SD card so subsequent visits load
|
||||
near-instantly. If you add or remove files the cache updates automatically.
|
||||
|
||||
### Background Playback
|
||||
|
||||
Audio continues playing when you leave the audiobook player screen. Press **Q**
|
||||
while audio is playing to return to the home screen — a **>>** indicator will
|
||||
appear in the status bar next to the battery icon to show that audio is active
|
||||
in the background. Press **P** at any time to return to the player screen and
|
||||
resume control.
|
||||
|
||||
If you pause or stop playback first and then press **Q**, the book is closed
|
||||
and you're returned to the file list instead.
|
||||
|
||||
### Audio Hardware
|
||||
|
||||
The audiobook player uses the PCM5102A I2S DAC on the audio variant of the
|
||||
T-Deck Pro (I2S pins: BCLK=7, DOUT=8, LRC=9). Audio is output via the 3.5mm
|
||||
headphone jack.
|
||||
|
||||
> **Note:** The audiobook player is not available on the 4G modem variant
|
||||
> due to I2S pin conflicts.
|
||||
|
||||
### SD Card Folder Structure
|
||||
|
||||
```
|
||||
SD Card
|
||||
├── audiobooks/
|
||||
│ ├── .bookmarks/ (auto-created, stores resume positions)
|
||||
│ │ ├── mybook.bmk
|
||||
│ │ └── another.bmk
|
||||
│ ├── .metacache (auto-created, speeds up file list loading)
|
||||
│ ├── Ann Leckie/
|
||||
│ │ ├── Ancillary Justice.mp3
|
||||
│ │ └── Ancillary Sword.mp3
|
||||
│ ├── Iain M. Banks/
|
||||
│ │ └── The Algebraist.mp3
|
||||
│ ├── mybook.mp3
|
||||
│ └── podcast.mp3
|
||||
├── books/ (existing — text reader)
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
78
README.md
78
README.md
@@ -1,7 +1,7 @@
|
||||
## 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.*** ⭐
|
||||
<img src="https://github.com/user-attachments/assets/b30ce6bd-79af-44d3-93c4-f5e7e21e5621" alt="IMG_1453" width="300" height="650">
|
||||
|
||||
### Contents
|
||||
- [T-Deck Pro Keyboard Controls](#t-deck-pro-keyboard-controls)
|
||||
@@ -16,6 +16,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 +26,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 +46,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 +88,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 +99,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 +159,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 +210,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 +282,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 +296,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 +317,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.
|
||||
315
Serial Settings Guide.md
Normal file
315
Serial Settings Guide.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# Meck Serial Settings Guide
|
||||
|
||||
Configure your T-Deck Pro's Meck firmware over USB serial — no companion app needed. Plug in a USB-C cable, open a serial terminal, and you have full access to every setting on the device.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### What You Need
|
||||
|
||||
- T-Deck Pro running Meck firmware
|
||||
- USB-C cable
|
||||
- A serial terminal application:
|
||||
- **Windows:** PuTTY, TeraTerm, or the Arduino IDE Serial Monitor
|
||||
- **macOS:** `screen`, CoolTerm, or the Arduino IDE Serial Monitor
|
||||
- **Linux:** `screen`, `minicom`, `picocom`, or the Arduino IDE Serial Monitor
|
||||
|
||||
### Connection Settings
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Baud rate | 115200 |
|
||||
| Data bits | 8 |
|
||||
| Parity | None |
|
||||
| Stop bits | 1 |
|
||||
| Line ending | CR (carriage return) or CR+LF |
|
||||
|
||||
### Quick Start (macOS / Linux)
|
||||
|
||||
```
|
||||
screen /dev/ttyACM0 115200
|
||||
```
|
||||
|
||||
On macOS the port is typically `/dev/cu.usbmodem*`. On Linux it is usually `/dev/ttyACM0` or `/dev/ttyUSB0`.
|
||||
|
||||
### Quick Start (Arduino IDE)
|
||||
|
||||
Open **Tools → Serial Monitor**, set baud to **115200** and line ending to **Carriage Return** or **Both NL & CR**.
|
||||
|
||||
Once connected, type `help` and press Enter to confirm everything is working.
|
||||
|
||||
---
|
||||
|
||||
## Command Reference
|
||||
|
||||
All commands follow a simple pattern: `get` to read, `set` to write.
|
||||
|
||||
### Viewing Settings
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `get all` | Dump every setting at once |
|
||||
| `get name` | Device name |
|
||||
| `get freq` | Radio frequency (MHz) |
|
||||
| `get bw` | Bandwidth (kHz) |
|
||||
| `get sf` | Spreading factor |
|
||||
| `get cr` | Coding rate |
|
||||
| `get tx` | TX power (dBm) |
|
||||
| `get radio` | All radio params in one line |
|
||||
| `get utc` | UTC offset (hours) |
|
||||
| `get notify` | Keyboard flash notification (on/off) |
|
||||
| `get gps` | GPS status and interval |
|
||||
| `get pin` | BLE pairing PIN |
|
||||
| `get channels` | List all channels with index numbers |
|
||||
| `get presets` | List all radio presets with parameters |
|
||||
| `get pubkey` | Device public key (hex) |
|
||||
| `get firmware` | Firmware version string |
|
||||
|
||||
**4G variant only:**
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `get modem` | Modem enabled/disabled |
|
||||
| `get apn` | Current APN |
|
||||
| `get imei` | Device IMEI |
|
||||
|
||||
### Changing Settings
|
||||
|
||||
#### Device Name
|
||||
|
||||
```
|
||||
set name MyNode
|
||||
```
|
||||
|
||||
Names cannot contain these characters: `[ ] / \ : , ? *`
|
||||
|
||||
#### Radio Parameters (Individual)
|
||||
|
||||
Each of these applies immediately — no reboot required.
|
||||
|
||||
```
|
||||
set freq 910.525
|
||||
set bw 62.5
|
||||
set sf 7
|
||||
set cr 5
|
||||
set tx 22
|
||||
```
|
||||
|
||||
Valid ranges:
|
||||
|
||||
| Parameter | Min | Max |
|
||||
|-----------|-----|-----|
|
||||
| freq | 400.0 | 928.0 |
|
||||
| bw | 7.8 | 500.0 |
|
||||
| sf | 5 | 12 |
|
||||
| cr | 5 | 8 |
|
||||
| tx | 1 | Board max (typically 22) |
|
||||
|
||||
#### Radio Parameters (All at Once)
|
||||
|
||||
Set frequency, bandwidth, spreading factor, and coding rate in a single command:
|
||||
|
||||
```
|
||||
set radio 910.525 62.5 7 5
|
||||
```
|
||||
|
||||
#### Radio Presets
|
||||
|
||||
The easiest way to configure your radio. First, list the available presets:
|
||||
|
||||
```
|
||||
get presets
|
||||
```
|
||||
|
||||
This prints a numbered list like:
|
||||
|
||||
```
|
||||
Available radio presets:
|
||||
0 Australia 915.800 MHz BW250.0 SF10 CR5 TX22
|
||||
1 Australia (Narrow) 916.575 MHz BW62.5 SF7 CR8 TX22
|
||||
...
|
||||
14 USA/Canada (Recommended) 910.525 MHz BW62.5 SF7 CR5 TX22
|
||||
15 Vietnam 920.250 MHz BW250.0 SF11 CR5 TX22
|
||||
```
|
||||
|
||||
Apply a preset by name or number:
|
||||
|
||||
```
|
||||
set preset USA/Canada (Recommended)
|
||||
set preset 14
|
||||
```
|
||||
|
||||
Preset names are case-insensitive, so `set preset australia` works too. The preset applies all five radio parameters (freq, bw, sf, cr, tx) and takes effect immediately.
|
||||
|
||||
#### UTC Offset
|
||||
|
||||
```
|
||||
set utc 10
|
||||
```
|
||||
|
||||
Range: -12 to +14.
|
||||
|
||||
#### Keyboard Notification Flash
|
||||
|
||||
Toggle whether the keyboard backlight flashes when a new message arrives:
|
||||
|
||||
```
|
||||
set notify on
|
||||
set notify off
|
||||
```
|
||||
|
||||
#### BLE PIN
|
||||
|
||||
```
|
||||
set pin 123456
|
||||
```
|
||||
|
||||
### Channel Management
|
||||
|
||||
#### List Channels
|
||||
|
||||
```
|
||||
get channels
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
[0] #public
|
||||
[1] #meck-test
|
||||
[2] #local-group
|
||||
```
|
||||
|
||||
#### Add a Hashtag Channel
|
||||
|
||||
```
|
||||
set channel.add meck-test
|
||||
```
|
||||
|
||||
The `#` prefix is added automatically if you omit it. The channel's encryption key is derived from the name (SHA-256), matching the same method used by the on-device Settings screen and companion apps.
|
||||
|
||||
#### Delete a Channel
|
||||
|
||||
```
|
||||
set channel.del 2
|
||||
```
|
||||
|
||||
Channels are referenced by their index number (shown in `get channels`). Channel 0 (public) cannot be deleted. Remaining channels are automatically compacted after deletion.
|
||||
|
||||
### 4G Modem (4G Variant Only)
|
||||
|
||||
#### Enable / Disable Modem
|
||||
|
||||
```
|
||||
set modem on
|
||||
set modem off
|
||||
```
|
||||
|
||||
#### Set APN
|
||||
|
||||
```
|
||||
set apn telstra.internet
|
||||
```
|
||||
|
||||
To clear a custom APN and revert to auto-detection on next boot:
|
||||
|
||||
```
|
||||
set apn
|
||||
```
|
||||
|
||||
### System Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `reboot` | Restart the device |
|
||||
| `rebuild` | Erase filesystem, re-save identity + prefs + contacts + channels |
|
||||
| `erase` | Format the filesystem (caution: loses everything) |
|
||||
| `ls UserData/` | List files on internal filesystem |
|
||||
| `ls ExtraFS/` | List files on secondary filesystem |
|
||||
| `cat UserData/<path>` | Dump file contents as hex |
|
||||
| `rm UserData/<path>` | Delete a file |
|
||||
| `help` | Show command summary |
|
||||
|
||||
---
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### First-Time Setup
|
||||
|
||||
Plug in your new T-Deck Pro and run through these commands to get on the air:
|
||||
|
||||
```
|
||||
set name YourCallsign
|
||||
set preset Australia
|
||||
set utc 10
|
||||
set channel.add local-group
|
||||
get all
|
||||
```
|
||||
|
||||
### Switching to a New Region
|
||||
|
||||
Moving from Australia to the US? One command:
|
||||
|
||||
```
|
||||
set preset USA/Canada (Recommended)
|
||||
```
|
||||
|
||||
Verify with:
|
||||
|
||||
```
|
||||
get radio
|
||||
```
|
||||
|
||||
### Custom Radio Configuration
|
||||
|
||||
If none of the presets match your local group or you need specific parameters, set them directly. You can do it all in one command:
|
||||
|
||||
```
|
||||
set radio 916.575 62.5 8 8
|
||||
set tx 20
|
||||
```
|
||||
|
||||
Or one parameter at a time if you're only adjusting part of your config:
|
||||
|
||||
```
|
||||
set freq 916.575
|
||||
set bw 62.5
|
||||
set sf 8
|
||||
set cr 8
|
||||
set tx 20
|
||||
```
|
||||
|
||||
Both approaches apply immediately. Confirm with `get radio` to double-check everything took:
|
||||
|
||||
```
|
||||
get radio
|
||||
> freq=916.575 bw=62.5 sf=8 cr=8 tx=20
|
||||
```
|
||||
|
||||
### Troubleshooting Radio Settings
|
||||
|
||||
If you're not sure what went wrong, dump everything:
|
||||
|
||||
```
|
||||
get all
|
||||
```
|
||||
|
||||
Compare the radio section against what others in your area are using. If you need to match exact parameters from another node:
|
||||
|
||||
```
|
||||
set radio 916.575 62.5 7 8
|
||||
set tx 22
|
||||
```
|
||||
|
||||
### Backing Up Your Settings
|
||||
|
||||
Use `get all` to capture a snapshot of your configuration. Copy the serial output and save it — you can manually re-enter the settings after a firmware update or device reset if your SD card backup isn't available.
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **All radio changes apply live.** There is no need to reboot after changing frequency, bandwidth, spreading factor, coding rate, or TX power. The radio reconfigures on the fly.
|
||||
- **Preset selection by number is faster.** Once you've seen `get presets`, use the index number instead of typing the full name.
|
||||
- **Settings are persisted immediately.** Every `set` command writes to flash. If power is lost, your settings are safe.
|
||||
- **SD card backup is automatic.** If your T-Deck Pro has an SD card inserted, settings are backed up after every change. On a fresh flash, settings restore automatically from the SD card.
|
||||
- **The `get all` command is your friend.** When in doubt, dump everything and check.
|
||||
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
|
||||
@@ -40,14 +40,19 @@ public:
|
||||
void enableSerial() { _serial->enable(); }
|
||||
void disableSerial() { _serial->disable(); }
|
||||
virtual void msgRead(int msgcount) = 0;
|
||||
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0;
|
||||
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
|
||||
const uint8_t* path = nullptr) = 0;
|
||||
virtual void notify(UIEventType t = UIEventType::none) = 0;
|
||||
virtual void loop() = 0;
|
||||
virtual void showAlert(const char* text, int duration_millis) {}
|
||||
virtual void forceRefresh() {}
|
||||
virtual void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {}
|
||||
|
||||
// Mark a channel as read when BLE companion app syncs a message
|
||||
virtual void markChannelReadFromBLE(uint8_t channel_idx) {}
|
||||
|
||||
// Repeater admin callbacks (from MyMesh)
|
||||
virtual void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {}
|
||||
virtual void onAdminCliResponse(const char* from_name, const char* text) {}
|
||||
virtual void onAdminTelemetryResult(const uint8_t* data, uint8_t len) {}
|
||||
};
|
||||
@@ -230,6 +230,18 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
file.read((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87
|
||||
file.read((uint8_t *)&_prefs.utc_offset_hours, sizeof(_prefs.utc_offset_hours)); // 88
|
||||
|
||||
// Fields added later — may not exist in older prefs files
|
||||
if (file.read((uint8_t *)&_prefs.kb_flash_notify, sizeof(_prefs.kb_flash_notify)) != sizeof(_prefs.kb_flash_notify)) {
|
||||
_prefs.kb_flash_notify = 0; // default OFF for old files
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.ringtone_enabled, sizeof(_prefs.ringtone_enabled)) != sizeof(_prefs.ringtone_enabled)) {
|
||||
_prefs.ringtone_enabled = 0; // default OFF for old files
|
||||
}
|
||||
|
||||
// Clamp booleans to 0/1 in case of garbage
|
||||
if (_prefs.kb_flash_notify > 1) _prefs.kb_flash_notify = 0;
|
||||
if (_prefs.ringtone_enabled > 1) _prefs.ringtone_enabled = 0;
|
||||
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
@@ -265,14 +277,64 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
||||
file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86
|
||||
file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87
|
||||
file.write((uint8_t *)&_prefs.utc_offset_hours, sizeof(_prefs.utc_offset_hours)); // 88
|
||||
file.write((uint8_t *)&_prefs.kb_flash_notify, sizeof(_prefs.kb_flash_notify)); // 89
|
||||
file.write((uint8_t *)&_prefs.ringtone_enabled, sizeof(_prefs.ringtone_enabled)); // 90
|
||||
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::loadContacts(DataStoreHost* host) {
|
||||
File file = openRead(_getContactsChannelsFS(), "/contacts3");
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
|
||||
// --- Crash recovery ---
|
||||
// If /contacts3 is missing but /contacts3.tmp exists, a crash occurred
|
||||
// after removing the original but before the rename completed.
|
||||
// The .tmp file has the valid data — promote it.
|
||||
if (!fs->exists("/contacts3") && fs->exists("/contacts3.tmp")) {
|
||||
Serial.println("DataStore: recovering contacts from .tmp file");
|
||||
fs->rename("/contacts3.tmp", "/contacts3");
|
||||
}
|
||||
// If both exist, a crash occurred before the old file was removed.
|
||||
// The original /contacts3 is still valid — just clean up the orphan.
|
||||
if (fs->exists("/contacts3.tmp")) {
|
||||
fs->remove("/contacts3.tmp");
|
||||
}
|
||||
|
||||
File file = openRead(fs, "/contacts3");
|
||||
if (file) {
|
||||
// --- Truncation guard ---
|
||||
// If the file is smaller than one full contact record (152 bytes),
|
||||
// it was truncated by a crash/brown-out. Discard it and try the
|
||||
// .tmp backup if available.
|
||||
size_t fsize = file.size();
|
||||
if (fsize > 0 && fsize < 152) {
|
||||
Serial.printf("DataStore: contacts3 truncated (%d bytes < 152), discarding\n", (int)fsize);
|
||||
file.close();
|
||||
fs->remove("/contacts3");
|
||||
if (fs->exists("/contacts3.tmp")) {
|
||||
File tmp = openRead(fs, "/contacts3.tmp");
|
||||
if (tmp && tmp.size() >= 152) {
|
||||
Serial.println("DataStore: recovering from .tmp after truncation");
|
||||
tmp.close();
|
||||
fs->rename("/contacts3.tmp", "/contacts3");
|
||||
file = openRead(fs, "/contacts3");
|
||||
if (!file) return; // give up
|
||||
} else {
|
||||
if (tmp) tmp.close();
|
||||
Serial.println("DataStore: no valid contacts backup — starting fresh");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Serial.println("DataStore: no .tmp backup — starting fresh");
|
||||
return;
|
||||
}
|
||||
} else if (fsize == 0) {
|
||||
// Empty file — nothing to load
|
||||
file.close();
|
||||
return;
|
||||
}
|
||||
|
||||
bool full = false;
|
||||
while (!full) {
|
||||
ContactInfo c;
|
||||
@@ -302,36 +364,86 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3");
|
||||
}
|
||||
|
||||
void DataStore::saveContacts(DataStoreHost* host) {
|
||||
File file = openWrite(_getContactsChannelsFS(), "/contacts3");
|
||||
if (file) {
|
||||
uint32_t idx = 0;
|
||||
ContactInfo c;
|
||||
uint8_t unused = 0;
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
const char* finalPath = "/contacts3";
|
||||
const char* tmpPath = "/contacts3.tmp";
|
||||
|
||||
while (host->getContactForSave(idx, c)) {
|
||||
bool success = (file.write(c.id.pub_key, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
|
||||
success = success && (file.write(&c.type, 1) == 1);
|
||||
success = success && (file.write(&c.flags, 1) == 1);
|
||||
success = success && (file.write(&unused, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
|
||||
success = success && (file.write(c.out_path, 64) == 64);
|
||||
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
|
||||
// --- Step 1: Write all contacts to a temporary file ---
|
||||
File file = openWrite(fs, tmpPath);
|
||||
if (!file) {
|
||||
Serial.println("DataStore: saveContacts FAILED — cannot open tmp file");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!success) break; // write failed
|
||||
uint32_t idx = 0;
|
||||
ContactInfo c;
|
||||
uint8_t unused = 0;
|
||||
uint32_t recordsWritten = 0;
|
||||
bool writeOk = true;
|
||||
|
||||
idx++; // advance to next contact
|
||||
while (host->getContactForSave(idx, c)) {
|
||||
bool success = (file.write(c.id.pub_key, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
|
||||
success = success && (file.write(&c.type, 1) == 1);
|
||||
success = success && (file.write(&c.flags, 1) == 1);
|
||||
success = success && (file.write(&unused, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
|
||||
success = success && (file.write(c.out_path, 64) == 64);
|
||||
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
|
||||
|
||||
if (!success) {
|
||||
writeOk = false;
|
||||
Serial.printf("DataStore: saveContacts write error at record %d\n", idx);
|
||||
break;
|
||||
}
|
||||
file.close();
|
||||
|
||||
recordsWritten++;
|
||||
idx++;
|
||||
}
|
||||
|
||||
file.close();
|
||||
|
||||
// --- Step 2: Verify the write completed ---
|
||||
// Reopen read-only to get true on-disk size (SPIFFS file.size() is unreliable before close)
|
||||
size_t expectedBytes = recordsWritten * 152; // 152 bytes per contact record
|
||||
File verify = openRead(fs, tmpPath);
|
||||
size_t bytesWritten = verify ? verify.size() : 0;
|
||||
if (verify) verify.close();
|
||||
|
||||
if (!writeOk || bytesWritten != expectedBytes) {
|
||||
Serial.printf("DataStore: saveContacts ABORTED — wrote %d bytes, expected %d (%d records)\n",
|
||||
(int)bytesWritten, (int)expectedBytes, recordsWritten);
|
||||
fs->remove(tmpPath); // Clean up failed tmp file
|
||||
return; // Original /contacts3 is untouched
|
||||
}
|
||||
|
||||
// --- Step 3: Replace original with verified temp file ---
|
||||
fs->remove(finalPath);
|
||||
if (fs->rename(tmpPath, finalPath)) {
|
||||
Serial.printf("DataStore: saved %d contacts (%d bytes)\n", recordsWritten, (int)bytesWritten);
|
||||
} else {
|
||||
// Rename failed — tmp file still has the good data
|
||||
Serial.println("DataStore: rename failed, tmp file preserved");
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::loadChannels(DataStoreHost* host) {
|
||||
File file = openRead(_getContactsChannelsFS(), "/channels2");
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
|
||||
// Crash recovery (same pattern as contacts)
|
||||
if (!fs->exists("/channels2") && fs->exists("/channels2.tmp")) {
|
||||
Serial.println("DataStore: recovering channels from .tmp file");
|
||||
fs->rename("/channels2.tmp", "/channels2");
|
||||
}
|
||||
if (fs->exists("/channels2.tmp")) {
|
||||
fs->remove("/channels2.tmp");
|
||||
}
|
||||
|
||||
File file = openRead(fs, "/channels2");
|
||||
if (file) {
|
||||
bool full = false;
|
||||
uint8_t channel_idx = 0;
|
||||
@@ -356,22 +468,54 @@ void DataStore::loadChannels(DataStoreHost* host) {
|
||||
}
|
||||
|
||||
void DataStore::saveChannels(DataStoreHost* host) {
|
||||
File file = openWrite(_getContactsChannelsFS(), "/channels2");
|
||||
if (file) {
|
||||
uint8_t channel_idx = 0;
|
||||
ChannelDetails ch;
|
||||
uint8_t unused[4];
|
||||
memset(unused, 0, 4);
|
||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||
const char* finalPath = "/channels2";
|
||||
const char* tmpPath = "/channels2.tmp";
|
||||
|
||||
while (host->getChannelForSave(channel_idx, ch)) {
|
||||
bool success = (file.write(unused, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
|
||||
File file = openWrite(fs, tmpPath);
|
||||
if (!file) {
|
||||
Serial.println("DataStore: saveChannels FAILED — cannot open tmp file");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!success) break; // write failed
|
||||
channel_idx++;
|
||||
uint8_t channel_idx = 0;
|
||||
ChannelDetails ch;
|
||||
uint8_t unused[4];
|
||||
memset(unused, 0, 4);
|
||||
bool writeOk = true;
|
||||
|
||||
while (host->getChannelForSave(channel_idx, ch)) {
|
||||
bool success = (file.write(unused, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
|
||||
|
||||
if (!success) {
|
||||
writeOk = false;
|
||||
Serial.printf("DataStore: saveChannels write error at channel %d\n", channel_idx);
|
||||
break;
|
||||
}
|
||||
file.close();
|
||||
channel_idx++;
|
||||
}
|
||||
|
||||
file.close();
|
||||
|
||||
// Reopen read-only to get true on-disk size (SPIFFS file.size() is unreliable before close)
|
||||
size_t expectedBytes = channel_idx * 68; // 4 + 32 + 32 = 68 bytes per channel
|
||||
File verify = openRead(fs, tmpPath);
|
||||
size_t bytesWritten = verify ? verify.size() : 0;
|
||||
if (verify) verify.close();
|
||||
if (!writeOk || bytesWritten != expectedBytes) {
|
||||
Serial.printf("DataStore: saveChannels ABORTED — wrote %d bytes, expected %d\n",
|
||||
(int)bytesWritten, (int)expectedBytes);
|
||||
fs->remove(tmpPath);
|
||||
return;
|
||||
}
|
||||
|
||||
fs->remove(finalPath);
|
||||
if (fs->rename(tmpPath, finalPath)) {
|
||||
Serial.printf("DataStore: saved %d channels (%d bytes)\n", channel_idx, (int)bytesWritten);
|
||||
} else {
|
||||
Serial.println("DataStore: channels rename failed, tmp file preserved");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
#include <Arduino.h> // needed for PlatformIO
|
||||
#include <Mesh.h>
|
||||
#include "RadioPresets.h" // Shared radio presets (serial CLI + settings screen)
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "ModemManager.h" // Serial CLI modem commands
|
||||
#endif
|
||||
|
||||
#define CMD_APP_START 1
|
||||
#define CMD_SEND_TXT_MSG 2
|
||||
@@ -53,6 +58,10 @@
|
||||
#define CMD_SET_FLOOD_SCOPE 54 // v8+
|
||||
#define CMD_SEND_CONTROL_DATA 55 // v8+
|
||||
#define CMD_GET_STATS 56 // v8+, second byte is stats type
|
||||
|
||||
// Control data sub-types for active node discovery
|
||||
#define CTL_TYPE_NODE_DISCOVER_REQ 0x80
|
||||
#define CTL_TYPE_NODE_DISCOVER_RESP 0x90
|
||||
#define CMD_SEND_ANON_REQ 57
|
||||
#define CMD_SET_AUTOADD_CONFIG 58
|
||||
#define CMD_GET_AUTOADD_CONFIG 59
|
||||
@@ -357,6 +366,32 @@ void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path
|
||||
memcpy(p->path, path, p->path_len);
|
||||
}
|
||||
|
||||
// Buffer for on-device discovery UI
|
||||
if (_discoveryActive && _discoveredCount < MAX_DISCOVERED_NODES) {
|
||||
bool dup = false;
|
||||
for (int i = 0; i < _discoveredCount; i++) {
|
||||
if (contact.id.matches(_discovered[i].contact.id)) {
|
||||
// Update existing entry with fresher data
|
||||
_discovered[i].contact = contact;
|
||||
_discovered[i].path_len = path_len;
|
||||
_discovered[i].already_in_contacts = !is_new;
|
||||
// Preserve snr if already set by active discovery response
|
||||
dup = true;
|
||||
Serial.printf("[Discovery] Updated: %s (hops=%d)\n", contact.name, path_len);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!dup) {
|
||||
_discovered[_discoveredCount].contact = contact;
|
||||
_discovered[_discoveredCount].path_len = path_len;
|
||||
_discovered[_discoveredCount].snr = 0; // no SNR from passive advert
|
||||
_discovered[_discoveredCount].already_in_contacts = !is_new;
|
||||
_discoveredCount++;
|
||||
Serial.printf("[Discovery] Found: %s (hops=%d, is_new=%d, total=%d)\n",
|
||||
contact.name, path_len, is_new, _discoveredCount);
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_new) dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // only schedule lazy write for contacts that are in contacts[]
|
||||
}
|
||||
|
||||
@@ -439,7 +474,8 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
|
||||
// we only want to show text messages on display, not cli data
|
||||
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
|
||||
if (should_display && _ui) {
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len);
|
||||
const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr;
|
||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path);
|
||||
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::contactMessage); //buzz if enabled
|
||||
}
|
||||
#endif
|
||||
@@ -523,6 +559,13 @@ void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint3
|
||||
const char *text) {
|
||||
markConnectionActive(from); // in case this is from a server, and we have a connection
|
||||
queueMessage(from, TXT_TYPE_CLI_DATA, pkt, sender_timestamp, NULL, 0, text);
|
||||
|
||||
// Forward CLI response to UI admin screen if admin session is active
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (_admin_contact_idx >= 0 && _ui) {
|
||||
_ui->onAdminCliResponse(from.name, text);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
||||
@@ -574,7 +617,8 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
|
||||
channel_name = channel_details.name;
|
||||
}
|
||||
if (_ui) {
|
||||
_ui->newMsg(path_len, channel_name, text, offline_queue_len);
|
||||
const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr;
|
||||
_ui->newMsg(path_len, channel_name, text, offline_queue_len, msg_path);
|
||||
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::channelMessage); //buzz if enabled
|
||||
}
|
||||
#endif
|
||||
@@ -650,6 +694,83 @@ bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password, uint32_t& est_timeout_ms) {
|
||||
ContactInfo contact;
|
||||
if (!getContactByIdx(contact_idx, contact)) return false;
|
||||
|
||||
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
|
||||
if (!recipient) return false;
|
||||
|
||||
// Force flood routing for login — a mobile repeater's direct path may be stale.
|
||||
// The companion protocol does the same for telemetry requests.
|
||||
int8_t save_path_len = recipient->out_path_len;
|
||||
recipient->out_path_len = -1;
|
||||
|
||||
int result = sendLogin(*recipient, password, est_timeout_ms);
|
||||
|
||||
recipient->out_path_len = save_path_len; // restore
|
||||
|
||||
if (result == MSG_SEND_FAILED) {
|
||||
MESH_DEBUG_PRINTLN("UI: Admin login send failed to %s", recipient->name);
|
||||
est_timeout_ms = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
clearPendingReqs();
|
||||
memcpy(&pending_login, recipient->id.pub_key, 4);
|
||||
_admin_contact_idx = contact_idx;
|
||||
|
||||
MESH_DEBUG_PRINTLN("UI: Admin login sent to %s (flood, was path_len=%d), timeout=%dms",
|
||||
recipient->name, (int)save_path_len, est_timeout_ms);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MyMesh::uiSendCliCommand(uint32_t contact_idx, const char* command) {
|
||||
ContactInfo contact;
|
||||
if (!getContactByIdx(contact_idx, contact)) return false;
|
||||
|
||||
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
|
||||
if (!recipient) return false;
|
||||
|
||||
uint32_t timestamp = getRTCClock()->getCurrentTimeUnique();
|
||||
uint32_t est_timeout;
|
||||
int result = sendCommandData(*recipient, timestamp, 0, command, est_timeout);
|
||||
if (result == MSG_SEND_FAILED) {
|
||||
MESH_DEBUG_PRINTLN("UI: CLI command send failed to %s: %s", recipient->name, command);
|
||||
return false;
|
||||
}
|
||||
|
||||
_admin_contact_idx = contact_idx;
|
||||
|
||||
MESH_DEBUG_PRINTLN("UI: CLI command sent to %s (%s): %s, timeout=%dms",
|
||||
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
|
||||
command, est_timeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MyMesh::uiSendTelemetryRequest(uint32_t contact_idx) {
|
||||
ContactInfo contact;
|
||||
if (!getContactByIdx(contact_idx, contact)) return false;
|
||||
|
||||
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
|
||||
if (!recipient) return false;
|
||||
|
||||
uint32_t tag, est_timeout;
|
||||
int result = sendRequest(*recipient, REQ_TYPE_GET_TELEMETRY_DATA, tag, est_timeout);
|
||||
if (result == MSG_SEND_FAILED) {
|
||||
MESH_DEBUG_PRINTLN("UI: Telemetry request send failed to %s", recipient->name);
|
||||
return false;
|
||||
}
|
||||
|
||||
clearPendingReqs();
|
||||
pending_telemetry = tag;
|
||||
|
||||
MESH_DEBUG_PRINTLN("UI: Telemetry request sent to %s (%s), timeout=%dms",
|
||||
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
|
||||
est_timeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
|
||||
uint8_t len, uint8_t *reply) {
|
||||
if (data[0] == REQ_TYPE_GET_TELEMETRY_DATA) {
|
||||
@@ -708,6 +829,11 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
|
||||
out_frame[i++] = 0; // legacy: is_admin = false
|
||||
memcpy(&out_frame[i], contact.id.pub_key, 6);
|
||||
i += 6; // pub_key_prefix
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
// Notify UI of successful legacy login
|
||||
if (_ui) _ui->onAdminLoginResult(true, 0, tag);
|
||||
#endif
|
||||
} else if (data[4] == RESP_SERVER_LOGIN_OK) { // new login response
|
||||
uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16;
|
||||
if (keep_alive_secs > 0) {
|
||||
@@ -721,11 +847,21 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
|
||||
i += 4; // NEW: include server timestamp
|
||||
out_frame[i++] = data[7]; // NEW (v7): ACL permissions
|
||||
out_frame[i++] = data[12]; // FIRMWARE_VER_LEVEL
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
// Notify UI of successful login
|
||||
if (_ui) _ui->onAdminLoginResult(true, data[6], tag);
|
||||
#endif
|
||||
} else {
|
||||
out_frame[i++] = PUSH_CODE_LOGIN_FAIL;
|
||||
out_frame[i++] = 0; // reserved
|
||||
memcpy(&out_frame[i], contact.id.pub_key, 6);
|
||||
i += 6; // pub_key_prefix
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
// Notify UI of login failure
|
||||
if (_ui) _ui->onAdminLoginResult(false, 0, 0);
|
||||
#endif
|
||||
}
|
||||
_serial->writeFrame(out_frame, i);
|
||||
} else if (len > 4 && // check for status response
|
||||
@@ -745,6 +881,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;
|
||||
@@ -754,6 +891,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;
|
||||
|
||||
@@ -802,6 +944,62 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
|
||||
}
|
||||
|
||||
void MyMesh::onControlDataRecv(mesh::Packet *packet) {
|
||||
// --- Active discovery response interception ---
|
||||
if (_discoveryActive && packet->payload_len >= 6) {
|
||||
uint8_t resp_type = packet->payload[0] & 0xF0;
|
||||
if (resp_type == CTL_TYPE_NODE_DISCOVER_RESP) {
|
||||
uint8_t node_type = packet->payload[0] & 0x0F;
|
||||
int8_t snr_scaled = (int8_t)packet->payload[1]; // SNR × 4 (how well repeater heard us)
|
||||
uint32_t tag;
|
||||
memcpy(&tag, &packet->payload[2], 4);
|
||||
|
||||
// Validate: tag must match ours AND payload must include full 32-byte pubkey
|
||||
if (tag == _discoveryTag && packet->payload_len >= 6 + PUB_KEY_SIZE) {
|
||||
const uint8_t* pubkey = &packet->payload[6];
|
||||
|
||||
// Dedup check against existing buffer entries (pre-seeded or earlier responses)
|
||||
for (int i = 0; i < _discoveredCount; i++) {
|
||||
if (_discovered[i].contact.id.matches(pubkey)) {
|
||||
// Already in buffer — update SNR (active discovery data is fresher)
|
||||
_discovered[i].snr = snr_scaled;
|
||||
Serial.printf("[Discovery] Updated SNR for %s: %d\n",
|
||||
_discovered[i].contact.name, snr_scaled);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// New node — add if room
|
||||
if (_discoveredCount < MAX_DISCOVERED_NODES) {
|
||||
DiscoveredNode& node = _discovered[_discoveredCount];
|
||||
memset(&node.contact, 0, sizeof(ContactInfo));
|
||||
memcpy(node.contact.id.pub_key, pubkey, PUB_KEY_SIZE);
|
||||
node.contact.type = node_type;
|
||||
node.snr = snr_scaled;
|
||||
node.path_len = packet->path_len;
|
||||
|
||||
// Try to resolve name from contacts table
|
||||
ContactInfo* existing = lookupContactByPubKey(pubkey, PUB_KEY_SIZE);
|
||||
if (existing) {
|
||||
strncpy(node.contact.name, existing->name, sizeof(node.contact.name) - 1);
|
||||
node.already_in_contacts = true;
|
||||
} else {
|
||||
// Show hex prefix as placeholder name
|
||||
snprintf(node.contact.name, sizeof(node.contact.name),
|
||||
"%02X%02X%02X%02X",
|
||||
pubkey[0], pubkey[1], pubkey[2], pubkey[3]);
|
||||
node.already_in_contacts = false;
|
||||
}
|
||||
|
||||
_discoveredCount++;
|
||||
Serial.printf("[Discovery] Active response: %s type=%d snr=%d hops=%d (total=%d)\n",
|
||||
node.contact.name, node_type, snr_scaled, packet->path_len, _discoveredCount);
|
||||
}
|
||||
}
|
||||
return; // consumed — don't forward discovery responses to BLE
|
||||
}
|
||||
}
|
||||
|
||||
// --- Original BLE forwarding for non-discovery control data ---
|
||||
if (packet->payload_len + 4 > sizeof(out_frame)) {
|
||||
MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len);
|
||||
return;
|
||||
@@ -897,6 +1095,11 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
||||
memset(send_scope.key, 0, sizeof(send_scope.key));
|
||||
memset(_sent_track, 0, sizeof(_sent_track));
|
||||
_sent_track_idx = 0;
|
||||
_admin_contact_idx = -1;
|
||||
_discoveredCount = 0;
|
||||
_discoveryActive = false;
|
||||
_discoveryTimeout = 0;
|
||||
_discoveryTag = 0;
|
||||
|
||||
// defaults
|
||||
memset(&_prefs, 0, sizeof(_prefs));
|
||||
@@ -971,6 +1174,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();
|
||||
@@ -1111,6 +1315,8 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
memcpy(&msg_timestamp, &cmd_frame[i], 4);
|
||||
i += 4;
|
||||
const char *text = (char *)&cmd_frame[i];
|
||||
int text_len = len - i;
|
||||
cmd_frame[len] = '\0'; // Null-terminate for C string use
|
||||
|
||||
if (txt_type != TXT_TYPE_PLAIN) {
|
||||
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
||||
@@ -1119,6 +1325,11 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
bool success = getChannel(channel_idx, channel);
|
||||
if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) {
|
||||
writeOKFrame();
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (_ui) {
|
||||
_ui->addSentChannelMessage(channel_idx, _prefs.node_name, text);
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
|
||||
}
|
||||
@@ -1292,6 +1503,7 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
}
|
||||
} else if (cmd_frame[0] == CMD_IMPORT_CONTACT && len > 2 + 32 + 64) {
|
||||
if (importContact(&cmd_frame[1], len - 1)) {
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
writeOKFrame();
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
||||
@@ -1301,7 +1513,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;
|
||||
@@ -1882,15 +2106,447 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
if (len > 0 && cli_command[len - 1] == '\r') { // received complete line
|
||||
cli_command[len - 1] = 0; // replace newline with C string null terminator
|
||||
|
||||
if (memcmp(cli_command, "set ", 4) == 0) {
|
||||
// =====================================================================
|
||||
// GET commands — read settings
|
||||
// =====================================================================
|
||||
if (memcmp(cli_command, "get ", 4) == 0) {
|
||||
const char* key = &cli_command[4];
|
||||
|
||||
if (strcmp(key, "name") == 0) {
|
||||
Serial.printf(" > %s\n", _prefs.node_name);
|
||||
} else if (strcmp(key, "freq") == 0) {
|
||||
Serial.printf(" > %.3f\n", _prefs.freq);
|
||||
} else if (strcmp(key, "bw") == 0) {
|
||||
Serial.printf(" > %.1f\n", _prefs.bw);
|
||||
} else if (strcmp(key, "sf") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.sf);
|
||||
} else if (strcmp(key, "cr") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.cr);
|
||||
} else if (strcmp(key, "tx") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.tx_power_dbm);
|
||||
} else if (strcmp(key, "utc") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.utc_offset_hours);
|
||||
} else if (strcmp(key, "notify") == 0) {
|
||||
Serial.printf(" > %s\n", _prefs.kb_flash_notify ? "on" : "off");
|
||||
} else if (strcmp(key, "gps") == 0) {
|
||||
Serial.printf(" > %s (interval: %ds)\n",
|
||||
_prefs.gps_enabled ? "on" : "off", _prefs.gps_interval);
|
||||
} else if (strcmp(key, "pin") == 0) {
|
||||
Serial.printf(" > %06d\n", _prefs.ble_pin);
|
||||
} else if (strcmp(key, "radio") == 0) {
|
||||
Serial.printf(" > freq=%.3f bw=%.1f sf=%d cr=%d tx=%d\n",
|
||||
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, _prefs.tx_power_dbm);
|
||||
} else if (strcmp(key, "pubkey") == 0) {
|
||||
char hex[PUB_KEY_SIZE * 2 + 1];
|
||||
mesh::Utils::toHex(hex, self_id.pub_key, PUB_KEY_SIZE);
|
||||
Serial.printf(" > %s\n", hex);
|
||||
} else if (strcmp(key, "firmware") == 0) {
|
||||
Serial.printf(" > %s\n", FIRMWARE_VERSION);
|
||||
} else if (strcmp(key, "channels") == 0) {
|
||||
bool found = false;
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails ch;
|
||||
if (getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
Serial.printf(" [%d] %s\n", i, ch.name);
|
||||
found = true;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) Serial.println(" (no channels)");
|
||||
} else if (strcmp(key, "presets") == 0) {
|
||||
Serial.println(" Available radio presets:");
|
||||
for (int i = 0; i < (int)NUM_RADIO_PRESETS; i++) {
|
||||
Serial.printf(" %2d %-30s %.3f MHz BW%.1f SF%d CR%d TX%d\n",
|
||||
i, RADIO_PRESETS[i].name, RADIO_PRESETS[i].freq,
|
||||
RADIO_PRESETS[i].bw, RADIO_PRESETS[i].sf,
|
||||
RADIO_PRESETS[i].cr, RADIO_PRESETS[i].tx_power);
|
||||
}
|
||||
#ifdef HAS_4G_MODEM
|
||||
} else if (strcmp(key, "modem") == 0) {
|
||||
Serial.printf(" > %s\n", ModemManager::loadEnabledConfig() ? "on" : "off");
|
||||
} else if (strcmp(key, "apn") == 0) {
|
||||
Serial.printf(" > %s\n", modemManager.getAPN());
|
||||
} else if (strcmp(key, "imei") == 0) {
|
||||
Serial.printf(" > %s\n", modemManager.getIMEI());
|
||||
#endif
|
||||
} else if (strcmp(key, "all") == 0) {
|
||||
Serial.println(" === Meck Device Settings ===");
|
||||
Serial.printf(" name: %s\n", _prefs.node_name);
|
||||
Serial.printf(" freq: %.3f\n", _prefs.freq);
|
||||
Serial.printf(" bw: %.1f\n", _prefs.bw);
|
||||
Serial.printf(" sf: %d\n", _prefs.sf);
|
||||
Serial.printf(" cr: %d\n", _prefs.cr);
|
||||
Serial.printf(" tx: %d\n", _prefs.tx_power_dbm);
|
||||
Serial.printf(" utc: %d\n", _prefs.utc_offset_hours);
|
||||
Serial.printf(" notify: %s\n", _prefs.kb_flash_notify ? "on" : "off");
|
||||
Serial.printf(" gps: %s (interval: %ds)\n",
|
||||
_prefs.gps_enabled ? "on" : "off", _prefs.gps_interval);
|
||||
Serial.printf(" pin: %06d\n", _prefs.ble_pin);
|
||||
#ifdef HAS_4G_MODEM
|
||||
Serial.printf(" modem: %s\n", ModemManager::loadEnabledConfig() ? "on" : "off");
|
||||
Serial.printf(" apn: %s\n", modemManager.getAPN());
|
||||
Serial.printf(" imei: %s\n", modemManager.getIMEI());
|
||||
#endif
|
||||
// Detect current preset
|
||||
bool presetFound = false;
|
||||
for (int i = 0; i < (int)NUM_RADIO_PRESETS; i++) {
|
||||
if (_prefs.freq == RADIO_PRESETS[i].freq && _prefs.bw == RADIO_PRESETS[i].bw &&
|
||||
_prefs.sf == RADIO_PRESETS[i].sf && _prefs.cr == RADIO_PRESETS[i].cr) {
|
||||
Serial.printf(" preset: %s\n", RADIO_PRESETS[i].name);
|
||||
presetFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!presetFound) Serial.println(" preset: (custom)");
|
||||
Serial.printf(" firmware: %s\n", FIRMWARE_VERSION);
|
||||
char hex[PUB_KEY_SIZE * 2 + 1];
|
||||
mesh::Utils::toHex(hex, self_id.pub_key, PUB_KEY_SIZE);
|
||||
Serial.printf(" pubkey: %s\n", hex);
|
||||
// List channels
|
||||
Serial.println(" channels:");
|
||||
bool chFound = false;
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails ch;
|
||||
if (getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
Serial.printf(" [%d] %s\n", i, ch.name);
|
||||
chFound = true;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!chFound) Serial.println(" (none)");
|
||||
} else {
|
||||
Serial.printf(" Error: unknown key '%s' (try 'help')\n", key);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// SET commands — write settings
|
||||
// =====================================================================
|
||||
} else if (memcmp(cli_command, "set ", 4) == 0) {
|
||||
const char* config = &cli_command[4];
|
||||
if (memcmp(config, "pin ", 4) == 0) {
|
||||
|
||||
if (memcmp(config, "name ", 5) == 0) {
|
||||
const char* val = &config[5];
|
||||
// Validate name (same rules as CommonCLI)
|
||||
bool valid = true;
|
||||
const char* p = val;
|
||||
while (*p) {
|
||||
if (*p == '[' || *p == ']' || *p == '/' || *p == '\\' ||
|
||||
*p == ':' || *p == ',' || *p == '?' || *p == '*') {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
if (valid && strlen(val) > 0) {
|
||||
strncpy(_prefs.node_name, val, sizeof(_prefs.node_name) - 1);
|
||||
_prefs.node_name[sizeof(_prefs.node_name) - 1] = '\0';
|
||||
savePrefs();
|
||||
Serial.printf(" > name = %s\n", _prefs.node_name);
|
||||
} else {
|
||||
Serial.println(" Error: invalid name (no []/:,?* chars)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "freq ", 5) == 0) {
|
||||
float f = atof(&config[5]);
|
||||
if (f >= 400.0f && f <= 928.0f) {
|
||||
_prefs.freq = f;
|
||||
savePrefs();
|
||||
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
Serial.printf(" > freq = %.3f (applied)\n", _prefs.freq);
|
||||
} else {
|
||||
Serial.println(" Error: freq out of range (400-928)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "bw ", 3) == 0) {
|
||||
float bw = atof(&config[3]);
|
||||
if (bw >= 7.8f && bw <= 500.0f) {
|
||||
_prefs.bw = bw;
|
||||
savePrefs();
|
||||
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
Serial.printf(" > bw = %.1f (applied)\n", _prefs.bw);
|
||||
} else {
|
||||
Serial.println(" Error: bw out of range (7.8-500)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "sf ", 3) == 0) {
|
||||
int sf = atoi(&config[3]);
|
||||
if (sf >= 5 && sf <= 12) {
|
||||
_prefs.sf = (uint8_t)sf;
|
||||
savePrefs();
|
||||
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
Serial.printf(" > sf = %d (applied)\n", _prefs.sf);
|
||||
} else {
|
||||
Serial.println(" Error: sf out of range (5-12)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "cr ", 3) == 0) {
|
||||
int cr = atoi(&config[3]);
|
||||
if (cr >= 5 && cr <= 8) {
|
||||
_prefs.cr = (uint8_t)cr;
|
||||
savePrefs();
|
||||
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
Serial.printf(" > cr = %d (applied)\n", _prefs.cr);
|
||||
} else {
|
||||
Serial.println(" Error: cr out of range (5-8)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "tx ", 3) == 0) {
|
||||
int tx = atoi(&config[3]);
|
||||
if (tx >= 1 && tx <= MAX_LORA_TX_POWER) {
|
||||
_prefs.tx_power_dbm = (uint8_t)tx;
|
||||
savePrefs();
|
||||
radio_set_tx_power(_prefs.tx_power_dbm);
|
||||
Serial.printf(" > tx = %d (applied)\n", _prefs.tx_power_dbm);
|
||||
} else {
|
||||
Serial.printf(" Error: tx out of range (1-%d)\n", MAX_LORA_TX_POWER);
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "utc ", 4) == 0) {
|
||||
int utc = atoi(&config[4]);
|
||||
if (utc >= -12 && utc <= 14) {
|
||||
_prefs.utc_offset_hours = (int8_t)utc;
|
||||
savePrefs();
|
||||
Serial.printf(" > utc = %d\n", _prefs.utc_offset_hours);
|
||||
} else {
|
||||
Serial.println(" Error: utc out of range (-12 to 14)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "notify ", 7) == 0) {
|
||||
if (strcmp(&config[7], "on") == 0) {
|
||||
_prefs.kb_flash_notify = 1;
|
||||
} else if (strcmp(&config[7], "off") == 0) {
|
||||
_prefs.kb_flash_notify = 0;
|
||||
} else {
|
||||
Serial.println(" Error: use 'on' or 'off'");
|
||||
cli_command[0] = 0;
|
||||
return;
|
||||
}
|
||||
savePrefs();
|
||||
Serial.printf(" > notify = %s\n", _prefs.kb_flash_notify ? "on" : "off");
|
||||
|
||||
} else if (memcmp(config, "pin ", 4) == 0) {
|
||||
_prefs.ble_pin = atoi(&config[4]);
|
||||
savePrefs();
|
||||
Serial.printf(" > pin is now %06d\n", _prefs.ble_pin);
|
||||
|
||||
} else if (memcmp(config, "radio ", 6) == 0) {
|
||||
// Composite: "set radio <freq> <bw> <sf> <cr>"
|
||||
char tmp[64];
|
||||
strncpy(tmp, &config[6], sizeof(tmp) - 1);
|
||||
tmp[sizeof(tmp) - 1] = '\0';
|
||||
const char* parts[4];
|
||||
int num = mesh::Utils::parseTextParts(tmp, parts, 4);
|
||||
if (num == 4) {
|
||||
float freq = strtof(parts[0], nullptr);
|
||||
float bw = strtof(parts[1], nullptr);
|
||||
int sf = atoi(parts[2]);
|
||||
int cr = atoi(parts[3]);
|
||||
if (freq >= 400.0f && freq <= 928.0f && bw >= 7.8f && bw <= 500.0f
|
||||
&& sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8) {
|
||||
_prefs.freq = freq;
|
||||
_prefs.bw = bw;
|
||||
_prefs.sf = (uint8_t)sf;
|
||||
_prefs.cr = (uint8_t)cr;
|
||||
savePrefs();
|
||||
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
radio_set_tx_power(_prefs.tx_power_dbm);
|
||||
Serial.printf(" > radio = %.3f/%.1f/SF%d/CR%d TX:%d (applied)\n",
|
||||
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, _prefs.tx_power_dbm);
|
||||
} else {
|
||||
Serial.println(" Error: invalid radio params");
|
||||
}
|
||||
} else {
|
||||
Serial.println(" Usage: set radio <freq> <bw> <sf> <cr>");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "preset ", 7) == 0) {
|
||||
const char* name = &config[7];
|
||||
// Try exact match first (case-insensitive)
|
||||
bool found = false;
|
||||
for (int i = 0; i < (int)NUM_RADIO_PRESETS; i++) {
|
||||
if (strcasecmp(RADIO_PRESETS[i].name, name) == 0) {
|
||||
_prefs.freq = RADIO_PRESETS[i].freq;
|
||||
_prefs.bw = RADIO_PRESETS[i].bw;
|
||||
_prefs.sf = RADIO_PRESETS[i].sf;
|
||||
_prefs.cr = RADIO_PRESETS[i].cr;
|
||||
_prefs.tx_power_dbm = RADIO_PRESETS[i].tx_power;
|
||||
savePrefs();
|
||||
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
radio_set_tx_power(_prefs.tx_power_dbm);
|
||||
Serial.printf(" > Applied preset '%s' (%.3f/%.1f/SF%d/CR%d TX:%d)\n",
|
||||
RADIO_PRESETS[i].name, _prefs.freq, _prefs.bw,
|
||||
_prefs.sf, _prefs.cr, _prefs.tx_power_dbm);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Try by index number if name didn't match
|
||||
if (!found) {
|
||||
char* endp;
|
||||
long idx = strtol(name, &endp, 10);
|
||||
if (endp != name && *endp == '\0' && idx >= 0 && idx < (int)NUM_RADIO_PRESETS) {
|
||||
_prefs.freq = RADIO_PRESETS[idx].freq;
|
||||
_prefs.bw = RADIO_PRESETS[idx].bw;
|
||||
_prefs.sf = RADIO_PRESETS[idx].sf;
|
||||
_prefs.cr = RADIO_PRESETS[idx].cr;
|
||||
_prefs.tx_power_dbm = RADIO_PRESETS[idx].tx_power;
|
||||
savePrefs();
|
||||
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
radio_set_tx_power(_prefs.tx_power_dbm);
|
||||
Serial.printf(" > Applied preset '%s' (%.3f/%.1f/SF%d/CR%d TX:%d)\n",
|
||||
RADIO_PRESETS[idx].name, _prefs.freq, _prefs.bw,
|
||||
_prefs.sf, _prefs.cr, _prefs.tx_power_dbm);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
Serial.printf(" Error: unknown preset '%s' (try 'get presets')\n", name);
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "channel.add ", 12) == 0) {
|
||||
const char* name = &config[12];
|
||||
if (strlen(name) == 0) {
|
||||
Serial.println(" Error: channel name required");
|
||||
cli_command[0] = 0;
|
||||
return;
|
||||
}
|
||||
// Build channel name with # prefix if not present
|
||||
char chanName[32];
|
||||
if (name[0] == '#') {
|
||||
strncpy(chanName, name, sizeof(chanName));
|
||||
} else {
|
||||
chanName[0] = '#';
|
||||
strncpy(&chanName[1], name, sizeof(chanName) - 1);
|
||||
}
|
||||
chanName[31] = '\0';
|
||||
|
||||
// Generate 128-bit PSK from SHA-256 of channel name
|
||||
ChannelDetails newCh;
|
||||
memset(&newCh, 0, sizeof(newCh));
|
||||
strncpy(newCh.name, chanName, sizeof(newCh.name));
|
||||
newCh.name[31] = '\0';
|
||||
|
||||
uint8_t hash[32];
|
||||
mesh::Utils::sha256(hash, 32, (const uint8_t*)chanName, strlen(chanName));
|
||||
memcpy(newCh.channel.secret, hash, 16);
|
||||
|
||||
// Find next empty slot
|
||||
bool added = false;
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails existing;
|
||||
if (!getChannel(i, existing) || existing.name[0] == '\0') {
|
||||
if (setChannel(i, newCh)) {
|
||||
saveChannels();
|
||||
Serial.printf(" > Added channel '%s' at slot %d\n", chanName, i);
|
||||
added = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!added) Serial.println(" Error: no empty channel slots");
|
||||
|
||||
} else if (memcmp(config, "channel.del ", 12) == 0) {
|
||||
int idx = atoi(&config[12]);
|
||||
if (idx <= 0) {
|
||||
Serial.println(" Error: cannot delete channel 0 (public)");
|
||||
} else if (idx >= MAX_GROUP_CHANNELS) {
|
||||
Serial.printf(" Error: index out of range (1-%d)\n", MAX_GROUP_CHANNELS - 1);
|
||||
} else {
|
||||
// Verify channel exists
|
||||
ChannelDetails ch;
|
||||
if (!getChannel(idx, ch) || ch.name[0] == '\0') {
|
||||
Serial.printf(" Error: no channel at index %d\n", idx);
|
||||
} else {
|
||||
// Compact: shift channels down
|
||||
int total = 0;
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails tmp;
|
||||
if (getChannel(i, tmp) && tmp.name[0] != '\0') {
|
||||
total = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (int i = idx; i < total - 1; i++) {
|
||||
ChannelDetails next;
|
||||
if (getChannel(i + 1, next)) {
|
||||
setChannel(i, next);
|
||||
}
|
||||
}
|
||||
ChannelDetails empty;
|
||||
memset(&empty, 0, sizeof(empty));
|
||||
setChannel(total - 1, empty);
|
||||
saveChannels();
|
||||
Serial.printf(" > Deleted channel %d ('%s'), compacted %d channels\n",
|
||||
idx, ch.name, total);
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
} else if (memcmp(config, "apn ", 4) == 0) {
|
||||
const char* apn = &config[4];
|
||||
if (strlen(apn) > 0) {
|
||||
modemManager.setAPN(apn);
|
||||
Serial.printf(" > apn = %s\n", apn);
|
||||
} else {
|
||||
ModemManager::saveAPNConfig("");
|
||||
Serial.println(" > apn cleared (will auto-detect on next boot)");
|
||||
}
|
||||
|
||||
} else if (strcmp(config, "modem on") == 0) {
|
||||
ModemManager::saveEnabledConfig(true);
|
||||
modemManager.begin();
|
||||
Serial.println(" > modem enabled");
|
||||
|
||||
} else if (strcmp(config, "modem off") == 0) {
|
||||
ModemManager::saveEnabledConfig(false);
|
||||
modemManager.shutdown();
|
||||
Serial.println(" > modem disabled");
|
||||
#endif
|
||||
|
||||
} else {
|
||||
Serial.printf(" Error: unknown config: %s\n", config);
|
||||
Serial.printf(" Error: unknown setting '%s' (try 'help')\n", config);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// HELP command
|
||||
// =====================================================================
|
||||
} else if (strcmp(cli_command, "help") == 0) {
|
||||
Serial.println("=== Meck Serial CLI ===");
|
||||
Serial.println(" get <key> Read a setting");
|
||||
Serial.println(" set <key> <value> Write a setting");
|
||||
Serial.println("");
|
||||
Serial.println(" Settings keys:");
|
||||
Serial.println(" name, freq, bw, sf, cr, tx, utc, notify, pin");
|
||||
Serial.println("");
|
||||
Serial.println(" Compound commands:");
|
||||
Serial.println(" get all Dump all settings");
|
||||
Serial.println(" get radio Show all radio params");
|
||||
Serial.println(" get channels List channels");
|
||||
Serial.println(" get presets List radio presets");
|
||||
Serial.println(" get pubkey Show public key");
|
||||
Serial.println(" get firmware Show firmware version");
|
||||
Serial.println(" set radio <f> <bw> <sf> <cr> Set all radio params");
|
||||
Serial.println(" set preset <name|num> Apply radio preset");
|
||||
Serial.println(" set channel.add <name> Add hashtag channel");
|
||||
Serial.println(" set channel.del <idx> Delete channel by index");
|
||||
#ifdef HAS_4G_MODEM
|
||||
Serial.println("");
|
||||
Serial.println(" 4G modem:");
|
||||
Serial.println(" get/set apn, get imei, set modem on/off");
|
||||
#endif
|
||||
Serial.println("");
|
||||
Serial.println(" System:");
|
||||
Serial.println(" rebuild Erase & rebuild filesystem");
|
||||
Serial.println(" erase Format filesystem");
|
||||
Serial.println(" reboot Restart device");
|
||||
Serial.println(" ls / cat / rm File operations");
|
||||
|
||||
// =====================================================================
|
||||
// Existing system commands (unchanged)
|
||||
// =====================================================================
|
||||
} else if (strcmp(cli_command, "rebuild") == 0) {
|
||||
bool success = _store->formatFileSystem();
|
||||
if (success) {
|
||||
@@ -2030,7 +2686,7 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
} else if (strcmp(cli_command, "reboot") == 0) {
|
||||
board.reboot(); // doesn't return
|
||||
} else {
|
||||
Serial.println(" Error: unknown command");
|
||||
Serial.println(" Error: unknown command (try 'help')");
|
||||
}
|
||||
|
||||
cli_command[0] = 0; // reset command buffer
|
||||
@@ -2079,6 +2735,12 @@ void MyMesh::loop() {
|
||||
dirty_contacts_expiry = 0;
|
||||
}
|
||||
|
||||
// Discovery scan timeout
|
||||
if (_discoveryActive && millisHasNowPassed(_discoveryTimeout)) {
|
||||
_discoveryActive = false;
|
||||
Serial.printf("[Discovery] Scan complete: %d nodes found\n", _discoveredCount);
|
||||
}
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (_ui) _ui->setHasConnection(_serial->isConnected());
|
||||
#endif
|
||||
@@ -2097,4 +2759,56 @@ bool MyMesh::advert() {
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::startDiscovery(uint32_t duration_ms) {
|
||||
_discoveredCount = 0;
|
||||
_discoveryActive = true;
|
||||
_discoveryTimeout = futureMillis(duration_ms);
|
||||
_discoveryTag = getRNG()->nextInt(1, 0xFFFFFFFF);
|
||||
|
||||
Serial.printf("[Discovery] Active scan started (%lu ms, tag=%08X)\n",
|
||||
duration_ms, _discoveryTag);
|
||||
|
||||
// --- Send active discovery request (CTL_TYPE_NODE_DISCOVER_REQ) ---
|
||||
// Repeaters with firmware v1.11+ will respond with their pubkey + SNR
|
||||
uint8_t ctl_payload[10];
|
||||
ctl_payload[0] = CTL_TYPE_NODE_DISCOVER_REQ; // 0x80, prefix_only=0 (full 32-byte pubkeys)
|
||||
ctl_payload[1] = (1 << ADV_TYPE_REPEATER) // repeaters
|
||||
| (1 << ADV_TYPE_ROOM); // rooms (repeaters with chat)
|
||||
memcpy(&ctl_payload[2], &_discoveryTag, 4); // random correlation tag
|
||||
uint32_t since = 0; // accept all firmware versions
|
||||
memcpy(&ctl_payload[6], &since, 4);
|
||||
|
||||
auto pkt = createControlData(ctl_payload, sizeof(ctl_payload));
|
||||
if (pkt) {
|
||||
sendZeroHop(pkt);
|
||||
Serial.println("[Discovery] Sent CTL_TYPE_NODE_DISCOVER_REQ (zero-hop)");
|
||||
} else {
|
||||
Serial.println("[Discovery] ERROR: createControlData returned NULL (packet pool full?)");
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::stopDiscovery() {
|
||||
_discoveryActive = false;
|
||||
}
|
||||
|
||||
bool MyMesh::addDiscoveredToContacts(int idx) {
|
||||
if (idx < 0 || idx >= _discoveredCount) return false;
|
||||
if (_discovered[idx].already_in_contacts) return true; // already there
|
||||
|
||||
// Retrieve cached raw advert packet and import it
|
||||
uint8_t buf[256];
|
||||
int plen = getBlobByKey(_discovered[idx].contact.id.pub_key, PUB_KEY_SIZE, buf);
|
||||
if (plen > 0) {
|
||||
bool ok = importContact(buf, (uint8_t)plen);
|
||||
if (ok) {
|
||||
_discovered[idx].already_in_contacts = true;
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
MESH_DEBUG_PRINTLN("Discovery: added contact '%s'", _discovered[idx].contact.name);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("Discovery: no cached advert blob for contact '%s'", _discovered[idx].contact.name);
|
||||
return false;
|
||||
}
|
||||
@@ -8,11 +8,11 @@
|
||||
#define FIRMWARE_VER_CODE 8
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "12 Feb 2026"
|
||||
#define FIRMWARE_BUILD_DATE "5 March 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v0.8.5"
|
||||
#define FIRMWARE_VERSION "Meck v0.9.8.1"
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
@@ -84,6 +84,16 @@ struct AdvertPath {
|
||||
uint8_t path[MAX_PATH_SIZE];
|
||||
};
|
||||
|
||||
// Discovery scan — transient buffer for on-device node discovery
|
||||
#define MAX_DISCOVERED_NODES 20
|
||||
|
||||
struct DiscoveredNode {
|
||||
ContactInfo contact;
|
||||
uint8_t path_len;
|
||||
int8_t snr; // SNR × 4 from active discovery response (0 if pre-seeded)
|
||||
bool already_in_contacts; // true if contact was auto-added or already known
|
||||
};
|
||||
|
||||
class MyMesh : public BaseChatMesh, public DataStoreHost {
|
||||
public:
|
||||
MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui=NULL);
|
||||
@@ -101,6 +111,14 @@ public:
|
||||
void enterCLIRescue();
|
||||
|
||||
int getRecentlyHeard(AdvertPath dest[], int max_num);
|
||||
|
||||
// Discovery scan — on-device node discovery
|
||||
void startDiscovery(uint32_t duration_ms = 30000);
|
||||
void stopDiscovery();
|
||||
bool isDiscoveryActive() const { return _discoveryActive; }
|
||||
int getDiscoveredCount() const { return _discoveredCount; }
|
||||
const DiscoveredNode& getDiscovered(int idx) const { return _discovered[idx]; }
|
||||
bool addDiscoveredToContacts(int idx); // promote a discovered node into contacts
|
||||
|
||||
// Queue a sent channel message for BLE app sync
|
||||
void queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text);
|
||||
@@ -109,8 +127,9 @@ public:
|
||||
bool uiSendDirectMessage(uint32_t contact_idx, const char* text);
|
||||
|
||||
// Repeater admin - UI-initiated operations
|
||||
bool uiLoginToRepeater(uint32_t contact_idx, const char* password);
|
||||
bool uiLoginToRepeater(uint32_t contact_idx, const char* password, uint32_t& est_timeout_ms);
|
||||
bool uiSendCliCommand(uint32_t contact_idx, const char* command);
|
||||
bool uiSendTelemetryRequest(uint32_t contact_idx);
|
||||
int getAdminContactIdx() const { return _admin_contact_idx; }
|
||||
|
||||
|
||||
@@ -256,6 +275,13 @@ private:
|
||||
SentMsgTrack _sent_track[SENT_TRACK_SIZE];
|
||||
int _sent_track_idx; // next slot in circular buffer
|
||||
int _admin_contact_idx; // contact index for active admin session (-1 if none)
|
||||
|
||||
// Discovery scan state
|
||||
DiscoveredNode _discovered[MAX_DISCOVERED_NODES];
|
||||
int _discoveredCount;
|
||||
bool _discoveryActive;
|
||||
unsigned long _discoveryTimeout;
|
||||
uint32_t _discoveryTag; // random correlation tag for active discovery
|
||||
};
|
||||
|
||||
extern MyMesh the_mesh;
|
||||
@@ -29,4 +29,6 @@ struct NodePrefs { // persisted to file
|
||||
uint32_t gps_interval; // GPS read interval in seconds
|
||||
uint8_t autoadd_config; // bitmask for auto-add contacts config
|
||||
int8_t utc_offset_hours; // UTC offset in hours (-12 to +14), default 0
|
||||
uint8_t kb_flash_notify; // Keyboard backlight flash on new message (0=off, 1=on)
|
||||
uint8_t ringtone_enabled; // Ringtone on incoming call (0=off, 1=on) — 4G only
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
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
|
||||
1802
examples/companion_radio/ui-new/Audiobookplayerscreen.h
Normal file
1802
examples/companion_radio/ui-new/Audiobookplayerscreen.h
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
// Maximum messages to store in history
|
||||
#define CHANNEL_MSG_HISTORY_SIZE 300
|
||||
#define CHANNEL_MSG_TEXT_LEN 160
|
||||
#define MSG_PATH_MAX 20 // Max repeater hops stored per message
|
||||
|
||||
#ifndef MAX_GROUP_CHANNELS
|
||||
#define MAX_GROUP_CHANNELS 20
|
||||
@@ -23,7 +24,7 @@
|
||||
// On-disk format for message persistence (SD card)
|
||||
// ---------------------------------------------------------------------------
|
||||
#define MSG_FILE_MAGIC 0x4D434853 // "MCHS" - MeshCore History Store
|
||||
#define MSG_FILE_VERSION 1
|
||||
#define MSG_FILE_VERSION 3 // v3: MSG_PATH_MAX increased to 20
|
||||
#define MSG_FILE_PATH "/meshcore/messages.bin"
|
||||
|
||||
struct __attribute__((packed)) MsgFileHeader {
|
||||
@@ -41,8 +42,9 @@ struct __attribute__((packed)) MsgFileRecord {
|
||||
uint8_t channel_idx;
|
||||
uint8_t valid;
|
||||
uint8_t reserved;
|
||||
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes (first byte of pub key)
|
||||
char text[CHANNEL_MSG_TEXT_LEN];
|
||||
// 168 bytes total
|
||||
// 188 bytes total
|
||||
};
|
||||
|
||||
class UITask; // Forward declaration
|
||||
@@ -55,6 +57,7 @@ public:
|
||||
uint32_t timestamp;
|
||||
uint8_t path_len;
|
||||
uint8_t channel_idx; // Which channel this message belongs to
|
||||
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes
|
||||
char text[CHANNEL_MSG_TEXT_LEN];
|
||||
bool valid;
|
||||
};
|
||||
@@ -70,21 +73,39 @@ private:
|
||||
int _msgsPerPage; // Messages that fit on screen
|
||||
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(CHANNEL_MSG_HISTORY_SIZE), _viewChannelIdx(0), _sdReady(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; }
|
||||
|
||||
// Add a new message to the history
|
||||
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text) {
|
||||
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text,
|
||||
const uint8_t* path_bytes = nullptr) {
|
||||
// Move to next slot in circular buffer
|
||||
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
|
||||
|
||||
@@ -94,6 +115,13 @@ public:
|
||||
msg->channel_idx = channel_idx;
|
||||
msg->valid = true;
|
||||
|
||||
// Store path hop hashes
|
||||
memset(msg->path, 0, MSG_PATH_MAX);
|
||||
if (path_bytes && path_len > 0 && path_len != 0xFF) {
|
||||
int n = path_len < MSG_PATH_MAX ? path_len : MSG_PATH_MAX;
|
||||
memcpy(msg->path, path_bytes, n);
|
||||
}
|
||||
|
||||
// Sanitize emoji: replace UTF-8 emoji sequences with single-byte escape codes
|
||||
// The text already contains "Sender: message" format
|
||||
emojiSanitize(text, msg->text, CHANNEL_MSG_TEXT_LEN);
|
||||
@@ -104,6 +132,19 @@ 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();
|
||||
@@ -123,7 +164,140 @@ public:
|
||||
int getMessageCount() const { return _msgCount; }
|
||||
|
||||
uint8_t getViewChannelIdx() const { return _viewChannelIdx; }
|
||||
void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; }
|
||||
void setViewChannelIdx(uint8_t idx) {
|
||||
_viewChannelIdx = idx;
|
||||
_scrollPos = 0;
|
||||
_showPathOverlay = false;
|
||||
_pathScrollPos = 0;
|
||||
markChannelRead(idx);
|
||||
}
|
||||
bool isShowingPathOverlay() const { return _showPathOverlay; }
|
||||
void dismissPathOverlay() { _showPathOverlay = false; _pathScrollPos = 0; }
|
||||
|
||||
// --- Reply select mode (R key → pick a message → Enter to @mention reply) ---
|
||||
bool isReplySelectMode() const { return _replySelectMode; }
|
||||
void exitReplySelect() { _replySelectMode = false; _replySelectPos = -1; }
|
||||
|
||||
// Extract sender name from a "Sender: message" formatted text.
|
||||
// Returns true if a sender was found, fills senderBuf (null-terminated).
|
||||
static bool extractSenderName(const char* msgText, char* senderBuf, int bufLen) {
|
||||
const char* colon = strstr(msgText, ": ");
|
||||
if (!colon || colon == msgText) return false;
|
||||
int nameLen = colon - msgText;
|
||||
if (nameLen >= bufLen) nameLen = bufLen - 1;
|
||||
memcpy(senderBuf, msgText, nameLen);
|
||||
senderBuf[nameLen] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the sender name of the currently selected message in reply select mode.
|
||||
// Returns true and fills senderBuf if valid selection exists.
|
||||
bool getReplySelectSender(char* senderBuf, int bufLen) {
|
||||
if (!_replySelectMode || _replySelectPos < 0) return false;
|
||||
|
||||
// Rebuild the channel message list (same logic as render)
|
||||
static int rsMsgs[CHANNEL_MSG_HISTORY_SIZE];
|
||||
int count = 0;
|
||||
for (int i = 0; i < _msgCount && count < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
|
||||
rsMsgs[count++] = idx;
|
||||
}
|
||||
}
|
||||
// Reverse to chronological (oldest first)
|
||||
for (int l = 0, r = count - 1; l < r; l++, r--) {
|
||||
int t = rsMsgs[l]; rsMsgs[l] = rsMsgs[r]; rsMsgs[r] = t;
|
||||
}
|
||||
|
||||
if (_replySelectPos >= count) return false;
|
||||
int idx = rsMsgs[_replySelectPos];
|
||||
return extractSenderName(_messages[idx].text, senderBuf, bufLen);
|
||||
}
|
||||
|
||||
// Get the ChannelMessage pointer for the currently selected reply message.
|
||||
ChannelMessage* getReplySelectMsg() {
|
||||
if (!_replySelectMode || _replySelectPos < 0) return nullptr;
|
||||
|
||||
static int rsMsgs[CHANNEL_MSG_HISTORY_SIZE];
|
||||
int count = 0;
|
||||
for (int i = 0; i < _msgCount && count < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
|
||||
rsMsgs[count++] = idx;
|
||||
}
|
||||
}
|
||||
for (int l = 0, r = count - 1; l < r; l++, r--) {
|
||||
int t = rsMsgs[l]; rsMsgs[l] = rsMsgs[r]; rsMsgs[r] = t;
|
||||
}
|
||||
|
||||
if (_replySelectPos >= count) return nullptr;
|
||||
return &_messages[rsMsgs[_replySelectPos]];
|
||||
}
|
||||
|
||||
// --- Unread message tracking (standalone mode) ---
|
||||
|
||||
// Mark all messages for a channel as read
|
||||
void markChannelRead(uint8_t channel_idx) {
|
||||
int slot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : channel_idx;
|
||||
if (slot >= 0 && slot <= MAX_GROUP_CHANNELS) {
|
||||
_unread[slot] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Get unread count for a specific channel
|
||||
int getUnreadForChannel(uint8_t channel_idx) const {
|
||||
int slot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : channel_idx;
|
||||
if (slot >= 0 && slot <= MAX_GROUP_CHANNELS) {
|
||||
return _unread[slot];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get total unread across all channels
|
||||
int getTotalUnread() const {
|
||||
int total = 0;
|
||||
for (int i = 0; i <= MAX_GROUP_CHANNELS; i++) {
|
||||
total += _unread[i];
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// Find the newest RECEIVED message for the current channel
|
||||
// (path_len != 0 means received, path_len 0 = locally sent)
|
||||
ChannelMessage* getNewestReceivedMsg() {
|
||||
for (int i = 0; i < _msgCount; 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
|
||||
&& _messages[idx].path_len != 0) {
|
||||
return &_messages[idx];
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -163,6 +337,7 @@ public:
|
||||
rec.channel_idx = _messages[i].channel_idx;
|
||||
rec.valid = _messages[i].valid ? 1 : 0;
|
||||
rec.reserved = 0;
|
||||
memcpy(rec.path, _messages[i].path, MSG_PATH_MAX);
|
||||
memcpy(rec.text, _messages[i].text, CHANNEL_MSG_TEXT_LEN);
|
||||
f.write((uint8_t*)&rec, sizeof(rec));
|
||||
}
|
||||
@@ -228,6 +403,7 @@ public:
|
||||
_messages[i].path_len = rec.path_len;
|
||||
_messages[i].channel_idx = rec.channel_idx;
|
||||
_messages[i].valid = (rec.valid != 0);
|
||||
memcpy(_messages[i].path, rec.path, MSG_PATH_MAX);
|
||||
memcpy(_messages[i].text, rec.text, CHANNEL_MSG_TEXT_LEN);
|
||||
if (_messages[i].valid) loaded++;
|
||||
}
|
||||
@@ -280,6 +456,175 @@ public:
|
||||
// Divider line
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// --- Path detail overlay ---
|
||||
if (_showPathOverlay) {
|
||||
display.setTextSize(0);
|
||||
int lineH = 9;
|
||||
int y = 14;
|
||||
|
||||
ChannelMessage* msg = getNewestReceivedMsg();
|
||||
if (!msg) {
|
||||
display.setCursor(0, y);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("No received messages");
|
||||
} else {
|
||||
// Message preview (first ~30 chars)
|
||||
display.setCursor(0, y);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
char preview[32];
|
||||
strncpy(preview, msg->text, 31);
|
||||
preview[31] = '\0';
|
||||
display.print(preview);
|
||||
y += lineH;
|
||||
|
||||
// Age
|
||||
uint32_t age = _rtc->getCurrentTime() - msg->timestamp;
|
||||
display.setCursor(0, y);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
if (age < 60) sprintf(tmp, "Age: %ds", age);
|
||||
else if (age < 3600) sprintf(tmp, "Age: %dm", age / 60);
|
||||
else if (age < 86400) sprintf(tmp, "Age: %dh", age / 3600);
|
||||
else sprintf(tmp, "Age: %dd", age / 86400);
|
||||
display.print(tmp);
|
||||
y += lineH;
|
||||
|
||||
// Route type
|
||||
display.setCursor(0, y);
|
||||
uint8_t plen = msg->path_len;
|
||||
if (plen == 0xFF) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("Route: Direct");
|
||||
} else if (plen == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("Route: Local/Sent");
|
||||
} else {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
sprintf(tmp, "Route: %d hop%s", plen, plen == 1 ? "" : "s");
|
||||
display.print(tmp);
|
||||
}
|
||||
y += lineH + 2;
|
||||
|
||||
// Show each hop resolved against contacts (scrollable)
|
||||
if (plen > 0 && plen != 0xFF) {
|
||||
int displayHops = plen < MSG_PATH_MAX ? plen : MSG_PATH_MAX;
|
||||
int footerReserve = 26; // footer + divider
|
||||
int scrollBarW = 4;
|
||||
int maxY = display.height() - footerReserve;
|
||||
int hopAreaTop = y;
|
||||
|
||||
// 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);
|
||||
|
||||
// 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;
|
||||
|
||||
// First pass: repeaters only
|
||||
for (uint32_t ci = 0; ci < numContacts && !resolved; ci++) {
|
||||
if (the_mesh.getContactByIdx(ci, contact)) {
|
||||
if (contact.id.pub_key[0] == hopHash && contact.type == ADV_TYPE_REPEATER) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.print(contact.name);
|
||||
resolved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second pass: any contact type
|
||||
if (!resolved) {
|
||||
for (uint32_t ci = 0; ci < numContacts; ci++) {
|
||||
if (the_mesh.getContactByIdx(ci, contact)) {
|
||||
if (contact.id.pub_key[0] == hopHash) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print(contact.name);
|
||||
resolved = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No name resolved - hex prefix already shown, add "?" marker
|
||||
if (!resolved) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay footer
|
||||
display.setTextSize(1);
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("Q:Back");
|
||||
// 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;
|
||||
#else
|
||||
return 1000;
|
||||
#endif
|
||||
}
|
||||
|
||||
if (channelMsgCount == 0) {
|
||||
display.setTextSize(0); // Tiny font for body text
|
||||
display.setCursor(0, 20);
|
||||
@@ -295,6 +640,7 @@ public:
|
||||
int lineHeight = 9; // 8px font + 1px spacing
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int scrollBarW = 4; // Width of scroll indicator on right edge
|
||||
// Hard cutoff: no text may START at or beyond this y value
|
||||
// This ensures rendered glyphs (which extend lineHeight below y) stay above the footer
|
||||
int maxY = display.height() - footerHeight;
|
||||
@@ -302,7 +648,8 @@ public:
|
||||
int y = headerHeight;
|
||||
|
||||
// Build list of messages for this channel (newest first)
|
||||
int channelMsgs[CHANNEL_MSG_HISTORY_SIZE];
|
||||
// Static to avoid 1200-byte stack allocation every render cycle
|
||||
static int channelMsgs[CHANNEL_MSG_HISTORY_SIZE];
|
||||
int numChannelMsgs = 0;
|
||||
|
||||
for (int i = 0; i < _msgCount && numChannelMsgs < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
@@ -320,6 +667,13 @@ 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;
|
||||
|
||||
// Calculate start index so newest messages appear at the bottom
|
||||
// scrollPos=0 shows the most recent messages, scrollPos++ scrolls up to older
|
||||
int startIdx = numChannelMsgs - _msgsPerPage - _scrollPos;
|
||||
@@ -328,40 +682,71 @@ 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
|
||||
// Uses advance width (cursor movement) not bounding box for px tracking
|
||||
int lineW = display.width();
|
||||
int lineW = display.width() - scrollBarW - 1; // Reserve space for scroll bar
|
||||
int px = display.getTextWidth(tmp); // Pixel X after timestamp
|
||||
char dblStr[3] = {0, 0, 0};
|
||||
|
||||
@@ -450,21 +835,70 @@ 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;
|
||||
}
|
||||
|
||||
// Only update _msgsPerPage when the screen actually filled up.
|
||||
// If we ran out of messages before filling the screen, keep the
|
||||
// previous (higher) value so startIdx doesn't under-count.
|
||||
if (screenFull && msgsDrawn > 0) {
|
||||
_msgsPerPage = msgsDrawn;
|
||||
// Only update _msgsPerPage when at the bottom (scrollPos==0) and the
|
||||
// screen actually filled up. While scrolled, freezing _msgsPerPage
|
||||
// prevents a feedback loop where variable-height messages cause
|
||||
// msgsPerPage to oscillate, shifting startIdx every render (flicker).
|
||||
if (screenFull && msgsDrawn > 0 && _scrollPos == 0) {
|
||||
// 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) ---
|
||||
int sbX = display.width() - scrollBarW;
|
||||
int sbTop = headerHeight;
|
||||
int sbHeight = maxY - headerHeight;
|
||||
|
||||
// Draw track outline
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(sbX, sbTop, scrollBarW, sbHeight);
|
||||
|
||||
if (channelMsgCount > _msgsPerPage) {
|
||||
// Scrollable: draw proportional thumb
|
||||
int maxScroll = channelMsgCount - _msgsPerPage;
|
||||
if (maxScroll < 1) maxScroll = 1;
|
||||
int thumbH = (_msgsPerPage * sbHeight) / channelMsgCount;
|
||||
if (thumbH < 4) thumbH = 4;
|
||||
// _scrollPos=0 is newest (bottom), so invert for thumb position
|
||||
int thumbY = sbTop + ((maxScroll - _scrollPos) * (sbHeight - thumbH)) / maxScroll;
|
||||
for (int ty = thumbY + 1; ty < thumbY + thumbH - 1; ty++)
|
||||
display.drawRect(sbX + 1, ty, scrollBarW - 2, 1);
|
||||
} else {
|
||||
// All messages fit: fill entire track
|
||||
for (int ty = sbTop + 1; ty < sbTop + sbHeight - 1; ty++)
|
||||
display.drawRect(sbX + 1, ty, scrollBarW - 2, 1);
|
||||
}
|
||||
|
||||
display.setTextSize(1); // Restore for footer
|
||||
@@ -476,13 +910,18 @@ public:
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
// Left side: Q:Back A/D:Ch
|
||||
display.print("Q:Back A/D:Ch");
|
||||
|
||||
// Right side: Entr:New
|
||||
const char* rightText = "Entr:New";
|
||||
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
|
||||
display.print(rightText);
|
||||
// Left side: abbreviated controls
|
||||
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;
|
||||
@@ -492,7 +931,111 @@ public:
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// If overlay is showing, handle scroll and dismiss
|
||||
if (_showPathOverlay) {
|
||||
if (c == 'q' || c == 'Q' || c == '\b' || c == 'v' || c == 'V') {
|
||||
_showPathOverlay = false;
|
||||
_pathScrollPos = 0;
|
||||
return true;
|
||||
}
|
||||
if (c == '\r' || c == 13) {
|
||||
return false; // Let main.cpp handle Enter for copy-to-compose
|
||||
}
|
||||
// W - scroll up in hop list
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_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
|
||||
}
|
||||
|
||||
// W or KEY_PREV - scroll up (older messages)
|
||||
if (c == 0xF2 || c == 'w' || c == 'W') {
|
||||
@@ -512,6 +1055,8 @@ public:
|
||||
|
||||
// A - previous channel
|
||||
if (c == 'a' || c == 'A') {
|
||||
_replySelectMode = false;
|
||||
_replySelectPos = -1;
|
||||
if (_viewChannelIdx > 0) {
|
||||
_viewChannelIdx--;
|
||||
} else {
|
||||
@@ -525,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') {
|
||||
@@ -538,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; }
|
||||
|
||||
@@ -286,17 +297,17 @@ public:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
// Left: Q:Back
|
||||
// Left: Q:Bk
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Back");
|
||||
display.print("Q:Bk");
|
||||
|
||||
// Center: A/D:Filter
|
||||
const char* mid = "A/D:Filtr";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
|
||||
// Right: W/S:Scroll
|
||||
const char* right = "W/S:Scrll";
|
||||
// Right: F:Dscvr
|
||||
const char* right = "F:Dscvr";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
|
||||
|
||||
205
examples/companion_radio/ui-new/Discoveryscreen.h
Normal file
205
examples/companion_radio/ui-new/Discoveryscreen.h
Normal file
@@ -0,0 +1,205 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/AdvertDataHelpers.h>
|
||||
#include <MeshCore.h>
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
class MyMesh;
|
||||
extern MyMesh the_mesh;
|
||||
|
||||
class DiscoveryScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
int _scrollPos;
|
||||
int _rowsPerPage;
|
||||
|
||||
static char typeChar(uint8_t adv_type) {
|
||||
switch (adv_type) {
|
||||
case ADV_TYPE_CHAT: return 'C';
|
||||
case ADV_TYPE_REPEATER: return 'R';
|
||||
case ADV_TYPE_ROOM: return 'S';
|
||||
case ADV_TYPE_SENSOR: return 'N';
|
||||
default: return '?';
|
||||
}
|
||||
}
|
||||
|
||||
static const char* typeLabel(uint8_t adv_type) {
|
||||
switch (adv_type) {
|
||||
case ADV_TYPE_CHAT: return "Chat";
|
||||
case ADV_TYPE_REPEATER: return "Rptr";
|
||||
case ADV_TYPE_ROOM: return "Room";
|
||||
case ADV_TYPE_SENSOR: return "Sens";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
DiscoveryScreen(UITask* task, mesh::RTCClock* rtc)
|
||||
: _task(task), _rtc(rtc), _scrollPos(0), _rowsPerPage(5) {}
|
||||
|
||||
void resetScroll() { _scrollPos = 0; }
|
||||
|
||||
int getSelectedIdx() const { return _scrollPos; }
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
int count = the_mesh.getDiscoveredCount();
|
||||
bool active = the_mesh.isDiscoveryActive();
|
||||
|
||||
// === Header ===
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
|
||||
char hdr[32];
|
||||
if (active) {
|
||||
snprintf(hdr, sizeof(hdr), "Scanning... %d found", count);
|
||||
} else {
|
||||
snprintf(hdr, sizeof(hdr), "Scan done: %d found", count);
|
||||
}
|
||||
display.print(hdr);
|
||||
|
||||
// Divider
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body — discovered node rows ===
|
||||
display.setTextSize(0); // tiny font for compact rows
|
||||
int lineHeight = 9;
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
int y = headerHeight;
|
||||
int rowsDrawn = 0;
|
||||
|
||||
if (count == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 28);
|
||||
display.print(active ? "Listening for adverts..." : "No nodes found");
|
||||
if (!active) {
|
||||
display.setCursor(4, 38);
|
||||
display.print("F: Scan again Q: Back");
|
||||
}
|
||||
} else {
|
||||
// Center visible window around selected item
|
||||
int maxVisible = (maxY - headerHeight) / lineHeight;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
int startIdx = max(0, min(_scrollPos - maxVisible / 2,
|
||||
count - maxVisible));
|
||||
int endIdx = min(count, startIdx + maxVisible);
|
||||
|
||||
for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) {
|
||||
const DiscoveredNode& node = the_mesh.getDiscovered(i);
|
||||
bool selected = (i == _scrollPos);
|
||||
|
||||
// Highlight selected row
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Prefix: cursor + type
|
||||
char prefix[4];
|
||||
if (selected) {
|
||||
snprintf(prefix, sizeof(prefix), ">%c", typeChar(node.contact.type));
|
||||
} else {
|
||||
snprintf(prefix, sizeof(prefix), " %c", typeChar(node.contact.type));
|
||||
}
|
||||
display.print(prefix);
|
||||
|
||||
// Build right-side info: SNR or hop count + status
|
||||
char rightStr[16];
|
||||
if (node.snr != 0) {
|
||||
// Active discovery result — show SNR in dB (value is ×4 scaled)
|
||||
int snr_db = node.snr / 4;
|
||||
if (node.already_in_contacts) {
|
||||
snprintf(rightStr, sizeof(rightStr), "%ddB [+]", snr_db);
|
||||
} else {
|
||||
snprintf(rightStr, sizeof(rightStr), "%ddB", snr_db);
|
||||
}
|
||||
} else {
|
||||
// Pre-seeded from cache — show hop count
|
||||
if (node.already_in_contacts) {
|
||||
snprintf(rightStr, sizeof(rightStr), "%dh [+]", node.path_len);
|
||||
} else {
|
||||
snprintf(rightStr, sizeof(rightStr), "%dh", node.path_len);
|
||||
}
|
||||
}
|
||||
int rightWidth = display.getTextWidth(rightStr) + 2;
|
||||
|
||||
// Name (truncated with ellipsis)
|
||||
char filteredName[32];
|
||||
display.translateUTF8ToBlocks(filteredName, node.contact.name, sizeof(filteredName));
|
||||
int nameX = display.getTextWidth(prefix) + 2;
|
||||
int nameMaxW = display.width() - nameX - rightWidth - 2;
|
||||
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
|
||||
|
||||
// Right-aligned info
|
||||
display.setCursor(display.width() - rightWidth, y);
|
||||
display.print(rightStr);
|
||||
|
||||
y += lineHeight;
|
||||
rowsDrawn++;
|
||||
}
|
||||
_rowsPerPage = (rowsDrawn > 0) ? rowsDrawn : 1;
|
||||
}
|
||||
|
||||
display.setTextSize(1); // restore for footer
|
||||
|
||||
// === Footer ===
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Back");
|
||||
|
||||
const char* mid = "Ent:Add";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
|
||||
const char* right = "F:Rescan";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
|
||||
// Faster refresh while actively scanning
|
||||
return active ? 1000 : 5000;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
int count = the_mesh.getDiscoveredCount();
|
||||
|
||||
// W - scroll up
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_scrollPos > 0) {
|
||||
_scrollPos--;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// S - scroll down
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
if (_scrollPos < count - 1) {
|
||||
_scrollPos++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// F - rescan (handled here as well as in main.cpp for consistency)
|
||||
if (c == 'f') {
|
||||
the_mesh.startDiscovery();
|
||||
_scrollPos = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Enter - handled by main.cpp for alert feedback
|
||||
|
||||
return false; // Q/back and Enter handled by main.cpp
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
651
examples/companion_radio/ui-new/M4BMetadata.h
Normal file
651
examples/companion_radio/ui-new/M4BMetadata.h
Normal file
@@ -0,0 +1,651 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// M4BMetadata.h - Lightweight MP4/M4B atom parser for metadata extraction
|
||||
//
|
||||
// Walks the MP4 atom (box) tree to extract:
|
||||
// - Title (moov/udta/meta/ilst/©nam)
|
||||
// - Author (moov/udta/meta/ilst/©ART)
|
||||
// - Cover art (moov/udta/meta/ilst/covr) - JPEG offset+size within file
|
||||
// - Duration (moov/mvhd timescale + duration)
|
||||
// - Chapter markers (moov/udta/chpl) - Nero-style chapter list
|
||||
//
|
||||
// Designed for embedded use: no dynamic allocation, reads directly from SD
|
||||
// via Arduino File API, uses a small stack buffer for atom headers.
|
||||
//
|
||||
// Usage:
|
||||
// M4BMetadata meta;
|
||||
// File f = SD.open("/audiobooks/mybook.m4b");
|
||||
// if (meta.parse(f)) {
|
||||
// Serial.printf("Title: %s\n", meta.title);
|
||||
// Serial.printf("Author: %s\n", meta.author);
|
||||
// if (meta.hasCoverArt) {
|
||||
// // JPEG data is at meta.coverOffset, meta.coverSize bytes
|
||||
// }
|
||||
// }
|
||||
// f.close();
|
||||
// =============================================================================
|
||||
|
||||
#include <SD.h>
|
||||
|
||||
// Maximum metadata string lengths (including null terminator)
|
||||
#define M4B_MAX_TITLE 128
|
||||
#define M4B_MAX_AUTHOR 64
|
||||
#define M4B_MAX_CHAPTERS 100
|
||||
|
||||
struct M4BChapter {
|
||||
uint32_t startMs; // Chapter start time in milliseconds
|
||||
char name[48]; // Chapter title (truncated to fit)
|
||||
};
|
||||
|
||||
class M4BMetadata {
|
||||
public:
|
||||
// Extracted metadata
|
||||
char title[M4B_MAX_TITLE];
|
||||
char author[M4B_MAX_AUTHOR];
|
||||
bool hasCoverArt;
|
||||
uint32_t coverOffset; // Byte offset of JPEG/PNG data within file
|
||||
uint32_t coverSize; // Size of cover image data in bytes
|
||||
uint8_t coverFormat; // 13=JPEG, 14=PNG (from MP4 well-known type)
|
||||
uint32_t durationMs; // Total duration in milliseconds
|
||||
uint32_t sampleRate; // Audio sample rate (from audio stsd)
|
||||
uint32_t bitrate; // Approximate bitrate in bps
|
||||
|
||||
// Chapter data
|
||||
M4BChapter chapters[M4B_MAX_CHAPTERS];
|
||||
int chapterCount;
|
||||
|
||||
M4BMetadata() { clear(); }
|
||||
|
||||
void clear() {
|
||||
title[0] = '\0';
|
||||
author[0] = '\0';
|
||||
hasCoverArt = false;
|
||||
coverOffset = 0;
|
||||
coverSize = 0;
|
||||
coverFormat = 0;
|
||||
durationMs = 0;
|
||||
sampleRate = 44100;
|
||||
bitrate = 0;
|
||||
chapterCount = 0;
|
||||
}
|
||||
|
||||
// Parse an open file. Returns true if at least title or duration was found.
|
||||
// File position is NOT preserved — caller should seek as needed afterward.
|
||||
bool parse(File& file) {
|
||||
clear();
|
||||
if (!file || file.size() < 8) return false;
|
||||
|
||||
_fileSize = file.size();
|
||||
|
||||
// Walk top-level atoms looking for 'moov'
|
||||
uint32_t pos = 0;
|
||||
while (pos < _fileSize) {
|
||||
AtomHeader hdr;
|
||||
if (!readAtomHeader(file, pos, hdr)) break;
|
||||
if (hdr.size < 8) break;
|
||||
|
||||
if (hdr.type == ATOM_MOOV) {
|
||||
parseMoov(file, hdr.dataOffset, hdr.dataOffset + hdr.dataSize);
|
||||
break; // moov found and parsed, we're done
|
||||
}
|
||||
|
||||
// Skip to next top-level atom
|
||||
pos += hdr.size;
|
||||
if (hdr.size == 0) break; // size=0 means "extends to EOF"
|
||||
}
|
||||
|
||||
return (title[0] != '\0' || durationMs > 0);
|
||||
}
|
||||
|
||||
// Get chapter index for a given playback position (milliseconds).
|
||||
// Returns -1 if no chapters or position is before first chapter.
|
||||
int getChapterForPosition(uint32_t positionMs) const {
|
||||
if (chapterCount == 0) return -1;
|
||||
int ch = 0;
|
||||
for (int i = 1; i < chapterCount; i++) {
|
||||
if (chapters[i].startMs > positionMs) break;
|
||||
ch = i;
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
|
||||
// Get the start position of the next chapter after the given position.
|
||||
// Returns 0 if no next chapter.
|
||||
uint32_t getNextChapterMs(uint32_t positionMs) const {
|
||||
for (int i = 0; i < chapterCount; i++) {
|
||||
if (chapters[i].startMs > positionMs) return chapters[i].startMs;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get the start position of the current or previous chapter.
|
||||
uint32_t getPrevChapterMs(uint32_t positionMs) const {
|
||||
uint32_t prev = 0;
|
||||
for (int i = 0; i < chapterCount; i++) {
|
||||
if (chapters[i].startMs >= positionMs) break;
|
||||
prev = chapters[i].startMs;
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
private:
|
||||
uint32_t _fileSize;
|
||||
|
||||
// MP4 atom type codes (big-endian FourCC)
|
||||
static constexpr uint32_t ATOM_MOOV = 0x6D6F6F76; // 'moov'
|
||||
static constexpr uint32_t ATOM_MVHD = 0x6D766864; // 'mvhd'
|
||||
static constexpr uint32_t ATOM_UDTA = 0x75647461; // 'udta'
|
||||
static constexpr uint32_t ATOM_META = 0x6D657461; // 'meta'
|
||||
static constexpr uint32_t ATOM_ILST = 0x696C7374; // 'ilst'
|
||||
static constexpr uint32_t ATOM_NAM = 0xA96E616D; // '©nam'
|
||||
static constexpr uint32_t ATOM_ART = 0xA9415254; // '©ART'
|
||||
static constexpr uint32_t ATOM_COVR = 0x636F7672; // 'covr'
|
||||
static constexpr uint32_t ATOM_DATA = 0x64617461; // 'data'
|
||||
static constexpr uint32_t ATOM_CHPL = 0x6368706C; // 'chpl' (Nero chapters)
|
||||
static constexpr uint32_t ATOM_TRAK = 0x7472616B; // 'trak'
|
||||
static constexpr uint32_t ATOM_MDIA = 0x6D646961; // 'mdia'
|
||||
static constexpr uint32_t ATOM_MDHD = 0x6D646864; // 'mdhd'
|
||||
static constexpr uint32_t ATOM_HDLR = 0x68646C72; // 'hdlr'
|
||||
|
||||
struct AtomHeader {
|
||||
uint32_t type;
|
||||
uint64_t size; // Total atom size including header
|
||||
uint32_t dataOffset; // File offset where data begins (after header)
|
||||
uint64_t dataSize; // size - header_length
|
||||
};
|
||||
|
||||
// Read a 32-bit big-endian value from file at current position
|
||||
static uint32_t readU32BE(File& file) {
|
||||
uint8_t buf[4];
|
||||
file.read(buf, 4);
|
||||
return ((uint32_t)buf[0] << 24) | ((uint32_t)buf[1] << 16) |
|
||||
((uint32_t)buf[2] << 8) | buf[3];
|
||||
}
|
||||
|
||||
// Read a 64-bit big-endian value
|
||||
static uint64_t readU64BE(File& file) {
|
||||
uint32_t hi = readU32BE(file);
|
||||
uint32_t lo = readU32BE(file);
|
||||
return ((uint64_t)hi << 32) | lo;
|
||||
}
|
||||
|
||||
// Read a 16-bit big-endian value
|
||||
static uint16_t readU16BE(File& file) {
|
||||
uint8_t buf[2];
|
||||
file.read(buf, 2);
|
||||
return ((uint16_t)buf[0] << 8) | buf[1];
|
||||
}
|
||||
|
||||
// Read atom header at given file offset
|
||||
bool readAtomHeader(File& file, uint32_t offset, AtomHeader& hdr) {
|
||||
if (offset + 8 > _fileSize) return false;
|
||||
|
||||
file.seek(offset);
|
||||
uint32_t size32 = readU32BE(file);
|
||||
hdr.type = readU32BE(file);
|
||||
|
||||
if (size32 == 1) {
|
||||
// 64-bit extended size
|
||||
if (offset + 16 > _fileSize) return false;
|
||||
hdr.size = readU64BE(file);
|
||||
hdr.dataOffset = offset + 16;
|
||||
hdr.dataSize = (hdr.size > 16) ? hdr.size - 16 : 0;
|
||||
} else if (size32 == 0) {
|
||||
// Atom extends to end of file
|
||||
hdr.size = _fileSize - offset;
|
||||
hdr.dataOffset = offset + 8;
|
||||
hdr.dataSize = hdr.size - 8;
|
||||
} else {
|
||||
hdr.size = size32;
|
||||
hdr.dataOffset = offset + 8;
|
||||
hdr.dataSize = (size32 > 8) ? size32 - 8 : 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse the moov container atom
|
||||
void parseMoov(File& file, uint32_t start, uint32_t end) {
|
||||
uint32_t pos = start;
|
||||
while (pos < end) {
|
||||
AtomHeader hdr;
|
||||
if (!readAtomHeader(file, pos, hdr)) break;
|
||||
if (hdr.size < 8) break;
|
||||
|
||||
switch (hdr.type) {
|
||||
case ATOM_MVHD:
|
||||
parseMvhd(file, hdr.dataOffset, (uint32_t)hdr.dataSize);
|
||||
break;
|
||||
case ATOM_UDTA:
|
||||
parseUdta(file, hdr.dataOffset, hdr.dataOffset + (uint32_t)hdr.dataSize);
|
||||
break;
|
||||
case ATOM_TRAK:
|
||||
break;
|
||||
}
|
||||
|
||||
pos += (uint32_t)hdr.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse mvhd (movie header) for duration
|
||||
void parseMvhd(File& file, uint32_t offset, uint32_t size) {
|
||||
file.seek(offset);
|
||||
uint8_t version = file.read();
|
||||
|
||||
if (version == 0) {
|
||||
file.seek(offset + 4); // skip version(1) + flags(3)
|
||||
/* create_time */ readU32BE(file);
|
||||
/* modify_time */ readU32BE(file);
|
||||
uint32_t timescale = readU32BE(file);
|
||||
uint32_t duration = readU32BE(file);
|
||||
if (timescale > 0) {
|
||||
durationMs = (uint32_t)((uint64_t)duration * 1000 / timescale);
|
||||
}
|
||||
} else if (version == 1) {
|
||||
file.seek(offset + 4);
|
||||
/* create_time */ readU64BE(file);
|
||||
/* modify_time */ readU64BE(file);
|
||||
uint32_t timescale = readU32BE(file);
|
||||
uint64_t duration = readU64BE(file);
|
||||
if (timescale > 0) {
|
||||
durationMs = (uint32_t)(duration * 1000 / timescale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse udta container — contains meta and/or chpl
|
||||
void parseUdta(File& file, uint32_t start, uint32_t end) {
|
||||
uint32_t pos = start;
|
||||
while (pos < end) {
|
||||
AtomHeader hdr;
|
||||
if (!readAtomHeader(file, pos, hdr)) break;
|
||||
if (hdr.size < 8) break;
|
||||
|
||||
if (hdr.type == ATOM_META) {
|
||||
parseMeta(file, hdr.dataOffset + 4,
|
||||
hdr.dataOffset + (uint32_t)hdr.dataSize);
|
||||
} else if (hdr.type == ATOM_CHPL) {
|
||||
parseChpl(file, hdr.dataOffset, (uint32_t)hdr.dataSize);
|
||||
}
|
||||
|
||||
pos += (uint32_t)hdr.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse meta container — contains hdlr + ilst
|
||||
void parseMeta(File& file, uint32_t start, uint32_t end) {
|
||||
uint32_t pos = start;
|
||||
while (pos < end) {
|
||||
AtomHeader hdr;
|
||||
if (!readAtomHeader(file, pos, hdr)) break;
|
||||
if (hdr.size < 8) break;
|
||||
|
||||
if (hdr.type == ATOM_ILST) {
|
||||
parseIlst(file, hdr.dataOffset, hdr.dataOffset + (uint32_t)hdr.dataSize);
|
||||
}
|
||||
|
||||
pos += (uint32_t)hdr.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse ilst (iTunes metadata list) — contains ©nam, ©ART, covr etc.
|
||||
void parseIlst(File& file, uint32_t start, uint32_t end) {
|
||||
uint32_t pos = start;
|
||||
while (pos < end) {
|
||||
AtomHeader hdr;
|
||||
if (!readAtomHeader(file, pos, hdr)) break;
|
||||
if (hdr.size < 8) break;
|
||||
|
||||
switch (hdr.type) {
|
||||
case ATOM_NAM:
|
||||
extractTextData(file, hdr.dataOffset,
|
||||
hdr.dataOffset + (uint32_t)hdr.dataSize,
|
||||
title, M4B_MAX_TITLE);
|
||||
break;
|
||||
case ATOM_ART:
|
||||
extractTextData(file, hdr.dataOffset,
|
||||
hdr.dataOffset + (uint32_t)hdr.dataSize,
|
||||
author, M4B_MAX_AUTHOR);
|
||||
break;
|
||||
case ATOM_COVR:
|
||||
extractCoverData(file, hdr.dataOffset,
|
||||
hdr.dataOffset + (uint32_t)hdr.dataSize);
|
||||
break;
|
||||
}
|
||||
|
||||
pos += (uint32_t)hdr.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract text from a 'data' sub-atom within an ilst entry.
|
||||
void extractTextData(File& file, uint32_t start, uint32_t end,
|
||||
char* dest, int maxLen) {
|
||||
uint32_t pos = start;
|
||||
while (pos < end) {
|
||||
AtomHeader hdr;
|
||||
if (!readAtomHeader(file, pos, hdr)) break;
|
||||
if (hdr.size < 8) break;
|
||||
|
||||
if (hdr.type == ATOM_DATA && hdr.dataSize > 8) {
|
||||
uint32_t textOffset = hdr.dataOffset + 8;
|
||||
uint32_t textLen = (uint32_t)hdr.dataSize - 8;
|
||||
if (textLen > (uint32_t)(maxLen - 1)) textLen = maxLen - 1;
|
||||
|
||||
file.seek(textOffset);
|
||||
file.read((uint8_t*)dest, textLen);
|
||||
dest[textLen] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
pos += (uint32_t)hdr.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract cover art location from 'data' sub-atom within covr.
|
||||
void extractCoverData(File& file, uint32_t start, uint32_t end) {
|
||||
uint32_t pos = start;
|
||||
while (pos < end) {
|
||||
AtomHeader hdr;
|
||||
if (!readAtomHeader(file, pos, hdr)) break;
|
||||
if (hdr.size < 8) break;
|
||||
|
||||
if (hdr.type == ATOM_DATA && hdr.dataSize > 8) {
|
||||
file.seek(hdr.dataOffset);
|
||||
uint32_t typeIndicator = readU32BE(file);
|
||||
uint8_t wellKnownType = typeIndicator & 0xFF;
|
||||
|
||||
coverOffset = hdr.dataOffset + 8;
|
||||
coverSize = (uint32_t)hdr.dataSize - 8;
|
||||
coverFormat = wellKnownType; // 13=JPEG, 14=PNG
|
||||
hasCoverArt = (coverSize > 0);
|
||||
|
||||
Serial.printf("M4B: Cover art found - %s, %u bytes at offset %u\n",
|
||||
wellKnownType == 13 ? "JPEG" :
|
||||
wellKnownType == 14 ? "PNG" : "unknown",
|
||||
coverSize, coverOffset);
|
||||
return;
|
||||
}
|
||||
|
||||
pos += (uint32_t)hdr.size;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ID3v2 Parser for MP3 files
|
||||
// =====================================================================
|
||||
public:
|
||||
// Parse ID3v2 tags from an MP3 file. Extracts title (TIT2), artist
|
||||
// (TPE1), and cover art (APIC). Fills the same metadata fields as
|
||||
// the M4B parser so decodeCoverArt() works unchanged.
|
||||
bool parseID3v2(File& file) {
|
||||
clear();
|
||||
if (!file || file.size() < 10) return false;
|
||||
|
||||
file.seek(0);
|
||||
uint8_t hdr[10];
|
||||
if (file.read(hdr, 10) != 10) return false;
|
||||
|
||||
// Verify "ID3" magic
|
||||
if (hdr[0] != 'I' || hdr[1] != 'D' || hdr[2] != '3') {
|
||||
Serial.println("ID3: No ID3v2 header found");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t versionMajor = hdr[3]; // 3 = ID3v2.3, 4 = ID3v2.4
|
||||
bool v24 = (versionMajor == 4);
|
||||
bool hasExtHeader = (hdr[5] & 0x40) != 0;
|
||||
|
||||
// Tag size is syncsafe integer (4 x 7-bit bytes)
|
||||
uint32_t tagSize = ((uint32_t)(hdr[6] & 0x7F) << 21) |
|
||||
((uint32_t)(hdr[7] & 0x7F) << 14) |
|
||||
((uint32_t)(hdr[8] & 0x7F) << 7) |
|
||||
(hdr[9] & 0x7F);
|
||||
|
||||
uint32_t tagEnd = 10 + tagSize;
|
||||
if (tagEnd > file.size()) tagEnd = file.size();
|
||||
|
||||
Serial.printf("ID3: v2.%d, %u bytes\n", versionMajor, tagSize);
|
||||
|
||||
// Skip extended header if present
|
||||
uint32_t pos = 10;
|
||||
if (hasExtHeader && pos + 4 < tagEnd) {
|
||||
file.seek(pos);
|
||||
uint32_t extSize;
|
||||
if (v24) {
|
||||
uint8_t eb[4];
|
||||
file.read(eb, 4);
|
||||
extSize = ((uint32_t)(eb[0] & 0x7F) << 21) |
|
||||
((uint32_t)(eb[1] & 0x7F) << 14) |
|
||||
((uint32_t)(eb[2] & 0x7F) << 7) |
|
||||
(eb[3] & 0x7F);
|
||||
} else {
|
||||
extSize = readU32BE(file) + 4;
|
||||
}
|
||||
pos += extSize;
|
||||
}
|
||||
|
||||
// Walk ID3v2 frames
|
||||
bool foundTitle = false, foundArtist = false, foundCover = false;
|
||||
|
||||
while (pos + 10 < tagEnd) {
|
||||
file.seek(pos);
|
||||
uint8_t fhdr[10];
|
||||
if (file.read(fhdr, 10) != 10) break;
|
||||
|
||||
if (fhdr[0] == 0) break;
|
||||
|
||||
char frameId[5] = { (char)fhdr[0], (char)fhdr[1],
|
||||
(char)fhdr[2], (char)fhdr[3], '\0' };
|
||||
|
||||
uint32_t frameSize;
|
||||
if (v24) {
|
||||
frameSize = ((uint32_t)(fhdr[4] & 0x7F) << 21) |
|
||||
((uint32_t)(fhdr[5] & 0x7F) << 14) |
|
||||
((uint32_t)(fhdr[6] & 0x7F) << 7) |
|
||||
(fhdr[7] & 0x7F);
|
||||
} else {
|
||||
frameSize = ((uint32_t)fhdr[4] << 24) | ((uint32_t)fhdr[5] << 16) |
|
||||
((uint32_t)fhdr[6] << 8) | fhdr[7];
|
||||
}
|
||||
|
||||
if (frameSize == 0 || pos + 10 + frameSize > tagEnd) break;
|
||||
|
||||
uint32_t dataStart = pos + 10;
|
||||
|
||||
// --- TIT2 (Title) ---
|
||||
if (!foundTitle && strcmp(frameId, "TIT2") == 0 && frameSize > 1) {
|
||||
id3ExtractText(file, dataStart, frameSize, title, M4B_MAX_TITLE);
|
||||
foundTitle = (title[0] != '\0');
|
||||
}
|
||||
// --- TPE1 (Artist/Author) ---
|
||||
if (!foundArtist && strcmp(frameId, "TPE1") == 0 && frameSize > 1) {
|
||||
id3ExtractText(file, dataStart, frameSize, author, M4B_MAX_AUTHOR);
|
||||
foundArtist = (author[0] != '\0');
|
||||
}
|
||||
// --- APIC (Attached Picture) ---
|
||||
if (!foundCover && strcmp(frameId, "APIC") == 0 && frameSize > 20) {
|
||||
id3ExtractAPIC(file, dataStart, frameSize);
|
||||
foundCover = hasCoverArt;
|
||||
}
|
||||
|
||||
pos = dataStart + frameSize;
|
||||
|
||||
// Early exit once we have everything
|
||||
if (foundTitle && foundArtist && foundCover) break;
|
||||
}
|
||||
|
||||
if (foundTitle) Serial.printf("ID3: Title: %s\n", title);
|
||||
if (foundArtist) Serial.printf("ID3: Author: %s\n", author);
|
||||
return (foundTitle || foundCover);
|
||||
}
|
||||
|
||||
private:
|
||||
// Extract text from a TIT2/TPE1 frame.
|
||||
// Format: encoding(1) + text data
|
||||
void id3ExtractText(File& file, uint32_t offset, uint32_t size,
|
||||
char* dest, int maxLen) {
|
||||
file.seek(offset);
|
||||
uint8_t encoding = file.read();
|
||||
uint32_t textLen = size - 1;
|
||||
if (textLen == 0) return;
|
||||
|
||||
if (encoding == 0 || encoding == 3) {
|
||||
// ISO-8859-1 or UTF-8 — read directly
|
||||
uint32_t readLen = (textLen < (uint32_t)(maxLen - 1))
|
||||
? textLen : (uint32_t)(maxLen - 1);
|
||||
file.read((uint8_t*)dest, readLen);
|
||||
dest[readLen] = '\0';
|
||||
// Strip trailing nulls
|
||||
while (readLen > 0 && dest[readLen - 1] == '\0') readLen--;
|
||||
dest[readLen] = '\0';
|
||||
}
|
||||
else if (encoding == 1 || encoding == 2) {
|
||||
// UTF-16 (with or without BOM) — crude ASCII extraction
|
||||
// Static buffer to avoid stack overflow (loopTask has limited stack)
|
||||
static uint8_t u16buf[128];
|
||||
uint32_t readLen = (textLen > sizeof(u16buf)) ? sizeof(u16buf) : textLen;
|
||||
file.read(u16buf, readLen);
|
||||
|
||||
uint32_t srcStart = 0;
|
||||
// Skip BOM if present
|
||||
if (readLen >= 2 && ((u16buf[0] == 0xFF && u16buf[1] == 0xFE) ||
|
||||
(u16buf[0] == 0xFE && u16buf[1] == 0xFF))) {
|
||||
srcStart = 2;
|
||||
}
|
||||
bool littleEndian = (srcStart >= 2 && u16buf[0] == 0xFF);
|
||||
|
||||
int dstIdx = 0;
|
||||
for (uint32_t i = srcStart; i + 1 < readLen && dstIdx < maxLen - 1; i += 2) {
|
||||
uint8_t lo = littleEndian ? u16buf[i] : u16buf[i + 1];
|
||||
uint8_t hi = littleEndian ? u16buf[i + 1] : u16buf[i];
|
||||
if (lo == 0 && hi == 0) break; // null terminator
|
||||
if (hi == 0 && lo >= 0x20 && lo < 0x7F) {
|
||||
dest[dstIdx++] = (char)lo;
|
||||
} else {
|
||||
dest[dstIdx++] = '?';
|
||||
}
|
||||
}
|
||||
dest[dstIdx] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
// Extract APIC (cover art) frame.
|
||||
// Format: encoding(1) + MIME(null-term) + picType(1) + desc(null-term) + imageData
|
||||
void id3ExtractAPIC(File& file, uint32_t offset, uint32_t frameSize) {
|
||||
file.seek(offset);
|
||||
uint8_t encoding = file.read();
|
||||
|
||||
// Read MIME type (null-terminated ASCII)
|
||||
char mime[32] = {0};
|
||||
int mimeLen = 0;
|
||||
while (mimeLen < 31) {
|
||||
int b = file.read();
|
||||
if (b < 0) return; // Read error
|
||||
if (b == 0) break; // Null terminator = end of MIME string
|
||||
mime[mimeLen++] = (char)b;
|
||||
}
|
||||
mime[mimeLen] = '\0';
|
||||
|
||||
// Picture type (1 byte)
|
||||
uint8_t picType = file.read();
|
||||
(void)picType;
|
||||
|
||||
// Skip description (null-terminated, encoding-dependent)
|
||||
if (encoding == 0 || encoding == 3) {
|
||||
// Single-byte null terminator
|
||||
while (true) {
|
||||
int b = file.read();
|
||||
if (b < 0) return; // Read error
|
||||
if (b == 0) break; // Null terminator
|
||||
}
|
||||
} else {
|
||||
// UTF-16: double-null terminator
|
||||
while (true) {
|
||||
int b1 = file.read();
|
||||
int b2 = file.read();
|
||||
if (b1 < 0 || b2 < 0) return; // Read error
|
||||
if (b1 == 0 && b2 == 0) break; // Double-null terminator
|
||||
}
|
||||
}
|
||||
|
||||
// Everything from here to end of frame is image data
|
||||
uint32_t imgOffset = file.position();
|
||||
uint32_t imgEnd = offset + frameSize;
|
||||
if (imgOffset >= imgEnd) return;
|
||||
|
||||
uint32_t imgSize = imgEnd - imgOffset;
|
||||
|
||||
// Determine format from MIME type
|
||||
bool isJpeg = (strstr(mime, "jpeg") || strstr(mime, "jpg"));
|
||||
bool isPng = (strstr(mime, "png") != nullptr);
|
||||
|
||||
// Also detect by magic bytes if MIME is generic
|
||||
if (!isJpeg && !isPng && imgSize > 4) {
|
||||
file.seek(imgOffset);
|
||||
uint8_t magic[4];
|
||||
file.read(magic, 4);
|
||||
if (magic[0] == 0xFF && magic[1] == 0xD8) isJpeg = true;
|
||||
else if (magic[0] == 0x89 && magic[1] == 'P' &&
|
||||
magic[2] == 'N' && magic[3] == 'G') isPng = true;
|
||||
}
|
||||
|
||||
coverOffset = imgOffset;
|
||||
coverSize = imgSize;
|
||||
coverFormat = isJpeg ? 13 : (isPng ? 14 : 0);
|
||||
hasCoverArt = (imgSize > 100 && (isJpeg || isPng));
|
||||
|
||||
if (hasCoverArt) {
|
||||
Serial.printf("ID3: Cover %s, %u bytes\n",
|
||||
isJpeg ? "JPEG" : "PNG", imgSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Nero-style chapter list (chpl atom).
|
||||
void parseChpl(File& file, uint32_t offset, uint32_t size) {
|
||||
if (size < 9) return;
|
||||
|
||||
file.seek(offset);
|
||||
uint8_t version = file.read();
|
||||
file.read(); // flags byte 1
|
||||
file.read(); // flags byte 2
|
||||
file.read(); // flags byte 3
|
||||
|
||||
file.read(); // reserved
|
||||
|
||||
uint32_t count;
|
||||
if (version == 1) {
|
||||
count = readU32BE(file);
|
||||
} else {
|
||||
count = file.read();
|
||||
}
|
||||
|
||||
if (count > M4B_MAX_CHAPTERS) count = M4B_MAX_CHAPTERS;
|
||||
|
||||
chapterCount = 0;
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
if (!file.available()) break;
|
||||
|
||||
uint64_t timestamp = readU64BE(file);
|
||||
uint32_t startMs = (uint32_t)(timestamp / 10000); // 100ns -> ms
|
||||
|
||||
uint8_t nameLen = file.read();
|
||||
if (nameLen == 0 || !file.available()) break;
|
||||
|
||||
M4BChapter& ch = chapters[chapterCount];
|
||||
ch.startMs = startMs;
|
||||
|
||||
uint8_t readLen = (nameLen < sizeof(ch.name) - 1) ? nameLen : sizeof(ch.name) - 1;
|
||||
file.read((uint8_t*)ch.name, readLen);
|
||||
ch.name[readLen] = '\0';
|
||||
|
||||
if (nameLen > readLen) {
|
||||
file.seek(file.position() + (nameLen - readLen));
|
||||
}
|
||||
|
||||
chapterCount++;
|
||||
}
|
||||
|
||||
Serial.printf("M4B: Found %d chapters\n", chapterCount);
|
||||
}
|
||||
};
|
||||
886
examples/companion_radio/ui-new/Mapscreen.h
Normal file
886
examples/companion_radio/ui-new/Mapscreen.h
Normal file
@@ -0,0 +1,886 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// MapScreen — OSM Tile Map for T-Deck Pro E-Ink Display
|
||||
// =============================================================================
|
||||
//
|
||||
// Renders standard OSM "slippy map" PNG tiles from SD card onto the e-ink
|
||||
// display at native 240×320 resolution (bypassing the 128×128 logical grid).
|
||||
//
|
||||
// Tiles are B&W PNGs stored at /tiles/{zoom}/{x}/{y}.png — the same format
|
||||
// used by Ripple, tdeck-maps, and MTD-Script tile downloaders.
|
||||
//
|
||||
// REQUIREMENTS:
|
||||
// 1. Add PNGdec library to platformio.ini:
|
||||
// lib_deps = ... bitbank2/PNGdec@^1.0.1
|
||||
//
|
||||
// 2. Add raw display access to GxEPDDisplay.h (public section):
|
||||
// // --- Raw pixel access for MapScreen (bypasses scaling) ---
|
||||
// void drawPixelRaw(int16_t x, int16_t y, uint16_t color) {
|
||||
// display.drawPixel(x, y, color);
|
||||
// }
|
||||
// int16_t rawWidth() { return display.width(); }
|
||||
// int16_t rawHeight() { return display.height(); }
|
||||
// // Force endFrame() to push to display even if CRC unchanged
|
||||
// // (needed because drawPixelRaw bypasses CRC tracking)
|
||||
// void invalidateFrameCRC() { last_display_crc_value = 0; }
|
||||
//
|
||||
// 3. Add to UITask.h:
|
||||
// #include "MapScreen.h"
|
||||
// UIScreen* map_screen;
|
||||
// void gotoMapScreen();
|
||||
// bool isOnMapScreen() const { return curr == map_screen; }
|
||||
// UIScreen* getMapScreen() const { return map_screen; }
|
||||
//
|
||||
// 4. Initialise in UITask::begin():
|
||||
// map_screen = new MapScreen(this);
|
||||
//
|
||||
// 5. Implement UITask::gotoMapScreen() following gotoTextReader() pattern.
|
||||
//
|
||||
// 6. Hook 'g' key in main.cpp for GPS/Map access:
|
||||
// case 'g':
|
||||
// if (ui_task.isOnMapScreen()) {
|
||||
// // Already on map — 'g' re-centers on GPS
|
||||
// ui_task.injectKey('g');
|
||||
// } else {
|
||||
// Serial.println("Opening map");
|
||||
// {
|
||||
// MapScreen* ms = (MapScreen*)ui_task.getMapScreen();
|
||||
// if (ms) {
|
||||
// ms->setSDReady(sdCardReady);
|
||||
// ms->setGPSPosition(sensors.node_lat,
|
||||
// sensors.node_lon);
|
||||
// // Populate contact markers via iterator
|
||||
// ms->clearMarkers();
|
||||
// ContactsIterator it = the_mesh.startContactsIterator();
|
||||
// ContactInfo ci;
|
||||
// while (it.hasNext(&the_mesh, ci)) {
|
||||
// double lat = ((double)ci.gps_lat) / 1000000.0;
|
||||
// double lon = ((double)ci.gps_lon) / 1000000.0;
|
||||
// ms->addMarker(lat, lon, ci.name, ci.type);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// ui_task.gotoMapScreen();
|
||||
// }
|
||||
// break;
|
||||
//
|
||||
// 7. Route WASD/zoom keys to map screen in main.cpp (in existing handlers):
|
||||
// For 'w', 's', 'a', 'd' cases, add:
|
||||
// if (ui_task.isOnMapScreen()) { ui_task.injectKey(key); break; }
|
||||
// For the default case, add map screen passthrough:
|
||||
// if (ui_task.isOnMapScreen()) { ui_task.injectKey(key); break; }
|
||||
// This covers +, -, i, o, g (re-center) keys too.
|
||||
//
|
||||
// TILE SOURCES (B&W recommended for e-ink):
|
||||
// - MTD-Script: github.com/fistulareffigy/MTD-Script
|
||||
// - tdeck-maps: github.com/JustDr00py/tdeck-maps
|
||||
// - Stamen Toner style gives best e-ink contrast
|
||||
// =============================================================================
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
#include <PNGdec.h>
|
||||
#undef local // PNGdec's zutil.h defines 'local' as 'static' — breaks any variable named 'local'
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ui/GxEPDDisplay.h>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout constants (physical pixel coordinates, 240×320 display)
|
||||
// ---------------------------------------------------------------------------
|
||||
#define MAP_DISPLAY_W 240
|
||||
#define MAP_DISPLAY_H 320
|
||||
|
||||
// Footer bar occupies the bottom — matches other screens' setTextSize(1) footer
|
||||
#define MAP_FOOTER_H 24 // ~24px at bottom for nav hints
|
||||
#define MAP_VIEWPORT_Y 0 // Map starts at top
|
||||
#define MAP_VIEWPORT_H (MAP_DISPLAY_H - MAP_FOOTER_H) // 296px for map
|
||||
|
||||
#define MAP_TILE_SIZE 256 // Standard OSM tile size in pixels
|
||||
#define MAP_DEFAULT_ZOOM 13
|
||||
#define MAP_MIN_ZOOM 1
|
||||
#define MAP_MAX_ZOOM 17
|
||||
|
||||
// PNG decode buffer size — 256×256 RGB = 196KB, but PNGdec streams row-by-row
|
||||
// We only need a line buffer. Allocate in PSRAM for safety.
|
||||
#define MAP_PNG_BUF_SIZE (65536) // 64KB for PNG file read buffer
|
||||
|
||||
// Tile path on SD card
|
||||
#define MAP_TILE_ROOT "/tiles"
|
||||
|
||||
// Contact type (for label display — matches AdvertDataHelpers.h)
|
||||
#ifndef ADV_TYPE_REPEATER
|
||||
#define ADV_TYPE_REPEATER 2
|
||||
#endif
|
||||
|
||||
// Pan step: fraction of viewport to move per keypress
|
||||
#define MAP_PAN_FRACTION 4 // 1/4 of viewport per press
|
||||
|
||||
// Max contact markers (PSRAM-allocated, ~37 bytes each)
|
||||
#define MAP_MAX_MARKERS 500
|
||||
|
||||
|
||||
class MapScreen : public UIScreen {
|
||||
public:
|
||||
MapScreen(UITask* task)
|
||||
: _task(task),
|
||||
_einkDisplay(nullptr),
|
||||
_sdReady(false),
|
||||
_needsRedraw(true),
|
||||
_hasFix(false),
|
||||
_centerLat(-33.8688), // Default: Sydney (most Ripple users)
|
||||
_centerLon(151.2093),
|
||||
_gpsLat(0.0),
|
||||
_gpsLon(0.0),
|
||||
_zoom(MAP_DEFAULT_ZOOM),
|
||||
_zoomMin(MAP_MIN_ZOOM),
|
||||
_zoomMax(MAP_MAX_ZOOM),
|
||||
_pngBuf(nullptr),
|
||||
_tileFound(false)
|
||||
{
|
||||
// Allocate marker array in PSRAM at construction (~20KB)
|
||||
// so addMarker() works before enter() is called
|
||||
_markers = (MapMarker*)ps_calloc(MAP_MAX_MARKERS, sizeof(MapMarker));
|
||||
if (_markers) {
|
||||
Serial.printf("MapScreen: markers allocated (%d × %d = %d bytes PSRAM)\n",
|
||||
MAP_MAX_MARKERS, (int)sizeof(MapMarker),
|
||||
MAP_MAX_MARKERS * (int)sizeof(MapMarker));
|
||||
} else {
|
||||
Serial.println("MapScreen: marker PSRAM alloc FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
~MapScreen() {
|
||||
if (_pngBuf) { free(_pngBuf); _pngBuf = nullptr; }
|
||||
if (_markers) { free(_markers); _markers = nullptr; }
|
||||
}
|
||||
|
||||
void setSDReady(bool ready) { _sdReady = ready; }
|
||||
|
||||
// Set initial GPS position (called when opening map — centers viewport)
|
||||
void setGPSPosition(double lat, double lon) {
|
||||
if (lat != 0.0 || lon != 0.0) {
|
||||
_gpsLat = lat;
|
||||
_gpsLon = lon;
|
||||
_centerLat = lat;
|
||||
_centerLon = lon;
|
||||
_hasFix = true;
|
||||
_needsRedraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update own GPS position without moving viewport (called periodically)
|
||||
void updateGPSPosition(double lat, double lon) {
|
||||
if (lat == 0.0 && lon == 0.0) return;
|
||||
if (lat != _gpsLat || lon != _gpsLon) {
|
||||
_gpsLat = lat;
|
||||
_gpsLon = lon;
|
||||
_hasFix = true;
|
||||
_needsRedraw = true; // Redraw to move own-position marker
|
||||
}
|
||||
}
|
||||
|
||||
// Add a location marker (call once per contact before entering map)
|
||||
void clearMarkers() { _numMarkers = 0; }
|
||||
void addMarker(double lat, double lon, const char* name = "", uint8_t type = 0) {
|
||||
if (!_markers || _numMarkers >= MAP_MAX_MARKERS) return;
|
||||
if (lat == 0.0 && lon == 0.0) return; // Skip no-location contacts
|
||||
_markers[_numMarkers].lat = lat;
|
||||
_markers[_numMarkers].lon = lon;
|
||||
_markers[_numMarkers].type = type;
|
||||
strncpy(_markers[_numMarkers].name, name, sizeof(_markers[0].name) - 1);
|
||||
_markers[_numMarkers].name[sizeof(_markers[0].name) - 1] = '\0';
|
||||
_numMarkers++;
|
||||
}
|
||||
|
||||
// Refresh contact markers (called periodically from main loop)
|
||||
// Clears and rebuilds — caller iterates contacts and calls addMarker()
|
||||
int getNumMarkers() const { return _numMarkers; }
|
||||
|
||||
// Called when navigating to map screen
|
||||
void enter(DisplayDriver& display) {
|
||||
_einkDisplay = static_cast<GxEPDDisplay*>(&display);
|
||||
_needsRedraw = true;
|
||||
|
||||
// Allocate PNG read buffer in PSRAM on first use
|
||||
if (!_pngBuf) {
|
||||
_pngBuf = (uint8_t*)ps_malloc(MAP_PNG_BUF_SIZE);
|
||||
if (!_pngBuf) {
|
||||
Serial.println("MapScreen: PSRAM alloc failed, trying heap");
|
||||
_pngBuf = (uint8_t*)malloc(MAP_PNG_BUF_SIZE);
|
||||
}
|
||||
if (_pngBuf) {
|
||||
Serial.printf("MapScreen: PNG buffer allocated (%d bytes)\n", MAP_PNG_BUF_SIZE);
|
||||
} else {
|
||||
Serial.println("MapScreen: PNG buffer alloc FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
// Detect available zoom levels from SD card directories
|
||||
detectZoomRange();
|
||||
}
|
||||
|
||||
// ---- UIScreen interface ----
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
if (!_einkDisplay) {
|
||||
_einkDisplay = static_cast<GxEPDDisplay*>(&display);
|
||||
}
|
||||
|
||||
if (!_sdReady) {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(10, 20);
|
||||
display.print("SD card not found");
|
||||
display.setCursor(10, 35);
|
||||
display.print("Insert SD with");
|
||||
display.setCursor(10, 48);
|
||||
display.print("/tiles/{z}/{x}/{y}.png");
|
||||
return 5000;
|
||||
}
|
||||
|
||||
// Always render tiles — UITask clears the buffer via startFrame() before
|
||||
// calling us, so we must redraw every time (e.g. after alert overlays)
|
||||
bool wasRedraw = _needsRedraw;
|
||||
_needsRedraw = false;
|
||||
|
||||
// Render map tiles into the viewport
|
||||
renderMapViewport();
|
||||
|
||||
// Overlay contact markers
|
||||
renderContactMarkers();
|
||||
|
||||
// Crosshair at viewport center
|
||||
renderCrosshair();
|
||||
|
||||
// Footer bar (uses normal display API with scaling)
|
||||
renderFooter(display);
|
||||
|
||||
// Raw pixel writes bypass CRC tracking — force refresh
|
||||
_einkDisplay->invalidateFrameCRC();
|
||||
|
||||
// If user panned/zoomed, allow quick re-render; otherwise idle longer
|
||||
return wasRedraw ? 1000 : 30000;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// Pan distances in degrees — adaptive to zoom level
|
||||
// At zoom Z, one tile covers 360/2^Z degrees of longitude
|
||||
double tileLonSpan = 360.0 / (1 << _zoom);
|
||||
double tileLatSpan = tileLonSpan * cos(_centerLat * PI / 180.0); // Rough approx
|
||||
|
||||
// Pan by 1/MAP_PAN_FRACTION of viewport (viewport ≈ 1 tile)
|
||||
double panLon = tileLonSpan / MAP_PAN_FRACTION;
|
||||
double panLat = tileLatSpan / MAP_PAN_FRACTION;
|
||||
|
||||
switch (c) {
|
||||
// ---- WASD panning ----
|
||||
case 'w':
|
||||
case 'W':
|
||||
_centerLat += panLat;
|
||||
if (_centerLat > 85.05) _centerLat = 85.05; // Web Mercator limit
|
||||
_needsRedraw = true;
|
||||
return true;
|
||||
|
||||
case 's':
|
||||
case 'S':
|
||||
_centerLat -= panLat;
|
||||
if (_centerLat < -85.05) _centerLat = -85.05;
|
||||
_needsRedraw = true;
|
||||
return true;
|
||||
|
||||
case 'a':
|
||||
case 'A':
|
||||
_centerLon -= panLon;
|
||||
if (_centerLon < -180.0) _centerLon += 360.0;
|
||||
_needsRedraw = true;
|
||||
return true;
|
||||
|
||||
case 'd':
|
||||
case 'D':
|
||||
_centerLon += panLon;
|
||||
if (_centerLon > 180.0) _centerLon -= 360.0;
|
||||
_needsRedraw = true;
|
||||
return true;
|
||||
|
||||
// ---- Zoom controls ----
|
||||
case 'z':
|
||||
case 'Z':
|
||||
if (_zoom < _zoomMax) {
|
||||
_zoom++;
|
||||
_needsRedraw = true;
|
||||
Serial.printf("MapScreen: zoom in -> %d\n", _zoom);
|
||||
}
|
||||
return true;
|
||||
|
||||
case 'x':
|
||||
case 'X':
|
||||
if (_zoom > _zoomMin) {
|
||||
_zoom--;
|
||||
_needsRedraw = true;
|
||||
Serial.printf("MapScreen: zoom out -> %d\n", _zoom);
|
||||
}
|
||||
return true;
|
||||
|
||||
// ---- Re-center on GPS fix ----
|
||||
case 'g':
|
||||
if (_hasFix) {
|
||||
_centerLat = _gpsLat;
|
||||
_centerLon = _gpsLon;
|
||||
_needsRedraw = true;
|
||||
Serial.println("MapScreen: re-center on GPS");
|
||||
}
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
GxEPDDisplay* _einkDisplay;
|
||||
bool _sdReady;
|
||||
bool _needsRedraw;
|
||||
bool _hasFix;
|
||||
|
||||
// Map state
|
||||
double _centerLat;
|
||||
double _centerLon;
|
||||
double _gpsLat; // Own GPS position (separate from viewport center)
|
||||
double _gpsLon;
|
||||
int _zoom;
|
||||
int _zoomMin; // Detected from SD card
|
||||
int _zoomMax; // Detected from SD card
|
||||
|
||||
// PNG decode buffer (PSRAM)
|
||||
uint8_t* _pngBuf;
|
||||
bool _tileFound; // Did last tile load succeed?
|
||||
|
||||
// PNGdec instance
|
||||
PNG _png;
|
||||
|
||||
// Contacts for marker overlay
|
||||
struct MapMarker {
|
||||
double lat;
|
||||
double lon;
|
||||
char name[20]; // Truncated display name
|
||||
uint8_t type; // ADV_TYPE_CHAT, ADV_TYPE_REPEATER, etc.
|
||||
};
|
||||
MapMarker* _markers = nullptr; // PSRAM-allocated
|
||||
int _numMarkers = 0;
|
||||
|
||||
// ---- Rendering state passed to PNG callback ----
|
||||
// PNGdec calls our callback per scanline — we need to know where to draw.
|
||||
// Also carries a PNG* so the static callback can call getLineAsRGB565().
|
||||
struct DrawContext {
|
||||
GxEPDDisplay* display;
|
||||
PNG* png; // Pointer to the decoder (for getLineAsRGB565)
|
||||
int offsetX; // Screen X offset for this tile
|
||||
int offsetY; // Screen Y offset for this tile
|
||||
int viewportY; // Top of viewport (MAP_VIEWPORT_Y)
|
||||
int viewportH; // Height of viewport (MAP_VIEWPORT_H)
|
||||
};
|
||||
DrawContext _drawCtx;
|
||||
|
||||
// ==========================================================================
|
||||
// Detect available zoom levels from /tiles/{z}/ directories on SD
|
||||
// ==========================================================================
|
||||
|
||||
void detectZoomRange() {
|
||||
if (!_sdReady) return;
|
||||
|
||||
_zoomMin = MAP_MAX_ZOOM;
|
||||
_zoomMax = MAP_MIN_ZOOM;
|
||||
|
||||
char path[32];
|
||||
for (int z = MAP_MIN_ZOOM; z <= MAP_MAX_ZOOM; z++) {
|
||||
snprintf(path, sizeof(path), MAP_TILE_ROOT "/%d", z);
|
||||
if (SD.exists(path)) {
|
||||
if (z < _zoomMin) _zoomMin = z;
|
||||
if (z > _zoomMax) _zoomMax = z;
|
||||
}
|
||||
}
|
||||
|
||||
// If no tiles found, reset to defaults
|
||||
if (_zoomMin > _zoomMax) {
|
||||
_zoomMin = MAP_MIN_ZOOM;
|
||||
_zoomMax = MAP_MAX_ZOOM;
|
||||
Serial.println("MapScreen: no tile directories found");
|
||||
} else {
|
||||
Serial.printf("MapScreen: detected zoom range %d-%d\n", _zoomMin, _zoomMax);
|
||||
}
|
||||
|
||||
// Clamp current zoom to available range
|
||||
if (_zoom > _zoomMax) _zoom = _zoomMax;
|
||||
if (_zoom < _zoomMin) _zoom = _zoomMin;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Tile coordinate math (Web Mercator / Slippy Map convention)
|
||||
// ==========================================================================
|
||||
|
||||
// Convert lat/lon to tile X,Y and sub-tile pixel offset at given zoom
|
||||
static void latLonToTileXY(double lat, double lon, int zoom,
|
||||
int& tileX, int& tileY,
|
||||
int& pixelX, int& pixelY)
|
||||
{
|
||||
int n = 1 << zoom;
|
||||
|
||||
// Tile X (longitude is linear)
|
||||
double x = (lon + 180.0) / 360.0 * n;
|
||||
tileX = (int)floor(x);
|
||||
pixelX = (int)((x - tileX) * MAP_TILE_SIZE);
|
||||
|
||||
// Tile Y (latitude uses Mercator projection)
|
||||
double latRad = lat * PI / 180.0;
|
||||
double y = (1.0 - log(tan(latRad) + 1.0 / cos(latRad)) / PI) / 2.0 * n;
|
||||
tileY = (int)floor(y);
|
||||
pixelY = (int)((y - tileY) * MAP_TILE_SIZE);
|
||||
}
|
||||
|
||||
// Convert tile X,Y + pixel offset back to lat/lon
|
||||
static void tileXYToLatLon(int tileX, int tileY, int pixelX, int pixelY,
|
||||
int zoom, double& lat, double& lon)
|
||||
{
|
||||
int n = 1 << zoom;
|
||||
double x = tileX + (double)pixelX / MAP_TILE_SIZE;
|
||||
double y = tileY + (double)pixelY / MAP_TILE_SIZE;
|
||||
|
||||
lon = x / n * 360.0 - 180.0;
|
||||
double latRad = atan(sinh(PI * (1.0 - 2.0 * y / n)));
|
||||
lat = latRad * 180.0 / PI;
|
||||
}
|
||||
|
||||
// Convert a lat/lon to pixel position within the current viewport
|
||||
// Returns false if off-screen
|
||||
bool latLonToScreen(double lat, double lon, int& screenX, int& screenY) {
|
||||
int centerTileX, centerTileY, centerPixelX, centerPixelY;
|
||||
latLonToTileXY(_centerLat, _centerLon, _zoom,
|
||||
centerTileX, centerTileY, centerPixelX, centerPixelY);
|
||||
|
||||
int targetTileX, targetTileY, targetPixelX, targetPixelY;
|
||||
latLonToTileXY(lat, lon, _zoom,
|
||||
targetTileX, targetTileY, targetPixelX, targetPixelY);
|
||||
|
||||
// Calculate pixel delta from center
|
||||
int dx = (targetTileX - centerTileX) * MAP_TILE_SIZE + (targetPixelX - centerPixelX);
|
||||
int dy = (targetTileY - centerTileY) * MAP_TILE_SIZE + (targetPixelY - centerPixelY);
|
||||
|
||||
screenX = MAP_DISPLAY_W / 2 + dx;
|
||||
screenY = MAP_VIEWPORT_Y + MAP_VIEWPORT_H / 2 + dy;
|
||||
|
||||
return (screenX >= 0 && screenX < MAP_DISPLAY_W &&
|
||||
screenY >= MAP_VIEWPORT_Y && screenY < MAP_VIEWPORT_Y + MAP_VIEWPORT_H);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Tile loading and rendering
|
||||
// ==========================================================================
|
||||
|
||||
// Build tile file path: /tiles/{zoom}/{x}/{y}.png
|
||||
static void buildTilePath(char* buf, int bufSize, int zoom, int x, int y) {
|
||||
snprintf(buf, bufSize, MAP_TILE_ROOT "/%d/%d/%d.png", zoom, x, y);
|
||||
}
|
||||
|
||||
// Load a PNG tile from SD and decode it directly to the display
|
||||
// screenX, screenY = top-left corner on display where this tile goes
|
||||
bool loadAndRenderTile(int tileX, int tileY, int screenX, int screenY) {
|
||||
if (!_pngBuf || !_einkDisplay) return false;
|
||||
|
||||
char path[64];
|
||||
buildTilePath(path, sizeof(path), _zoom, tileX, tileY);
|
||||
|
||||
// Check existence first to avoid noisy ESP32 VFS error logs
|
||||
if (!SD.exists(path)) return false;
|
||||
|
||||
File f = SD.open(path, FILE_READ);
|
||||
if (!f) return false;
|
||||
|
||||
// Read entire PNG into buffer
|
||||
int fileSize = f.size();
|
||||
if (fileSize > MAP_PNG_BUF_SIZE) {
|
||||
Serial.printf("MapScreen: tile too large: %s (%d bytes)\n", path, fileSize);
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
int bytesRead = f.read(_pngBuf, fileSize);
|
||||
f.close();
|
||||
|
||||
if (bytesRead != fileSize) {
|
||||
Serial.printf("MapScreen: short read: %s (%d/%d)\n", path, bytesRead, fileSize);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set up draw context for the PNG callback
|
||||
_drawCtx.display = _einkDisplay;
|
||||
_drawCtx.png = &_png;
|
||||
_drawCtx.offsetX = screenX;
|
||||
_drawCtx.offsetY = screenY;
|
||||
_drawCtx.viewportY = MAP_VIEWPORT_Y;
|
||||
_drawCtx.viewportH = MAP_VIEWPORT_H;
|
||||
|
||||
// Open PNG from memory buffer
|
||||
int rc = _png.openRAM(_pngBuf, fileSize, pngDrawCallback);
|
||||
if (rc != PNG_SUCCESS) {
|
||||
Serial.printf("MapScreen: PNG open failed: %s (rc=%d)\n", path, rc);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decode — triggers pngDrawCallback for each scanline.
|
||||
// First arg is user pointer, passed as pDraw->pUser in callback.
|
||||
rc = _png.decode(&_drawCtx, 0);
|
||||
_png.close();
|
||||
|
||||
if (rc != PNG_SUCCESS) {
|
||||
Serial.printf("MapScreen: PNG decode failed: %s (rc=%d)\n", path, rc);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// PNGdec scanline callback — called once per row of the decoded image.
|
||||
// Draws directly to the e-ink display at raw pixel coordinates.
|
||||
// Uses getLineAsRGB565 with correct (little) endianness for ESP32.
|
||||
static int pngDrawCallback(PNGDRAW* pDraw) {
|
||||
DrawContext* ctx = (DrawContext*)pDraw->pUser;
|
||||
if (!ctx || !ctx->display || !ctx->png) return 0;
|
||||
|
||||
int screenY = ctx->offsetY + pDraw->y;
|
||||
|
||||
// Clip to viewport vertically
|
||||
if (screenY < ctx->viewportY || screenY >= ctx->viewportY + ctx->viewportH) return 1;
|
||||
|
||||
// Debug: log format on first row of first tile only
|
||||
if (pDraw->y == 0 && ctx->offsetX >= 0 && ctx->offsetY >= 0) {
|
||||
static bool logged = false;
|
||||
if (!logged) {
|
||||
Serial.printf("MapScreen: PNG iBpp=%d iWidth=%d\n", pDraw->iBpp, pDraw->iWidth);
|
||||
logged = true;
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t lineWidth = pDraw->iWidth;
|
||||
uint16_t lineBuf[MAP_TILE_SIZE];
|
||||
if (lineWidth > MAP_TILE_SIZE) lineWidth = MAP_TILE_SIZE;
|
||||
ctx->png->getLineAsRGB565(pDraw, lineBuf, PNG_RGB565_LITTLE_ENDIAN, 0xFFFFFFFF);
|
||||
|
||||
for (int x = 0; x < lineWidth; x++) {
|
||||
int screenX = ctx->offsetX + x;
|
||||
if (screenX < 0 || screenX >= MAP_DISPLAY_W) continue;
|
||||
|
||||
// RGB565 little-endian on ESP32: standard bit layout
|
||||
// R[15:11] G[10:5] B[4:0]
|
||||
uint16_t pixel = lineBuf[x];
|
||||
|
||||
// For B&W tiles this is 0x0000 (black) or 0xFFFF (white)
|
||||
// Simple threshold on full 16-bit value handles both cleanly
|
||||
uint16_t color = (pixel > 0x7FFF) ? GxEPD_WHITE : GxEPD_BLACK;
|
||||
ctx->display->drawPixelRaw(screenX, screenY, color);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Viewport rendering — stitch tiles to fill the screen
|
||||
// ==========================================================================
|
||||
|
||||
void renderMapViewport() {
|
||||
if (!_einkDisplay) return;
|
||||
|
||||
// Find which tile the center point falls in
|
||||
int centerTileX, centerTileY, centerPixelX, centerPixelY;
|
||||
latLonToTileXY(_centerLat, _centerLon, _zoom,
|
||||
centerTileX, centerTileY, centerPixelX, centerPixelY);
|
||||
|
||||
Serial.printf("MapScreen: center tile %d/%d/%d px(%d,%d)\n",
|
||||
_zoom, centerTileX, centerTileY, centerPixelX, centerPixelY);
|
||||
|
||||
// Screen position where the center tile's (0,0) corner should be placed
|
||||
// such that the GPS point ends up at viewport center
|
||||
int viewCenterX = MAP_DISPLAY_W / 2;
|
||||
int viewCenterY = MAP_VIEWPORT_Y + MAP_VIEWPORT_H / 2;
|
||||
|
||||
int baseTileScreenX = viewCenterX - centerPixelX;
|
||||
int baseTileScreenY = viewCenterY - centerPixelY;
|
||||
|
||||
// Determine tile grid range needed to cover the entire viewport
|
||||
int startDX = 0, startDY = 0;
|
||||
int endDX = 0, endDY = 0;
|
||||
|
||||
while (baseTileScreenX + startDX * MAP_TILE_SIZE > 0) startDX--;
|
||||
while (baseTileScreenY + startDY * MAP_TILE_SIZE > MAP_VIEWPORT_Y) startDY--;
|
||||
while (baseTileScreenX + (endDX + 1) * MAP_TILE_SIZE < MAP_DISPLAY_W) endDX++;
|
||||
while (baseTileScreenY + (endDY + 1) * MAP_TILE_SIZE < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) endDY++;
|
||||
|
||||
int maxTile = (1 << _zoom) - 1;
|
||||
int loaded = 0, missing = 0;
|
||||
|
||||
for (int dy = startDY; dy <= endDY; dy++) {
|
||||
for (int dx = startDX; dx <= endDX; dx++) {
|
||||
int tx = centerTileX + dx;
|
||||
int ty = centerTileY + dy;
|
||||
|
||||
// Longitude wraps
|
||||
if (tx < 0) tx += (1 << _zoom);
|
||||
if (tx > maxTile) tx -= (1 << _zoom);
|
||||
|
||||
// Latitude doesn't wrap — skip out-of-range
|
||||
if (ty < 0 || ty > maxTile) continue;
|
||||
|
||||
int screenX = baseTileScreenX + dx * MAP_TILE_SIZE;
|
||||
int screenY = baseTileScreenY + dy * MAP_TILE_SIZE;
|
||||
|
||||
if (loadAndRenderTile(tx, ty, screenX, screenY)) {
|
||||
loaded++;
|
||||
} else {
|
||||
missing++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("MapScreen: rendered %d tiles, %d missing\n", loaded, missing);
|
||||
_tileFound = (loaded > 0);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Contact marker overlay
|
||||
// ==========================================================================
|
||||
|
||||
void renderContactMarkers() {
|
||||
if (!_einkDisplay || !_markers) return;
|
||||
|
||||
int visible = 0;
|
||||
for (int i = 0; i < _numMarkers; i++) {
|
||||
int sx, sy;
|
||||
if (latLonToScreen(_markers[i].lat, _markers[i].lon, sx, sy)) {
|
||||
int r = markerRadius();
|
||||
drawDiamond(sx, sy, r);
|
||||
|
||||
// Draw name label for repeaters (and at higher zoom for all contacts)
|
||||
if (_markers[i].name[0] != '\0' &&
|
||||
(_markers[i].type == ADV_TYPE_REPEATER || _zoom >= 14)) {
|
||||
drawLabel(sx, sy - r - 2, _markers[i].name);
|
||||
}
|
||||
visible++;
|
||||
}
|
||||
}
|
||||
|
||||
// Render own GPS position as a distinct marker (circle)
|
||||
if (_hasFix) {
|
||||
int sx, sy;
|
||||
if (latLonToScreen(_gpsLat, _gpsLon, sx, sy)) {
|
||||
drawOwnPosition(sx, sy);
|
||||
visible++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Marker radius scaled by zoom level
|
||||
// z10→3px, z11→4, z12→5, z13→6, z14→7, z15→8, z16→9, z17→10
|
||||
int markerRadius() {
|
||||
int r = _zoom - 7;
|
||||
if (r < 3) r = 3;
|
||||
if (r > 10) r = 10;
|
||||
return r;
|
||||
}
|
||||
|
||||
// Draw a filled diamond marker at screen coordinates with given radius
|
||||
void drawDiamond(int cx, int cy, int r) {
|
||||
// White outline first (1px larger than fill)
|
||||
for (int dy = -(r + 1); dy <= (r + 1); dy++) {
|
||||
int span = (r + 1) - abs(dy);
|
||||
int innerSpan = r - abs(dy);
|
||||
for (int dx = -span; dx <= span; dx++) {
|
||||
if (abs(dy) <= r && abs(dx) <= innerSpan) continue;
|
||||
int px = cx + dx, py = cy + dy;
|
||||
if (px >= 0 && px < MAP_DISPLAY_W &&
|
||||
py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
|
||||
_einkDisplay->drawPixelRaw(px, py, GxEPD_WHITE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filled black diamond
|
||||
for (int dy = -r; dy <= r; dy++) {
|
||||
int span = r - abs(dy);
|
||||
for (int dx = -span; dx <= span; dx++) {
|
||||
int px = cx + dx, py = cy + dy;
|
||||
if (px >= 0 && px < MAP_DISPLAY_W &&
|
||||
py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
|
||||
_einkDisplay->drawPixelRaw(px, py, GxEPD_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strip non-ASCII characters (emoji, flags, symbols) from label text.
|
||||
// Copies only printable ASCII (0x20-0x7E) into dest buffer.
|
||||
// Skips leading whitespace after stripping. Returns length.
|
||||
static int extractAsciiLabel(const char* src, char* dest, int destSize) {
|
||||
int j = 0;
|
||||
for (int i = 0; src[i] != '\0' && j < destSize - 1; i++) {
|
||||
uint8_t ch = (uint8_t)src[i];
|
||||
if (ch >= 0x20 && ch <= 0x7E) {
|
||||
dest[j++] = src[i];
|
||||
}
|
||||
// Skip continuation bytes of multi-byte UTF-8 sequences
|
||||
}
|
||||
dest[j] = '\0';
|
||||
|
||||
// Trim leading spaces (left after stripping emoji prefix)
|
||||
int start = 0;
|
||||
while (dest[start] == ' ') start++;
|
||||
if (start > 0) {
|
||||
memmove(dest, dest + start, j - start + 1);
|
||||
j -= start;
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
// Draw a text label above a marker with white background for readability
|
||||
// Built-in font is 5×7 pixels per character
|
||||
void drawLabel(int cx, int topY, const char* text) {
|
||||
// Clean emoji/non-ASCII from label
|
||||
char clean[24];
|
||||
int len = extractAsciiLabel(text, clean, sizeof(clean));
|
||||
if (len == 0) return; // Nothing printable
|
||||
if (len > 14) len = 14; // Truncate long names
|
||||
clean[len] = '\0';
|
||||
|
||||
int textW = len * 6; // 5px char + 1px spacing
|
||||
int textH = 8; // 7px + 1px padding
|
||||
|
||||
int lx = cx - textW / 2;
|
||||
int ly = topY - textH;
|
||||
|
||||
// Clamp to viewport
|
||||
if (lx < 1) lx = 1;
|
||||
if (lx + textW >= MAP_DISPLAY_W - 1) lx = MAP_DISPLAY_W - textW - 1;
|
||||
if (ly < MAP_VIEWPORT_Y) ly = MAP_VIEWPORT_Y;
|
||||
if (ly + textH >= MAP_VIEWPORT_Y + MAP_VIEWPORT_H) return;
|
||||
|
||||
// White background rectangle
|
||||
for (int y = ly - 1; y <= ly + textH; y++) {
|
||||
for (int x = lx - 1; x <= lx + textW; x++) {
|
||||
if (x >= 0 && x < MAP_DISPLAY_W &&
|
||||
y >= MAP_VIEWPORT_Y && y < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
|
||||
_einkDisplay->drawPixelRaw(x, y, GxEPD_WHITE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw text using raw font rendering
|
||||
_einkDisplay->drawTextRaw(lx, ly, clean, GxEPD_BLACK);
|
||||
}
|
||||
|
||||
// Draw own-position marker: bold circle with filled center dot
|
||||
// Fixed size (doesn't scale with zoom) so it's always clearly visible
|
||||
void drawOwnPosition(int cx, int cy) {
|
||||
int r = 8; // Outer radius — always prominent
|
||||
|
||||
// White halo (clears map underneath)
|
||||
for (int dy = -(r + 2); dy <= (r + 2); dy++) {
|
||||
for (int dx = -(r + 2); dx <= (r + 2); dx++) {
|
||||
if (dx * dx + dy * dy <= (r + 2) * (r + 2)) {
|
||||
int px = cx + dx, py = cy + dy;
|
||||
if (px >= 0 && px < MAP_DISPLAY_W &&
|
||||
py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
|
||||
_einkDisplay->drawPixelRaw(px, py, GxEPD_WHITE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thick black circle outline (2px wide ring)
|
||||
for (int dy = -r; dy <= r; dy++) {
|
||||
for (int dx = -r; dx <= r; dx++) {
|
||||
int d2 = dx * dx + dy * dy;
|
||||
if (d2 >= (r - 2) * (r - 2) && d2 <= r * r) {
|
||||
int px = cx + dx, py = cy + dy;
|
||||
if (px >= 0 && px < MAP_DISPLAY_W &&
|
||||
py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
|
||||
_einkDisplay->drawPixelRaw(px, py, GxEPD_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filled black center dot (radius 3)
|
||||
for (int dy = -3; dy <= 3; dy++) {
|
||||
for (int dx = -3; dx <= 3; dx++) {
|
||||
if (dx * dx + dy * dy <= 9) {
|
||||
int px = cx + dx, py = cy + dy;
|
||||
if (px >= 0 && px < MAP_DISPLAY_W &&
|
||||
py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
|
||||
_einkDisplay->drawPixelRaw(px, py, GxEPD_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Crosshair at viewport center
|
||||
// ==========================================================================
|
||||
|
||||
void renderCrosshair() {
|
||||
if (!_einkDisplay) return;
|
||||
|
||||
int cx = MAP_DISPLAY_W / 2;
|
||||
int cy = MAP_VIEWPORT_Y + MAP_VIEWPORT_H / 2;
|
||||
int len = markerRadius() + 2; // Scales with zoom
|
||||
|
||||
// Draw thin crosshair: black line with white border for contrast
|
||||
// Horizontal arm
|
||||
for (int x = cx - len; x <= cx + len; x++) {
|
||||
if (x >= 0 && x < MAP_DISPLAY_W) {
|
||||
if (cy - 1 >= MAP_VIEWPORT_Y)
|
||||
_einkDisplay->drawPixelRaw(x, cy - 1, GxEPD_WHITE);
|
||||
if (cy + 1 < MAP_VIEWPORT_Y + MAP_VIEWPORT_H)
|
||||
_einkDisplay->drawPixelRaw(x, cy + 1, GxEPD_WHITE);
|
||||
_einkDisplay->drawPixelRaw(x, cy, GxEPD_BLACK);
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical arm
|
||||
for (int y = cy - len; y <= cy + len; y++) {
|
||||
if (y >= MAP_VIEWPORT_Y && y < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) {
|
||||
if (cx - 1 >= 0)
|
||||
_einkDisplay->drawPixelRaw(cx - 1, y, GxEPD_WHITE);
|
||||
if (cx + 1 < MAP_DISPLAY_W)
|
||||
_einkDisplay->drawPixelRaw(cx + 1, y, GxEPD_WHITE);
|
||||
_einkDisplay->drawPixelRaw(cx, y, GxEPD_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Footer bar — zoom level, GPS status, navigation hints
|
||||
// ==========================================================================
|
||||
|
||||
void renderFooter(DisplayDriver& display) {
|
||||
// Use the standard footer pattern: setTextSize(1) at height()-12
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
int footerY = display.height() - 12;
|
||||
|
||||
// Separator line
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
|
||||
// Left: zoom level
|
||||
char left[8];
|
||||
snprintf(left, sizeof(left), "Z%d", _zoom);
|
||||
display.setCursor(0, footerY);
|
||||
display.print(left);
|
||||
|
||||
// Right: navigation hint
|
||||
const char* right = "WASD:pan Z/X:zoom";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
};
|
||||
1234
examples/companion_radio/ui-new/ModemManager.cpp
Normal file
1234
examples/companion_radio/ui-new/ModemManager.cpp
Normal file
File diff suppressed because it is too large
Load Diff
266
examples/companion_radio/ui-new/ModemManager.h
Normal file
266
examples/companion_radio/ui-new/ModemManager.h
Normal file
@@ -0,0 +1,266 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// ModemManager - A7682E 4G Modem Driver for T-Deck Pro (V1.1 4G variant)
|
||||
//
|
||||
// Runs AT commands on a dedicated FreeRTOS task (Core 0, priority 1) to never
|
||||
// block the mesh radio loop. Communicates with main loop via lock-free queues.
|
||||
//
|
||||
// Supports: SMS send/receive, voice call dial/answer/hangup/DTMF
|
||||
//
|
||||
// Guard: HAS_4G_MODEM (defined only for the 4G build environment)
|
||||
// =============================================================================
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
|
||||
#ifndef MODEM_MANAGER_H
|
||||
#define MODEM_MANAGER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include <freertos/queue.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include "variant.h"
|
||||
#include "ApnDatabase.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modem pins (from variant.h, always defined for reference)
|
||||
// MODEM_POWER_EN 41 Board 6609 enable
|
||||
// MODEM_PWRKEY 40 Power key toggle
|
||||
// MODEM_RST 9 Reset (shared with I2S BCLK on audio board)
|
||||
// MODEM_RI 7 Ring indicator (shared with I2S DOUT on audio)
|
||||
// MODEM_DTR 8 Data terminal ready (shared with I2S LRC on audio)
|
||||
// MODEM_RX 10 UART RX (shared with PIN_PERF_POWERON)
|
||||
// MODEM_TX 11 UART TX
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SMS field limits
|
||||
#define SMS_PHONE_LEN 20
|
||||
#define SMS_BODY_LEN 161 // 160 chars + null
|
||||
|
||||
// Task configuration
|
||||
#define MODEM_TASK_PRIORITY 1 // Below mesh (default loop = priority 1 on core 1)
|
||||
#define MODEM_TASK_STACK_SIZE 6144 // Increased for call handling
|
||||
#define MODEM_TASK_CORE 0 // Run on core 0 (mesh runs on core 1)
|
||||
|
||||
// Queue sizes
|
||||
#define MODEM_SEND_QUEUE_SIZE 4
|
||||
#define MODEM_RECV_QUEUE_SIZE 8
|
||||
#define MODEM_CALL_CMD_QUEUE_SIZE 4
|
||||
#define MODEM_CALL_EVT_QUEUE_SIZE 4
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modem state machine
|
||||
// ---------------------------------------------------------------------------
|
||||
enum class ModemState {
|
||||
OFF,
|
||||
POWERING_ON,
|
||||
INITIALIZING,
|
||||
REGISTERING,
|
||||
READY,
|
||||
ERROR,
|
||||
SENDING_SMS,
|
||||
// Voice call states
|
||||
DIALING, // ATD sent, waiting for connect/carrier
|
||||
RINGING_IN, // Incoming call detected (RING URC)
|
||||
IN_CALL // Voice call active
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SMS structures (unchanged)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Outgoing SMS (queued from main loop to modem task)
|
||||
struct SMSOutgoing {
|
||||
char phone[SMS_PHONE_LEN];
|
||||
char body[SMS_BODY_LEN];
|
||||
};
|
||||
|
||||
// Incoming SMS (queued from modem task to main loop)
|
||||
struct SMSIncoming {
|
||||
char phone[SMS_PHONE_LEN];
|
||||
char body[SMS_BODY_LEN];
|
||||
uint32_t timestamp; // epoch seconds (from modem RTC or millis-based)
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voice call structures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Commands from main loop → modem task
|
||||
enum class CallCmd : uint8_t {
|
||||
DIAL, // Initiate outgoing call
|
||||
ANSWER, // Answer incoming call
|
||||
HANGUP, // End active call or reject incoming
|
||||
DTMF, // Send DTMF tone during call
|
||||
SET_VOLUME // Set speaker volume
|
||||
};
|
||||
|
||||
struct CallCommand {
|
||||
CallCmd cmd;
|
||||
char phone[SMS_PHONE_LEN]; // Used by DIAL
|
||||
char dtmf; // Used by DTMF (single digit: 0-9, *, #)
|
||||
uint8_t volume; // Used by SET_VOLUME (0-5)
|
||||
};
|
||||
|
||||
// Events from modem task → main loop
|
||||
enum class CallEventType : uint8_t {
|
||||
INCOMING, // Incoming call ringing (+CLIP parsed)
|
||||
CONNECTED, // Call answered / outgoing connected
|
||||
ENDED, // Call ended (local hangup, remote hangup, or no carrier)
|
||||
MISSED, // Incoming call ended before answer
|
||||
BUSY, // Outgoing call got busy signal
|
||||
NO_ANSWER, // Outgoing call not answered
|
||||
DIAL_FAILED // ATD command failed
|
||||
};
|
||||
|
||||
struct CallEvent {
|
||||
CallEventType type;
|
||||
char phone[SMS_PHONE_LEN]; // Caller/callee number (from +CLIP or dial)
|
||||
uint32_t duration; // Call duration in seconds (for ENDED)
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ModemManager class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ModemManager {
|
||||
public:
|
||||
void begin();
|
||||
void shutdown();
|
||||
|
||||
// --- SMS API (unchanged) ---
|
||||
bool sendSMS(const char* phone, const char* body);
|
||||
bool recvSMS(SMSIncoming& out);
|
||||
|
||||
// --- Voice Call API ---
|
||||
bool dialCall(const char* phone); // Queue outgoing call
|
||||
bool answerCall(); // Answer incoming call
|
||||
bool hangupCall(); // End active / reject incoming
|
||||
bool sendDTMF(char digit); // Send DTMF during call
|
||||
bool setCallVolume(uint8_t level); // Set volume 0-5
|
||||
bool pollCallEvent(CallEvent& out); // Poll from main loop
|
||||
|
||||
// Ringtone control — called from main loop
|
||||
void setRingtoneEnabled(bool en) { _ringtoneEnabled = en; }
|
||||
bool isRingtoneEnabled() const { return _ringtoneEnabled; }
|
||||
|
||||
// --- State queries (lock-free reads) ---
|
||||
ModemState getState() const { return _state; }
|
||||
int getSignalBars() const; // 0-5
|
||||
int getCSQ() const { return _csq; }
|
||||
bool isReady() const { return _state == ModemState::READY; }
|
||||
bool isInCall() const { return _state == ModemState::IN_CALL; }
|
||||
bool isRinging() const { return _state == ModemState::RINGING_IN; }
|
||||
bool isDialing() const { return _state == ModemState::DIALING; }
|
||||
bool isCallActive() const {
|
||||
return _state == ModemState::IN_CALL ||
|
||||
_state == ModemState::DIALING ||
|
||||
_state == ModemState::RINGING_IN;
|
||||
}
|
||||
const char* getOperator() const { return _operator; }
|
||||
const char* getCallPhone() const { return _callPhone; }
|
||||
uint32_t getCallStartTime() const { return _callStartTime; }
|
||||
|
||||
// --- Device info (populated during init) ---
|
||||
const char* getIMEI() const { return _imei; }
|
||||
const char* getIMSI() const { return _imsi; }
|
||||
const char* getAPN() const { return _apn; }
|
||||
const char* getAPNSource() const { return _apnSource; } // "auto", "network", "user", "none"
|
||||
|
||||
// --- APN configuration ---
|
||||
// Set APN manually (overrides auto-detection). Persists to SD.
|
||||
void setAPN(const char* apn);
|
||||
// Load user-configured APN from SD card. Returns true if found.
|
||||
static bool loadAPNConfig(char* apnOut, int maxLen);
|
||||
// Save user-configured APN to SD card.
|
||||
static void saveAPNConfig(const char* apn);
|
||||
|
||||
// Pause/resume polling — used by web reader to avoid Core 0 contention
|
||||
// during WiFi TLS handshakes. While paused, the task skips AT commands
|
||||
// (SMS poll, CSQ poll) but still drains URCs and handles call commands
|
||||
// so incoming calls aren't missed.
|
||||
void pausePolling() { _paused = true; }
|
||||
void resumePolling() { _paused = false; }
|
||||
bool isPaused() const { return _paused; }
|
||||
|
||||
static const char* stateToString(ModemState s);
|
||||
|
||||
// Persistent enable/disable config (SD file /sms/modem.cfg)
|
||||
static bool loadEnabledConfig();
|
||||
static void saveEnabledConfig(bool enabled);
|
||||
|
||||
private:
|
||||
volatile ModemState _state = ModemState::OFF;
|
||||
volatile int _csq = 99; // 99 = unknown
|
||||
volatile bool _paused = false; // Suppresses AT polling when true
|
||||
char _operator[24] = {0};
|
||||
|
||||
// Device identity (populated during Phase 2 init)
|
||||
char _imei[20] = {0}; // IMEI from AT+GSN
|
||||
char _imsi[20] = {0}; // IMSI from AT+CIMI (for APN lookup)
|
||||
char _apn[64] = {0}; // Active APN
|
||||
char _apnSource[8] = {0}; // "auto", "network", "user", "none"
|
||||
|
||||
// Call state (written by modem task, read by main loop)
|
||||
char _callPhone[SMS_PHONE_LEN] = {0}; // Current call number
|
||||
volatile uint32_t _callStartTime = 0; // millis() when call connected
|
||||
|
||||
// Ringtone state
|
||||
volatile bool _ringtoneEnabled = false;
|
||||
bool _ringing = false; // Shadow of RINGING_IN for tone logic
|
||||
unsigned long _nextRingTone = 0; // Next tone burst timestamp (modem task)
|
||||
bool _toneActive = false; // Is a tone currently sounding
|
||||
|
||||
TaskHandle_t _taskHandle = nullptr;
|
||||
|
||||
// SMS queues
|
||||
QueueHandle_t _sendQueue = nullptr;
|
||||
QueueHandle_t _recvQueue = nullptr;
|
||||
|
||||
// Call queues
|
||||
QueueHandle_t _callCmdQueue = nullptr; // main loop → modem task
|
||||
QueueHandle_t _callEvtQueue = nullptr; // modem task → main loop
|
||||
|
||||
SemaphoreHandle_t _uartMutex = nullptr;
|
||||
|
||||
// URC line buffer (accumulated between AT commands)
|
||||
static const int URC_BUF_SIZE = 256;
|
||||
char _urcBuf[URC_BUF_SIZE];
|
||||
int _urcPos = 0;
|
||||
|
||||
// UART AT command helpers (called only from modem task)
|
||||
bool modemPowerOn();
|
||||
bool sendAT(const char* cmd, const char* expect, uint32_t timeout_ms = 2000);
|
||||
bool waitResponse(const char* expect, uint32_t timeout_ms, char* buf = nullptr, size_t bufLen = 0);
|
||||
void pollCSQ();
|
||||
void pollIncomingSMS();
|
||||
bool doSendSMS(const char* phone, const char* body);
|
||||
|
||||
// URC (unsolicited result code) handling
|
||||
void drainURCs(); // Read available UART data, process complete lines
|
||||
void processURCLine(const char* line); // Handle a single URC line
|
||||
|
||||
// APN resolution (called from modem task during init)
|
||||
void resolveAPN(); // Auto-detect APN from network/IMSI/user config
|
||||
|
||||
// Call control (called from modem task)
|
||||
bool doDialCall(const char* phone);
|
||||
bool doAnswerCall();
|
||||
bool doHangup();
|
||||
bool doSendDTMF(char digit);
|
||||
bool doSetVolume(uint8_t level);
|
||||
void queueCallEvent(CallEventType type, const char* phone = nullptr, uint32_t duration = 0);
|
||||
void handleRingtone(); // Play tone bursts while incoming call rings
|
||||
|
||||
// FreeRTOS task
|
||||
static void taskEntry(void* param);
|
||||
void taskLoop();
|
||||
};
|
||||
|
||||
// Global singleton
|
||||
extern ModemManager modemManager;
|
||||
|
||||
#endif // MODEM_MANAGER_H
|
||||
#endif // HAS_4G_MODEM
|
||||
34
examples/companion_radio/ui-new/Radiopresets.h
Normal file
34
examples/companion_radio/ui-new/Radiopresets.h
Normal file
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Radio presets — shared between SettingsScreen (UI) and MyMesh (Serial CLI)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct RadioPreset {
|
||||
const char* name;
|
||||
float freq;
|
||||
float bw;
|
||||
uint8_t sf;
|
||||
uint8_t cr;
|
||||
uint8_t tx_power;
|
||||
};
|
||||
|
||||
static const RadioPreset RADIO_PRESETS[] = {
|
||||
{ "Australia", 915.800f, 250.0f, 10, 5, 22 },
|
||||
{ "Australia (Narrow)", 916.575f, 62.5f, 7, 8, 22 },
|
||||
{ "Australia: SA, WA", 923.125f, 62.5f, 8, 8, 22 },
|
||||
{ "Australia: QLD", 923.125f, 62.5f, 8, 5, 22 },
|
||||
{ "EU/UK (Narrow)", 869.618f, 62.5f, 8, 8, 14 },
|
||||
{ "EU/UK (Long Range)", 869.525f, 250.0f, 11, 5, 14 },
|
||||
{ "EU/UK (Medium Range)", 869.525f, 250.0f, 10, 5, 14 },
|
||||
{ "Czech Republic (Narrow)",869.432f, 62.5f, 7, 5, 14 },
|
||||
{ "EU 433 (Long Range)", 433.650f, 250.0f, 11, 5, 14 },
|
||||
{ "New Zealand", 917.375f, 250.0f, 11, 5, 22 },
|
||||
{ "New Zealand (Narrow)", 917.375f, 62.5f, 7, 5, 22 },
|
||||
{ "Portugal 433", 433.375f, 62.5f, 9, 6, 14 },
|
||||
{ "Portugal 868", 869.618f, 62.5f, 7, 6, 14 },
|
||||
{ "Switzerland", 869.618f, 62.5f, 8, 8, 14 },
|
||||
{ "USA/Canada (Recommended)",910.525f, 62.5f, 7, 5, 22 },
|
||||
{ "Vietnam", 920.250f, 250.0f, 11, 5, 22 },
|
||||
};
|
||||
#define NUM_RADIO_PRESETS (sizeof(RADIO_PRESETS) / sizeof(RADIO_PRESETS[0]))
|
||||
File diff suppressed because it is too large
Load Diff
8
examples/companion_radio/ui-new/SMSContacts.cpp
Normal file
8
examples/companion_radio/ui-new/SMSContacts.cpp
Normal file
@@ -0,0 +1,8 @@
|
||||
#ifdef HAS_4G_MODEM
|
||||
|
||||
#include "SMSContacts.h"
|
||||
|
||||
// Global singleton
|
||||
SMSContactStore smsContacts;
|
||||
|
||||
#endif // HAS_4G_MODEM
|
||||
176
examples/companion_radio/ui-new/SMSContacts.h
Normal file
176
examples/companion_radio/ui-new/SMSContacts.h
Normal file
@@ -0,0 +1,176 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// SMSContacts - Phone-to-name lookup for SMS contacts (4G variant)
|
||||
//
|
||||
// Stores contacts in /sms/contacts.txt on SD card.
|
||||
// Format: one contact per line as "phone=Display Name"
|
||||
//
|
||||
// Completely separate from mesh ContactInfo / IdentityStore.
|
||||
//
|
||||
// Guard: HAS_4G_MODEM
|
||||
// =============================================================================
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
|
||||
#ifndef SMS_CONTACTS_H
|
||||
#define SMS_CONTACTS_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
|
||||
#define SMS_CONTACT_NAME_LEN 24
|
||||
#define SMS_CONTACT_MAX 30
|
||||
#define SMS_CONTACTS_FILE "/sms/contacts.txt"
|
||||
|
||||
struct SMSContact {
|
||||
char phone[20]; // matches SMS_PHONE_LEN
|
||||
char name[SMS_CONTACT_NAME_LEN];
|
||||
bool valid;
|
||||
};
|
||||
|
||||
class SMSContactStore {
|
||||
public:
|
||||
void begin() {
|
||||
_count = 0;
|
||||
memset(_contacts, 0, sizeof(_contacts));
|
||||
load();
|
||||
}
|
||||
|
||||
// Look up a name by phone number. Returns nullptr if not found.
|
||||
const char* lookup(const char* phone) const {
|
||||
for (int i = 0; i < _count; i++) {
|
||||
if (_contacts[i].valid && strcmp(_contacts[i].phone, phone) == 0) {
|
||||
return _contacts[i].name;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Fill buf with display name if found, otherwise copy phone number.
|
||||
// Returns true if a name was found.
|
||||
bool displayName(const char* phone, char* buf, size_t bufLen) const {
|
||||
const char* name = lookup(phone);
|
||||
if (name && name[0]) {
|
||||
strncpy(buf, name, bufLen - 1);
|
||||
buf[bufLen - 1] = '\0';
|
||||
return true;
|
||||
}
|
||||
strncpy(buf, phone, bufLen - 1);
|
||||
buf[bufLen - 1] = '\0';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add or update a contact. Returns true on success.
|
||||
bool set(const char* phone, const char* name) {
|
||||
// Update existing
|
||||
for (int i = 0; i < _count; i++) {
|
||||
if (_contacts[i].valid && strcmp(_contacts[i].phone, phone) == 0) {
|
||||
strncpy(_contacts[i].name, name, SMS_CONTACT_NAME_LEN - 1);
|
||||
_contacts[i].name[SMS_CONTACT_NAME_LEN - 1] = '\0';
|
||||
save();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Add new
|
||||
if (_count >= SMS_CONTACT_MAX) return false;
|
||||
strncpy(_contacts[_count].phone, phone, sizeof(_contacts[_count].phone) - 1);
|
||||
_contacts[_count].phone[sizeof(_contacts[_count].phone) - 1] = '\0';
|
||||
strncpy(_contacts[_count].name, name, SMS_CONTACT_NAME_LEN - 1);
|
||||
_contacts[_count].name[SMS_CONTACT_NAME_LEN - 1] = '\0';
|
||||
_contacts[_count].valid = true;
|
||||
_count++;
|
||||
save();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove a contact by phone number
|
||||
bool remove(const char* phone) {
|
||||
for (int i = 0; i < _count; i++) {
|
||||
if (_contacts[i].valid && strcmp(_contacts[i].phone, phone) == 0) {
|
||||
for (int j = i; j < _count - 1; j++) {
|
||||
_contacts[j] = _contacts[j + 1];
|
||||
}
|
||||
_count--;
|
||||
memset(&_contacts[_count], 0, sizeof(SMSContact));
|
||||
save();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Accessors for list browsing
|
||||
int count() const { return _count; }
|
||||
const SMSContact& get(int index) const { return _contacts[index]; }
|
||||
|
||||
// Check if a contact exists
|
||||
bool exists(const char* phone) const { return lookup(phone) != nullptr; }
|
||||
|
||||
private:
|
||||
SMSContact _contacts[SMS_CONTACT_MAX];
|
||||
int _count = 0;
|
||||
|
||||
void load() {
|
||||
File f = SD.open(SMS_CONTACTS_FILE, FILE_READ);
|
||||
if (!f) {
|
||||
Serial.println("[SMSContacts] No contacts file, starting fresh");
|
||||
return;
|
||||
}
|
||||
|
||||
char line[64];
|
||||
while (f.available() && _count < SMS_CONTACT_MAX) {
|
||||
int pos = 0;
|
||||
while (f.available() && pos < (int)sizeof(line) - 1) {
|
||||
char c = f.read();
|
||||
if (c == '\n' || c == '\r') break;
|
||||
line[pos++] = c;
|
||||
}
|
||||
line[pos] = '\0';
|
||||
if (pos == 0) continue;
|
||||
// Consume trailing CR/LF
|
||||
while (f.available()) {
|
||||
int pk = f.peek();
|
||||
if (pk == '\n' || pk == '\r') { f.read(); continue; }
|
||||
break;
|
||||
}
|
||||
|
||||
// Parse "phone=name"
|
||||
char* eq = strchr(line, '=');
|
||||
if (!eq) continue;
|
||||
*eq = '\0';
|
||||
const char* phone = line;
|
||||
const char* name = eq + 1;
|
||||
if (strlen(phone) == 0 || strlen(name) == 0) continue;
|
||||
|
||||
strncpy(_contacts[_count].phone, phone, sizeof(_contacts[_count].phone) - 1);
|
||||
strncpy(_contacts[_count].name, name, SMS_CONTACT_NAME_LEN - 1);
|
||||
_contacts[_count].valid = true;
|
||||
_count++;
|
||||
}
|
||||
f.close();
|
||||
Serial.printf("[SMSContacts] Loaded %d contacts\n", _count);
|
||||
}
|
||||
|
||||
void save() {
|
||||
if (!SD.exists("/sms")) SD.mkdir("/sms");
|
||||
File f = SD.open(SMS_CONTACTS_FILE, FILE_WRITE);
|
||||
if (!f) {
|
||||
Serial.println("[SMSContacts] Failed to write contacts file");
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < _count; i++) {
|
||||
if (!_contacts[i].valid) continue;
|
||||
f.print(_contacts[i].phone);
|
||||
f.print('=');
|
||||
f.println(_contacts[i].name);
|
||||
}
|
||||
f.close();
|
||||
}
|
||||
};
|
||||
|
||||
// Global singleton
|
||||
extern SMSContactStore smsContacts;
|
||||
|
||||
#endif // SMS_CONTACTS_H
|
||||
#endif // HAS_4G_MODEM
|
||||
1643
examples/companion_radio/ui-new/SMSScreen.h
Normal file
1643
examples/companion_radio/ui-new/SMSScreen.h
Normal file
File diff suppressed because it is too large
Load Diff
196
examples/companion_radio/ui-new/SMSStore.cpp
Normal file
196
examples/companion_radio/ui-new/SMSStore.cpp
Normal file
@@ -0,0 +1,196 @@
|
||||
#ifdef HAS_4G_MODEM
|
||||
|
||||
#include "SMSStore.h"
|
||||
#include <Mesh.h> // For MESH_DEBUG_PRINTLN
|
||||
#include "target.h" // For SDCARD_CS macro
|
||||
|
||||
// Global singleton
|
||||
SMSStore smsStore;
|
||||
|
||||
void SMSStore::begin() {
|
||||
// Ensure SMS directory exists
|
||||
if (!SD.exists(SMS_DIR)) {
|
||||
SD.mkdir(SMS_DIR);
|
||||
MESH_DEBUG_PRINTLN("[SMSStore] created %s", SMS_DIR);
|
||||
}
|
||||
_ready = true;
|
||||
MESH_DEBUG_PRINTLN("[SMSStore] ready");
|
||||
}
|
||||
|
||||
void SMSStore::phoneToFilename(const char* phone, char* out, size_t outLen) {
|
||||
// Convert phone number to safe filename: strip non-alphanumeric, prefix with dir
|
||||
// e.g. "+1234567890" -> "/sms/p1234567890.sms"
|
||||
char safe[SMS_PHONE_LEN];
|
||||
int j = 0;
|
||||
for (int i = 0; phone[i] && j < SMS_PHONE_LEN - 1; i++) {
|
||||
char c = phone[i];
|
||||
if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
|
||||
safe[j++] = c;
|
||||
}
|
||||
}
|
||||
safe[j] = '\0';
|
||||
snprintf(out, outLen, "%s/p%s.sms", SMS_DIR, safe);
|
||||
}
|
||||
|
||||
bool SMSStore::saveMessage(const char* phone, const char* body, bool isSent, uint32_t timestamp) {
|
||||
if (!_ready) return false;
|
||||
|
||||
char filepath[64];
|
||||
phoneToFilename(phone, filepath, sizeof(filepath));
|
||||
|
||||
// Build record
|
||||
SMSRecord rec;
|
||||
memset(&rec, 0, sizeof(rec));
|
||||
rec.timestamp = timestamp;
|
||||
rec.isSent = isSent ? 1 : 0;
|
||||
rec.bodyLen = strlen(body);
|
||||
if (rec.bodyLen >= SMS_BODY_LEN) rec.bodyLen = SMS_BODY_LEN - 1;
|
||||
strncpy(rec.phone, phone, SMS_PHONE_LEN - 1);
|
||||
strncpy(rec.body, body, SMS_BODY_LEN - 1);
|
||||
|
||||
// Append to file
|
||||
File f = SD.open(filepath, FILE_APPEND);
|
||||
if (!f) {
|
||||
// Try creating
|
||||
f = SD.open(filepath, FILE_WRITE);
|
||||
if (!f) {
|
||||
MESH_DEBUG_PRINTLN("[SMSStore] can't open %s", filepath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
size_t written = f.write((uint8_t*)&rec, sizeof(rec));
|
||||
f.close();
|
||||
|
||||
// Release SD CS
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
return written == sizeof(rec);
|
||||
}
|
||||
|
||||
int SMSStore::loadConversations(SMSConversation* out, int maxCount) {
|
||||
if (!_ready) return 0;
|
||||
|
||||
File dir = SD.open(SMS_DIR);
|
||||
if (!dir || !dir.isDirectory()) return 0;
|
||||
|
||||
int count = 0;
|
||||
File entry;
|
||||
while ((entry = dir.openNextFile()) && count < maxCount) {
|
||||
const char* name = entry.name();
|
||||
// Only process .sms files
|
||||
if (!strstr(name, ".sms")) { entry.close(); continue; }
|
||||
|
||||
size_t fileSize = entry.size();
|
||||
if (fileSize < sizeof(SMSRecord)) { entry.close(); continue; }
|
||||
|
||||
int numRecords = fileSize / sizeof(SMSRecord);
|
||||
|
||||
// Read the last record for preview
|
||||
SMSRecord lastRec;
|
||||
entry.seek(fileSize - sizeof(SMSRecord));
|
||||
if (entry.read((uint8_t*)&lastRec, sizeof(SMSRecord)) != sizeof(SMSRecord)) {
|
||||
entry.close();
|
||||
continue;
|
||||
}
|
||||
|
||||
SMSConversation& conv = out[count];
|
||||
memset(&conv, 0, sizeof(SMSConversation));
|
||||
strncpy(conv.phone, lastRec.phone, SMS_PHONE_LEN - 1);
|
||||
strncpy(conv.preview, lastRec.body, 39);
|
||||
conv.preview[39] = '\0';
|
||||
conv.lastTimestamp = lastRec.timestamp;
|
||||
conv.messageCount = numRecords;
|
||||
conv.unreadCount = 0; // TODO: track read state
|
||||
conv.valid = true;
|
||||
|
||||
count++;
|
||||
entry.close();
|
||||
}
|
||||
dir.close();
|
||||
|
||||
// Release SD CS
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
// Sort by most recent (simple bubble sort, small N)
|
||||
for (int i = 0; i < count - 1; i++) {
|
||||
for (int j = 0; j < count - 1 - i; j++) {
|
||||
if (out[j].lastTimestamp < out[j + 1].lastTimestamp) {
|
||||
SMSConversation tmp = out[j];
|
||||
out[j] = out[j + 1];
|
||||
out[j + 1] = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
int SMSStore::loadMessages(const char* phone, SMSMessage* out, int maxCount) {
|
||||
if (!_ready) return 0;
|
||||
|
||||
char filepath[64];
|
||||
phoneToFilename(phone, filepath, sizeof(filepath));
|
||||
|
||||
File f = SD.open(filepath, FILE_READ);
|
||||
if (!f) return 0;
|
||||
|
||||
size_t fileSize = f.size();
|
||||
int numRecords = fileSize / sizeof(SMSRecord);
|
||||
|
||||
// Load from end of file (most recent N messages), in chronological order
|
||||
int startIdx = numRecords > maxCount ? numRecords - maxCount : 0;
|
||||
|
||||
// Read chronologically (oldest first) for chat-style display
|
||||
SMSRecord rec;
|
||||
int outIdx = 0;
|
||||
for (int i = startIdx; i < numRecords && outIdx < maxCount; i++) {
|
||||
f.seek(i * sizeof(SMSRecord));
|
||||
if (f.read((uint8_t*)&rec, sizeof(SMSRecord)) != sizeof(SMSRecord)) continue;
|
||||
|
||||
out[outIdx].timestamp = rec.timestamp;
|
||||
out[outIdx].isSent = rec.isSent != 0;
|
||||
out[outIdx].valid = true;
|
||||
strncpy(out[outIdx].phone, rec.phone, SMS_PHONE_LEN - 1);
|
||||
strncpy(out[outIdx].body, rec.body, SMS_BODY_LEN - 1);
|
||||
outIdx++;
|
||||
}
|
||||
|
||||
f.close();
|
||||
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
return outIdx;
|
||||
}
|
||||
|
||||
bool SMSStore::deleteConversation(const char* phone) {
|
||||
if (!_ready) return false;
|
||||
|
||||
char filepath[64];
|
||||
phoneToFilename(phone, filepath, sizeof(filepath));
|
||||
|
||||
bool ok = SD.remove(filepath);
|
||||
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
int SMSStore::getMessageCount(const char* phone) {
|
||||
if (!_ready) return 0;
|
||||
|
||||
char filepath[64];
|
||||
phoneToFilename(phone, filepath, sizeof(filepath));
|
||||
|
||||
File f = SD.open(filepath, FILE_READ);
|
||||
if (!f) return 0;
|
||||
|
||||
int count = f.size() / sizeof(SMSRecord);
|
||||
f.close();
|
||||
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
#endif // HAS_4G_MODEM
|
||||
87
examples/companion_radio/ui-new/SMSStore.h
Normal file
87
examples/companion_radio/ui-new/SMSStore.h
Normal file
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// SMSStore - SD card backed SMS message storage
|
||||
//
|
||||
// Stores sent and received messages in /sms/ on the SD card.
|
||||
// Each conversation is a separate file named by phone number (sanitised).
|
||||
// Messages are appended as fixed-size records for simple random access.
|
||||
//
|
||||
// Guard: HAS_4G_MODEM
|
||||
// =============================================================================
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
|
||||
#ifndef SMS_STORE_H
|
||||
#define SMS_STORE_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
|
||||
#define SMS_PHONE_LEN 20
|
||||
#define SMS_BODY_LEN 161
|
||||
#define SMS_MAX_CONVERSATIONS 20
|
||||
#define SMS_DIR "/sms"
|
||||
|
||||
// Fixed-size on-disk record (256 bytes, easy alignment)
|
||||
struct SMSRecord {
|
||||
uint32_t timestamp; // epoch seconds
|
||||
uint8_t isSent; // 1=sent, 0=received
|
||||
uint8_t reserved[2];
|
||||
uint8_t bodyLen; // actual length of body
|
||||
char phone[SMS_PHONE_LEN]; // 20
|
||||
char body[SMS_BODY_LEN]; // 161
|
||||
uint8_t padding[256 - 4 - 3 - 1 - SMS_PHONE_LEN - SMS_BODY_LEN];
|
||||
};
|
||||
|
||||
// In-memory message for UI
|
||||
struct SMSMessage {
|
||||
uint32_t timestamp;
|
||||
bool isSent;
|
||||
bool valid;
|
||||
char phone[SMS_PHONE_LEN];
|
||||
char body[SMS_BODY_LEN];
|
||||
};
|
||||
|
||||
// Conversation summary for inbox view
|
||||
struct SMSConversation {
|
||||
char phone[SMS_PHONE_LEN];
|
||||
char preview[40]; // last message preview
|
||||
uint32_t lastTimestamp;
|
||||
int messageCount;
|
||||
int unreadCount;
|
||||
bool valid;
|
||||
};
|
||||
|
||||
class SMSStore {
|
||||
public:
|
||||
void begin();
|
||||
bool isReady() const { return _ready; }
|
||||
|
||||
// Save a message (sent or received)
|
||||
bool saveMessage(const char* phone, const char* body, bool isSent, uint32_t timestamp);
|
||||
|
||||
// Load conversation list (sorted by most recent)
|
||||
int loadConversations(SMSConversation* out, int maxCount);
|
||||
|
||||
// Load messages for a specific phone number (chronological, oldest first)
|
||||
int loadMessages(const char* phone, SMSMessage* out, int maxCount);
|
||||
|
||||
// Delete all messages for a phone number
|
||||
bool deleteConversation(const char* phone);
|
||||
|
||||
// Get total message count for a phone number
|
||||
int getMessageCount(const char* phone);
|
||||
|
||||
private:
|
||||
bool _ready = false;
|
||||
|
||||
// Convert phone number to safe filename
|
||||
void phoneToFilename(const char* phone, char* out, size_t outLen);
|
||||
};
|
||||
|
||||
// Global singleton
|
||||
extern SMSStore smsStore;
|
||||
|
||||
#endif // SMS_STORE_H
|
||||
#endif // HAS_4G_MODEM
|
||||
File diff suppressed because it is too large
Load Diff
@@ -182,8 +182,10 @@ private:
|
||||
|
||||
// File list state
|
||||
std::vector<String> _fileList;
|
||||
std::vector<String> _dirList; // Subdirectories at current path
|
||||
std::vector<FileCache> _fileCache;
|
||||
int _selectedFile;
|
||||
String _currentPath; // Current browsed directory
|
||||
|
||||
// Reading state
|
||||
File _file;
|
||||
@@ -391,8 +393,8 @@ private:
|
||||
idxFile.read(&fullyFlag, 1);
|
||||
idxFile.read((uint8_t*)&lastRead, 4);
|
||||
|
||||
// Verify file hasn't changed - try BOOKS_FOLDER first, then epub cache
|
||||
String fullPath = String(BOOKS_FOLDER) + "/" + filename;
|
||||
// Verify file hasn't changed - try current path first, then epub cache
|
||||
String fullPath = _currentPath + "/" + filename;
|
||||
File txtFile = SD.open(fullPath.c_str(), FILE_READ);
|
||||
if (!txtFile) {
|
||||
// Fallback: check epub cache directory
|
||||
@@ -482,33 +484,94 @@ private:
|
||||
|
||||
// ---- File Scanning ----
|
||||
|
||||
// ---- Folder Navigation Helpers ----
|
||||
|
||||
bool isAtBooksRoot() const {
|
||||
return _currentPath == String(BOOKS_FOLDER);
|
||||
}
|
||||
|
||||
// Number of non-file entries at the start of the visual list
|
||||
int dirEntryCount() const {
|
||||
int count = _dirList.size();
|
||||
if (!isAtBooksRoot()) count++; // ".." entry
|
||||
return count;
|
||||
}
|
||||
|
||||
// Total items in the visual list (parent + dirs + files)
|
||||
int totalListItems() const {
|
||||
return dirEntryCount() + (int)_fileList.size();
|
||||
}
|
||||
|
||||
// What type of entry is at visual list index idx?
|
||||
// Returns: 0 = ".." parent, 1 = directory, 2 = file
|
||||
int itemTypeAt(int idx) const {
|
||||
bool hasParent = !isAtBooksRoot();
|
||||
if (hasParent && idx == 0) return 0; // ".."
|
||||
int dirStart = hasParent ? 1 : 0;
|
||||
if (idx < dirStart + (int)_dirList.size()) return 1; // directory
|
||||
return 2; // file
|
||||
}
|
||||
|
||||
// Get directory name for visual index (only valid when itemTypeAt == 1)
|
||||
const String& dirNameAt(int idx) const {
|
||||
int dirStart = isAtBooksRoot() ? 0 : 1;
|
||||
return _dirList[idx - dirStart];
|
||||
}
|
||||
|
||||
// Get file list index for visual index (only valid when itemTypeAt == 2)
|
||||
int fileIndexAt(int idx) const {
|
||||
return idx - dirEntryCount();
|
||||
}
|
||||
|
||||
void navigateToParent() {
|
||||
int lastSlash = _currentPath.lastIndexOf('/');
|
||||
if (lastSlash > 0) {
|
||||
_currentPath = _currentPath.substring(0, lastSlash);
|
||||
} else {
|
||||
_currentPath = BOOKS_FOLDER;
|
||||
}
|
||||
}
|
||||
|
||||
void navigateToChild(const String& dirName) {
|
||||
_currentPath = _currentPath + "/" + dirName;
|
||||
}
|
||||
|
||||
// ---- File Scanning ----
|
||||
|
||||
void scanFiles() {
|
||||
_fileList.clear();
|
||||
_dirList.clear();
|
||||
if (!SD.exists(BOOKS_FOLDER)) {
|
||||
SD.mkdir(BOOKS_FOLDER);
|
||||
Serial.printf("TextReader: Created %s\n", BOOKS_FOLDER);
|
||||
}
|
||||
|
||||
File root = SD.open(BOOKS_FOLDER);
|
||||
File root = SD.open(_currentPath.c_str());
|
||||
if (!root || !root.isDirectory()) return;
|
||||
|
||||
File f = root.openNextFile();
|
||||
while (f && _fileList.size() < READER_MAX_FILES) {
|
||||
if (!f.isDirectory()) {
|
||||
String name = String(f.name());
|
||||
int slash = name.lastIndexOf('/');
|
||||
if (slash >= 0) name = name.substring(slash + 1);
|
||||
while (f && (_fileList.size() + _dirList.size()) < READER_MAX_FILES) {
|
||||
String name = String(f.name());
|
||||
int slash = name.lastIndexOf('/');
|
||||
if (slash >= 0) name = name.substring(slash + 1);
|
||||
|
||||
if (!name.startsWith(".") &&
|
||||
(name.endsWith(".txt") || name.endsWith(".TXT") ||
|
||||
name.endsWith(".epub") || name.endsWith(".EPUB"))) {
|
||||
_fileList.push_back(name);
|
||||
}
|
||||
// Skip hidden files/dirs
|
||||
if (name.startsWith(".")) {
|
||||
f = root.openNextFile();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (f.isDirectory()) {
|
||||
_dirList.push_back(name);
|
||||
} else if (name.endsWith(".txt") || name.endsWith(".TXT") ||
|
||||
name.endsWith(".epub") || name.endsWith(".EPUB")) {
|
||||
_fileList.push_back(name);
|
||||
}
|
||||
f = root.openNextFile();
|
||||
}
|
||||
root.close();
|
||||
Serial.printf("TextReader: Found %d files\n", _fileList.size());
|
||||
Serial.printf("TextReader: %s — %d dirs, %d files\n",
|
||||
_currentPath.c_str(), (int)_dirList.size(), (int)_fileList.size());
|
||||
}
|
||||
|
||||
// ---- Book Open/Close ----
|
||||
@@ -518,7 +581,7 @@ private:
|
||||
|
||||
// ---- EPUB auto-conversion ----
|
||||
String actualFilename = filename;
|
||||
String actualFullPath = String(BOOKS_FOLDER) + "/" + filename;
|
||||
String actualFullPath = _currentPath + "/" + filename;
|
||||
bool isEpub = filename.endsWith(".epub") || filename.endsWith(".EPUB");
|
||||
|
||||
if (isEpub) {
|
||||
@@ -755,15 +818,26 @@ private:
|
||||
display.setCursor(0, 0);
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.print("Text Reader");
|
||||
if (isAtBooksRoot()) {
|
||||
display.print("Text Reader");
|
||||
} else {
|
||||
// Show current subfolder name
|
||||
int lastSlash = _currentPath.lastIndexOf('/');
|
||||
String folderName = (lastSlash >= 0) ? _currentPath.substring(lastSlash + 1) : _currentPath;
|
||||
char hdrBuf[20];
|
||||
strncpy(hdrBuf, folderName.c_str(), 17);
|
||||
hdrBuf[17] = '\0';
|
||||
display.print(hdrBuf);
|
||||
}
|
||||
|
||||
sprintf(tmp, "[%d]", (int)_fileList.size());
|
||||
int totalItems = totalListItems();
|
||||
sprintf(tmp, "[%d]", totalItems);
|
||||
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
|
||||
display.print(tmp);
|
||||
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
if (_fileList.size() == 0) {
|
||||
if (totalItems == 0) {
|
||||
display.setCursor(0, 18);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("No files found");
|
||||
@@ -780,8 +854,8 @@ private:
|
||||
if (maxVisible > 15) maxVisible = 15;
|
||||
|
||||
int startIdx = max(0, min(_selectedFile - maxVisible / 2,
|
||||
(int)_fileList.size() - maxVisible));
|
||||
int endIdx = min((int)_fileList.size(), startIdx + maxVisible);
|
||||
totalItems - maxVisible));
|
||||
int endIdx = min(totalItems, startIdx + maxVisible);
|
||||
|
||||
int y = startY;
|
||||
for (int i = startIdx; i < endIdx; i++) {
|
||||
@@ -800,27 +874,41 @@ private:
|
||||
// Set cursor AFTER fillRect so text draws on top of highlight
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Build display string: "> filename.txt *" (asterisk if has bookmark)
|
||||
int type = itemTypeAt(i);
|
||||
String line = selected ? "> " : " ";
|
||||
String name = _fileList[i];
|
||||
|
||||
// Check for resume indicator
|
||||
String suffix = "";
|
||||
for (int j = 0; j < (int)_fileCache.size(); j++) {
|
||||
if (_fileCache[j].filename == name && _fileCache[j].lastReadPage > 0) {
|
||||
suffix = " *";
|
||||
break;
|
||||
if (type == 0) {
|
||||
// ".." parent directory
|
||||
line += ".. (up)";
|
||||
} else if (type == 1) {
|
||||
// Subdirectory
|
||||
line += "/" + dirNameAt(i);
|
||||
// Truncate if needed
|
||||
if ((int)line.length() > _charsPerLine) {
|
||||
line = line.substring(0, _charsPerLine - 3) + "...";
|
||||
}
|
||||
} else {
|
||||
// File
|
||||
int fi = fileIndexAt(i);
|
||||
String name = _fileList[fi];
|
||||
|
||||
// Check for resume indicator
|
||||
String suffix = "";
|
||||
if (fi < (int)_fileCache.size()) {
|
||||
if (_fileCache[fi].filename == name && _fileCache[fi].lastReadPage > 0) {
|
||||
suffix = " *";
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate if needed
|
||||
int maxLen = _charsPerLine - 4 - suffix.length();
|
||||
if ((int)name.length() > maxLen) {
|
||||
name = name.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
line += name + suffix;
|
||||
}
|
||||
|
||||
// Truncate if needed
|
||||
int maxLen = _charsPerLine - 4 - suffix.length();
|
||||
if ((int)name.length() > maxLen) {
|
||||
name = name.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
line += name + suffix;
|
||||
display.print(line.c_str());
|
||||
|
||||
y += listLineH;
|
||||
}
|
||||
display.setTextSize(1); // Restore
|
||||
@@ -928,7 +1016,8 @@ public:
|
||||
_bootIndexed(false), _display(nullptr),
|
||||
_charsPerLine(38), _linesPerPage(22), _lineHeight(5),
|
||||
_headerHeight(14), _footerHeight(14),
|
||||
_selectedFile(0), _fileOpen(false), _currentPage(0), _totalPages(0),
|
||||
_selectedFile(0), _currentPath(BOOKS_FOLDER),
|
||||
_fileOpen(false), _currentPage(0), _totalPages(0),
|
||||
_pageBufLen(0), _contentDirty(true) {
|
||||
}
|
||||
|
||||
@@ -1068,8 +1157,8 @@ public:
|
||||
indexProgress++;
|
||||
drawBootSplash(indexProgress, needsIndexCount, _fileList[i]);
|
||||
|
||||
// Try BOOKS_FOLDER first, then epub cache fallback
|
||||
String fullPath = String(BOOKS_FOLDER) + "/" + _fileList[i];
|
||||
// Try current path first, then epub cache fallback
|
||||
String fullPath = _currentPath + "/" + _fileList[i];
|
||||
File file = SD.open(fullPath.c_str(), FILE_READ);
|
||||
if (!file) {
|
||||
String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
|
||||
@@ -1166,6 +1255,8 @@ public:
|
||||
}
|
||||
|
||||
bool handleFileListInput(char c) {
|
||||
int total = totalListItems();
|
||||
|
||||
// W - scroll up
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_selectedFile > 0) {
|
||||
@@ -1177,18 +1268,36 @@ public:
|
||||
|
||||
// S - scroll down
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
if (_selectedFile < (int)_fileList.size() - 1) {
|
||||
if (_selectedFile < total - 1) {
|
||||
_selectedFile++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter - open selected file
|
||||
// Enter - open selected item (directory or file)
|
||||
if (c == '\r' || c == 13) {
|
||||
if (_fileList.size() > 0 && _selectedFile < (int)_fileList.size()) {
|
||||
openBook(_fileList[_selectedFile]);
|
||||
if (total == 0 || _selectedFile >= total) return false;
|
||||
|
||||
int type = itemTypeAt(_selectedFile);
|
||||
|
||||
if (type == 0) {
|
||||
// ".." — navigate to parent
|
||||
navigateToParent();
|
||||
rescanAndIndex();
|
||||
return true;
|
||||
} else if (type == 1) {
|
||||
// Subdirectory — navigate into it
|
||||
navigateToChild(dirNameAt(_selectedFile));
|
||||
rescanAndIndex();
|
||||
return true;
|
||||
} else {
|
||||
// File — open it
|
||||
int fi = fileIndexAt(_selectedFile);
|
||||
if (fi >= 0 && fi < (int)_fileList.size()) {
|
||||
openBook(_fileList[fi]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1196,6 +1305,53 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rescan current directory and re-index its files.
|
||||
// Called when navigating into or out of a subfolder.
|
||||
void rescanAndIndex() {
|
||||
scanFiles();
|
||||
_selectedFile = 0;
|
||||
|
||||
// Rebuild file cache for the new directory's files
|
||||
_fileCache.clear();
|
||||
_fileCache.resize(_fileList.size());
|
||||
|
||||
for (int i = 0; i < (int)_fileList.size(); i++) {
|
||||
if (!loadIndex(_fileList[i], _fileCache[i])) {
|
||||
// Not cached — skip EPUB auto-indexing here (it happens on open)
|
||||
// For .txt files, index now
|
||||
if (!(_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB"))) {
|
||||
String fullPath = _currentPath + "/" + _fileList[i];
|
||||
File file = SD.open(fullPath.c_str(), FILE_READ);
|
||||
if (!file) {
|
||||
// Try epub cache fallback
|
||||
String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
|
||||
file = SD.open(cacheFallback.c_str(), FILE_READ);
|
||||
}
|
||||
if (file) {
|
||||
FileCache& cache = _fileCache[i];
|
||||
cache.filename = _fileList[i];
|
||||
cache.fileSize = file.size();
|
||||
cache.fullyIndexed = false;
|
||||
cache.lastReadPage = 0;
|
||||
cache.pagePositions.clear();
|
||||
cache.pagePositions.push_back(0);
|
||||
indexPagesWordWrap(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
PREINDEX_PAGES - 1);
|
||||
cache.fullyIndexed = !file.available();
|
||||
file.close();
|
||||
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
|
||||
cache.fullyIndexed, 0);
|
||||
}
|
||||
} else {
|
||||
_fileCache[i].filename = "";
|
||||
}
|
||||
}
|
||||
yield(); // Feed WDT between files
|
||||
}
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
}
|
||||
|
||||
bool handleReadingInput(char c) {
|
||||
// W/A - previous page
|
||||
if (c == 'w' || c == 'W' || c == 'a' || c == 'A' || c == 0xF2) {
|
||||
|
||||
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
|
||||
@@ -2,9 +2,11 @@
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include "../MyMesh.h"
|
||||
#include "NotesScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
#include "DiscoveryScreen.h"
|
||||
#include "MapScreen.h"
|
||||
#include "target.h"
|
||||
#include "GPSDutyCycle.h"
|
||||
#ifdef WIFI_SSID
|
||||
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
#include <WiFi.h>
|
||||
#endif
|
||||
|
||||
@@ -36,11 +38,18 @@
|
||||
#include "ContactsScreen.h"
|
||||
#include "TextReaderScreen.h"
|
||||
#include "SettingsScreen.h"
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
#include "AudiobookPlayerScreen.h"
|
||||
#endif
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "SMSScreen.h"
|
||||
#include "ModemManager.h"
|
||||
#endif
|
||||
|
||||
class SplashScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
unsigned long dismiss_after;
|
||||
char _version_info[12];
|
||||
char _version_info[24];
|
||||
|
||||
public:
|
||||
SplashScreen(UITask* task) : _task(task) {
|
||||
@@ -87,13 +96,20 @@ class HomeScreen : public UIScreen {
|
||||
FIRST,
|
||||
RECENT,
|
||||
RADIO,
|
||||
#ifdef BLE_PIN_CODE
|
||||
BLUETOOTH,
|
||||
#elif defined(MECK_WIFI_COMPANION)
|
||||
WIFI_STATUS,
|
||||
#endif
|
||||
ADVERT,
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
GPS,
|
||||
#endif
|
||||
#if UI_SENSORS_PAGE == 1
|
||||
SENSORS,
|
||||
#endif
|
||||
#if HAS_BQ27220
|
||||
BATTERY,
|
||||
#endif
|
||||
SHUTDOWN,
|
||||
Count // keep as last
|
||||
@@ -105,12 +121,13 @@ class HomeScreen : public UIScreen {
|
||||
NodePrefs* _node_prefs;
|
||||
uint8_t _page;
|
||||
bool _shutdown_init;
|
||||
unsigned long _shutdown_at; // earliest time to proceed with shutdown (after e-ink refresh)
|
||||
bool _editing_utc;
|
||||
int8_t _saved_utc_offset; // for cancel/undo
|
||||
AdvertPath recent[UI_RECENT_LIST_SIZE];
|
||||
|
||||
|
||||
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
|
||||
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int* outIconX = nullptr) {
|
||||
// Use voltage-based estimation to match BLE app readings
|
||||
uint8_t batteryPercentage = 0;
|
||||
if (batteryMilliVolts > 0) {
|
||||
@@ -139,6 +156,8 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
|
||||
int iconX = display.width() - totalWidth;
|
||||
int iconY = 0; // vertically align with node name text
|
||||
|
||||
if (outIconX) *outIconX = iconX;
|
||||
|
||||
// battery outline
|
||||
display.drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||
|
||||
@@ -158,6 +177,24 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
|
||||
display.setTextSize(1); // restore default text size
|
||||
}
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
// ---- Audio background playback indicator ----
|
||||
// Shows a small play symbol to the left of the battery icon when an
|
||||
// audiobook is actively playing in the background.
|
||||
// Uses the font renderer (not manual pixel drawing) since it handles
|
||||
// the e-ink coordinate scaling correctly.
|
||||
void renderAudioIndicator(DisplayDriver& display, int batteryLeftX) {
|
||||
if (!_task->isAudioPlayingInBackground()) return;
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(0); // tiny font (same as clock & battery %)
|
||||
int x = batteryLeftX - display.getTextWidth(">>") - 2;
|
||||
display.setCursor(x, -3); // align vertically with battery text
|
||||
display.print(">>");
|
||||
display.setTextSize(1); // restore
|
||||
}
|
||||
#endif
|
||||
|
||||
CayenneLPP sensors_lpp;
|
||||
int sensors_nb = 0;
|
||||
bool sensors_scroll = false;
|
||||
@@ -188,7 +225,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() {
|
||||
@@ -199,23 +236,31 @@ public:
|
||||
}
|
||||
|
||||
void poll() override {
|
||||
if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released
|
||||
if (_shutdown_init && millis() >= _shutdown_at && !_task->isButtonPressed()) {
|
||||
_task->shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
char tmp[80];
|
||||
// node name
|
||||
display.setTextSize(1);
|
||||
// node name (tinyfont to avoid overlapping clock)
|
||||
display.setTextSize(0);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
char filtered_name[sizeof(_node_prefs->node_name)];
|
||||
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
|
||||
display.setCursor(0, 0);
|
||||
display.setCursor(0, -3);
|
||||
display.print(filtered_name);
|
||||
|
||||
// battery voltage
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
int battLeftX = display.width(); // default if battery doesn't render
|
||||
renderBatteryIndicator(display, _task->getBattMilliVolts(), &battLeftX);
|
||||
|
||||
// audio background playback indicator (>> icon next to battery)
|
||||
renderAudioIndicator(display, battLeftX);
|
||||
#else
|
||||
renderBatteryIndicator(display, _task->getBattMilliVolts());
|
||||
#endif
|
||||
|
||||
// centered clock (tinyfont) - only show when time is valid
|
||||
{
|
||||
@@ -252,28 +297,70 @@ public:
|
||||
}
|
||||
|
||||
if (_page == HomePage::FIRST) {
|
||||
int y = 20;
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "MSG: %d", _task->getMsgCount());
|
||||
display.drawTextCentered(display.width() / 2, 20, tmp);
|
||||
sprintf(tmp, "MSG: %d", _task->getUnreadMsgCount());
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 18;
|
||||
|
||||
#ifdef WIFI_SSID
|
||||
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
IPAddress ip = WiFi.localIP();
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 54, tmp);
|
||||
if (ip != IPAddress(0,0,0,0)) {
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d:%d", ip[0], ip[1], ip[2], ip[3], TCP_PORT);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 12;
|
||||
}
|
||||
#endif
|
||||
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 43, "< Connected >");
|
||||
|
||||
} else if (the_mesh.getBLEPin() != 0) { // BT pin
|
||||
display.drawTextCentered(display.width() / 2, y, "< Connected >");
|
||||
y += 12;
|
||||
#ifdef BLE_PIN_CODE
|
||||
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
|
||||
display.drawTextCentered(display.width() / 2, 43, tmp);
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 18;
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
// Menu shortcuts - tinyfont monospaced grid
|
||||
y += 6;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(0); // tinyfont 6x8 monospaced
|
||||
display.drawTextCentered(display.width() / 2, y, "Press:");
|
||||
y += 12;
|
||||
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts ");
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps ");
|
||||
y += 10;
|
||||
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[T] Phone [B] Browser ");
|
||||
#elif defined(HAS_4G_MODEM)
|
||||
display.drawTextCentered(display.width() / 2, y, "[T] Phone ");
|
||||
#elif defined(MECK_AUDIO_VARIANT) && defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [B] Browser ");
|
||||
#elif defined(MECK_AUDIO_VARIANT)
|
||||
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks ");
|
||||
#elif defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
|
||||
#else
|
||||
y -= 10; // reclaim the row for standalone
|
||||
#endif
|
||||
y += 14;
|
||||
|
||||
// Nav hint
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(display.width() / 2, y, "Press A/D to cycle home views");
|
||||
display.setTextSize(1); // restore
|
||||
} else if (_page == HomePage::RECENT) {
|
||||
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -318,47 +405,80 @@ public:
|
||||
display.setCursor(0, 53);
|
||||
sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
|
||||
display.print(tmp);
|
||||
#ifdef BLE_PIN_CODE
|
||||
} else if (_page == HomePage::BLUETOOTH) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawXbm((display.width() - 32) / 2, 18,
|
||||
_task->isSerialEnabled() ? bluetooth_on : bluetooth_off,
|
||||
32, 32);
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 53, "< Connected >");
|
||||
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
|
||||
display.drawTextCentered(display.width() / 2, 53, tmp);
|
||||
}
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL);
|
||||
display.drawTextCentered(display.width() / 2, 72, "toggle: " PRESS_LABEL);
|
||||
#endif
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
} else if (_page == HomePage::WIFI_STATUS) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 18, "WiFi Companion");
|
||||
|
||||
int wy = 36;
|
||||
display.setTextSize(0);
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
snprintf(tmp, sizeof(tmp), "SSID: %s", WiFi.SSID().c_str());
|
||||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||||
wy += 10;
|
||||
IPAddress ip = WiFi.localIP();
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||||
wy += 10;
|
||||
snprintf(tmp, sizeof(tmp), "Port: %d", TCP_PORT);
|
||||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||||
wy += 12;
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, wy, "< App Connected >");
|
||||
} else {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, wy, "Waiting for app...");
|
||||
}
|
||||
} else {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.drawTextCentered(display.width() / 2, wy, "Not connected");
|
||||
wy += 12;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawTextCentered(display.width() / 2, wy, "Configure in Settings");
|
||||
}
|
||||
display.setTextSize(1);
|
||||
#endif
|
||||
} else if (_page == HomePage::ADVERT) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL);
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
} else if (_page == HomePage::GPS) {
|
||||
extern GPSDutyCycle gpsDuty;
|
||||
extern GPSStreamCounter gpsStream;
|
||||
LocationProvider* nmea = sensors.getLocationProvider();
|
||||
char buf[50];
|
||||
int y = 18;
|
||||
|
||||
// GPS state line with duty cycle info
|
||||
// GPS state line
|
||||
if (!_node_prefs->gps_enabled) {
|
||||
strcpy(buf, "gps off");
|
||||
} else {
|
||||
switch (gpsDuty.getState()) {
|
||||
case GPSDutyState::ACQUIRING: {
|
||||
uint32_t elapsed = gpsDuty.acquireElapsedSecs();
|
||||
sprintf(buf, "acquiring %us", (unsigned)elapsed);
|
||||
break;
|
||||
}
|
||||
case GPSDutyState::SLEEPING: {
|
||||
uint32_t remain = gpsDuty.sleepRemainingSecs();
|
||||
if (remain >= 60) {
|
||||
sprintf(buf, "sleep %um%02us", (unsigned)(remain / 60), (unsigned)(remain % 60));
|
||||
} else {
|
||||
sprintf(buf, "sleep %us", (unsigned)remain);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
strcpy(buf, "gps off");
|
||||
}
|
||||
strcpy(buf, "gps on");
|
||||
}
|
||||
display.drawTextLeftAlign(0, y, buf);
|
||||
|
||||
@@ -374,9 +494,9 @@ public:
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
|
||||
// NMEA sentence counter  confirms baud rate and data flow
|
||||
// NMEA sentence counter — confirms baud rate and data flow
|
||||
display.drawTextLeftAlign(0, y, "sentences");
|
||||
if (gpsDuty.isHardwareOn()) {
|
||||
if (_node_prefs->gps_enabled) {
|
||||
uint16_t sps = gpsStream.getSentencesPerSec();
|
||||
uint32_t total = gpsStream.getSentenceCount();
|
||||
sprintf(buf, "%u/s (%lu)", sps, (unsigned long)total);
|
||||
@@ -504,6 +624,68 @@ public:
|
||||
}
|
||||
if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb;
|
||||
else sensors_scroll_offset = 0;
|
||||
#endif
|
||||
#if HAS_BQ27220
|
||||
} else if (_page == HomePage::BATTERY) {
|
||||
char buf[30];
|
||||
int y = 18;
|
||||
|
||||
// Title
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(display.width() / 2, y, "Battery Gauge");
|
||||
y += 12;
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
// Time to empty
|
||||
uint16_t tte = board.getTimeToEmpty();
|
||||
display.drawTextLeftAlign(0, y, "remaining");
|
||||
if (tte == 0xFFFF || tte == 0) {
|
||||
strcpy(buf, tte == 0 ? "depleted" : "charging");
|
||||
} else if (tte >= 60) {
|
||||
sprintf(buf, "%dh %dm", tte / 60, tte % 60);
|
||||
} else {
|
||||
sprintf(buf, "%d min", tte);
|
||||
}
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y += 10;
|
||||
|
||||
// Average current
|
||||
int16_t avgCur = board.getAvgCurrent();
|
||||
display.drawTextLeftAlign(0, y, "avg current");
|
||||
sprintf(buf, "%d mA", avgCur);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y += 10;
|
||||
|
||||
// Average power
|
||||
int16_t avgPow = board.getAvgPower();
|
||||
display.drawTextLeftAlign(0, y, "avg power");
|
||||
sprintf(buf, "%d mW", avgPow);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y += 10;
|
||||
|
||||
// Voltage (already available)
|
||||
uint16_t mv = board.getBattMilliVolts();
|
||||
display.drawTextLeftAlign(0, y, "voltage");
|
||||
sprintf(buf, "%d.%03d V", mv / 1000, mv % 1000);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y += 10;
|
||||
|
||||
// Remaining capacity (clamped to design capacity — gauge FCC may be
|
||||
// stale from factory defaults until a full charge cycle re-learns it)
|
||||
uint16_t remCap = board.getRemainingCapacity();
|
||||
uint16_t desCap = board.getDesignCapacity();
|
||||
if (desCap > 0 && remCap > desCap) remCap = desCap;
|
||||
display.drawTextLeftAlign(0, y, "remaining cap");
|
||||
sprintf(buf, "%d mAh", remCap);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y += 10;
|
||||
|
||||
// Battery temperature
|
||||
int16_t battTemp = board.getBattTemperature();
|
||||
display.drawTextLeftAlign(0, y, "temperature");
|
||||
sprintf(buf, "%d.%d C", battTemp / 10, abs(battTemp % 10));
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
#endif
|
||||
} else if (_page == HomePage::SHUTDOWN) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -564,6 +746,7 @@ public:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#ifdef BLE_PIN_CODE
|
||||
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
|
||||
if (_task->isSerialEnabled()) { // toggle Bluetooth on/off
|
||||
_task->disableSerial();
|
||||
@@ -572,6 +755,7 @@ public:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
|
||||
_task->notify(UIEventType::ack);
|
||||
if (the_mesh.advert()) {
|
||||
@@ -600,7 +784,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;
|
||||
@@ -739,6 +924,17 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
vibration.begin();
|
||||
#endif
|
||||
|
||||
// Keyboard backlight for message flash notifications
|
||||
#ifdef KB_BL_PIN
|
||||
pinMode(KB_BL_PIN, OUTPUT);
|
||||
digitalWrite(KB_BL_PIN, LOW);
|
||||
#endif
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
// Sync ringtone enabled state to modem manager
|
||||
modemManager.setRingtoneEnabled(node_prefs->ringtone_enabled);
|
||||
#endif
|
||||
|
||||
ui_started_at = millis();
|
||||
_alert_expiry = 0;
|
||||
|
||||
@@ -750,6 +946,13 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
text_reader = new TextReaderScreen(this);
|
||||
notes_screen = new NotesScreen(this);
|
||||
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
|
||||
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
|
||||
discovery_screen = new DiscoveryScreen(this, &rtc_clock);
|
||||
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
|
||||
#ifdef HAS_4G_MODEM
|
||||
sms_screen = new SMSScreen(this);
|
||||
#endif
|
||||
map_screen = new MapScreen(this);
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
|
||||
@@ -791,12 +994,13 @@ switch(t){
|
||||
|
||||
void UITask::msgRead(int msgcount) {
|
||||
_msgcount = msgcount;
|
||||
if (msgcount == 0) {
|
||||
if (msgcount == 0 && curr == msg_preview) {
|
||||
gotoHomeScreen();
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
|
||||
const uint8_t* path) {
|
||||
_msgcount = msgcount;
|
||||
|
||||
// Add to preview screen (for notifications on non-keyboard devices)
|
||||
@@ -814,15 +1018,25 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
||||
}
|
||||
}
|
||||
|
||||
// Add to channel history screen with channel index
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text);
|
||||
// Add to channel history screen with channel index and path data
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path);
|
||||
|
||||
// If user is currently viewing this channel, mark it as read immediately
|
||||
// (they can see the message arrive in real-time)
|
||||
if (isOnChannelScreen() &&
|
||||
((ChannelScreen *) channel_screen)->getViewChannelIdx() == channel_idx) {
|
||||
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
|
||||
}
|
||||
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
// T-Deck Pro: Don't interrupt user with popup - just show brief notification
|
||||
// Messages are stored in channel history, accessible via 'M' key
|
||||
char alertBuf[40];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
||||
showAlert(alertBuf, 2000);
|
||||
// Suppress alert entirely on admin screen - it needs focused interaction
|
||||
if (!isOnRepeaterAdmin()) {
|
||||
char alertBuf[40];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
||||
showAlert(alertBuf, 2000);
|
||||
}
|
||||
#else
|
||||
// Other devices: Show full preview screen (legacy behavior)
|
||||
setCurrScreen(msg_preview);
|
||||
@@ -837,6 +1051,14 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
||||
_next_refresh = 100; // trigger refresh
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard flash notification
|
||||
#ifdef KB_BL_PIN
|
||||
if (_node_prefs->kb_flash_notify) {
|
||||
digitalWrite(KB_BL_PIN, HIGH);
|
||||
_kb_flash_off_at = millis() + 200; // 200ms flash
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void UITask::userLedHandler() {
|
||||
@@ -886,8 +1108,32 @@ void UITask::shutdown(bool restart){
|
||||
if (restart) {
|
||||
_board->reboot();
|
||||
} else {
|
||||
_display->turnOff();
|
||||
// Disable BLE if active
|
||||
if (_serial != NULL && _serial->isEnabled()) {
|
||||
_serial->disable();
|
||||
}
|
||||
|
||||
// Disable WiFi if active
|
||||
#ifdef WIFI_SSID
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
#endif
|
||||
|
||||
// Disable GPS if active
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
{
|
||||
if (_sensors != NULL && _node_prefs != NULL && _node_prefs->gps_enabled) {
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Power off LoRa radio, display, and board
|
||||
radio_driver.powerOff();
|
||||
_display->turnOff();
|
||||
_board->powerOff();
|
||||
}
|
||||
}
|
||||
@@ -972,6 +1218,63 @@ void UITask::loop() {
|
||||
|
||||
userLedHandler();
|
||||
|
||||
// Turn off keyboard flash after timeout
|
||||
#ifdef KB_BL_PIN
|
||||
if (_kb_flash_off_at && millis() >= _kb_flash_off_at) {
|
||||
#ifdef HAS_4G_MODEM
|
||||
// Don't turn off LED if incoming call flash is active
|
||||
if (!_incomingCallRinging) {
|
||||
digitalWrite(KB_BL_PIN, LOW);
|
||||
}
|
||||
#else
|
||||
digitalWrite(KB_BL_PIN, LOW);
|
||||
#endif
|
||||
_kb_flash_off_at = 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Incoming call LED flash — rapid repeated pulse while ringing
|
||||
#if defined(HAS_4G_MODEM) && defined(KB_BL_PIN)
|
||||
{
|
||||
bool ringing = modemManager.isRinging();
|
||||
|
||||
if (ringing && !_incomingCallRinging) {
|
||||
// Ringing just started
|
||||
_incomingCallRinging = true;
|
||||
_callFlashState = false;
|
||||
_nextCallFlash = 0; // Start immediately
|
||||
|
||||
// Wake display for incoming call
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + 60000; // Keep display on while ringing (60s)
|
||||
|
||||
} else if (!ringing && _incomingCallRinging) {
|
||||
// Ringing stopped
|
||||
_incomingCallRinging = false;
|
||||
// Only turn off LED if message flash isn't also active
|
||||
if (!_kb_flash_off_at) {
|
||||
digitalWrite(KB_BL_PIN, LOW);
|
||||
}
|
||||
_callFlashState = false;
|
||||
}
|
||||
|
||||
// Rapid LED flash while ringing (if kb_flash_notify is ON)
|
||||
if (_incomingCallRinging && _node_prefs->kb_flash_notify) {
|
||||
unsigned long now = millis();
|
||||
if (now >= _nextCallFlash) {
|
||||
_callFlashState = !_callFlashState;
|
||||
digitalWrite(KB_BL_PIN, _callFlashState ? HIGH : LOW);
|
||||
// 250ms on, 250ms off — fast pulse to distinguish from single msg flash
|
||||
_nextCallFlash = now + 250;
|
||||
}
|
||||
// Extend auto-off while ringing
|
||||
_auto_off = millis() + 60000;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
if (buzzer.isPlaying()) buzzer.loop();
|
||||
#endif
|
||||
@@ -1012,10 +1315,11 @@ if (curr) curr->poll();
|
||||
if (millis() > next_batt_chck) {
|
||||
uint16_t milliVolts = getBattMilliVolts();
|
||||
if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) {
|
||||
_low_batt_count++;
|
||||
if (_low_batt_count >= 3) { // 3 consecutive low readings (~24s) to avoid transient sags
|
||||
|
||||
// show low battery shutdown alert
|
||||
// we should only do this for eink displays, which will persist after power loss
|
||||
#if defined(THINKNODE_M1) || defined(LILYGO_TECHO)
|
||||
// show low battery shutdown alert on e-ink (persists after power loss)
|
||||
#if defined(THINKNODE_M1) || defined(LILYGO_TECHO) || defined(LilyGo_TDeck_Pro)
|
||||
if (_display != NULL) {
|
||||
_display->startFrame();
|
||||
_display->setTextSize(2);
|
||||
@@ -1027,7 +1331,9 @@ if (curr) curr->poll();
|
||||
#endif
|
||||
|
||||
shutdown();
|
||||
|
||||
}
|
||||
} else {
|
||||
_low_batt_count = 0;
|
||||
}
|
||||
next_batt_chck = millis() + 8000;
|
||||
}
|
||||
@@ -1078,20 +1384,22 @@ bool UITask::getGPSState() {
|
||||
|
||||
void UITask::toggleGPS() {
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
extern GPSDutyCycle gpsDuty;
|
||||
|
||||
if (_sensors != NULL) {
|
||||
if (_node_prefs->gps_enabled) {
|
||||
// Disable GPS  cut hardware power
|
||||
// Disable GPS — cut hardware power
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
_node_prefs->gps_enabled = 0;
|
||||
gpsDuty.disable();
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
|
||||
#endif
|
||||
notify(UIEventType::ack);
|
||||
} else {
|
||||
// Enable GPS  start duty cycle
|
||||
// Enable GPS — power on hardware
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
_node_prefs->gps_enabled = 1;
|
||||
gpsDuty.enable();
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
|
||||
#endif
|
||||
notify(UIEventType::ack);
|
||||
}
|
||||
the_mesh.savePrefs();
|
||||
@@ -1155,6 +1463,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();
|
||||
@@ -1219,10 +1531,43 @@ void UITask::gotoOnboarding() {
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoAudiobookPlayer() {
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
if (audiobook_screen == nullptr) return; // No audio hardware
|
||||
AudiobookPlayerScreen* abPlayer = (AudiobookPlayerScreen*)audiobook_screen;
|
||||
if (_display != NULL) {
|
||||
abPlayer->enter(*_display);
|
||||
}
|
||||
setCurrScreen(audiobook_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
void UITask::gotoSMSScreen() {
|
||||
SMSScreen* smsScr = (SMSScreen*)sms_screen;
|
||||
smsScr->activate();
|
||||
setCurrScreen(sms_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
#endif
|
||||
|
||||
uint8_t UITask::getChannelScreenViewIdx() const {
|
||||
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
|
||||
}
|
||||
|
||||
int UITask::getUnreadMsgCount() const {
|
||||
return ((ChannelScreen *) channel_screen)->getTotalUnread();
|
||||
}
|
||||
|
||||
void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {
|
||||
// Format the message as "Sender: message"
|
||||
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
|
||||
@@ -1230,4 +1575,119 @@ void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, cons
|
||||
|
||||
// Add to channel history with path_len=0 (local message)
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::markChannelReadFromBLE(uint8_t channel_idx) {
|
||||
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
|
||||
// Trigger a refresh so the home screen unread count updates in real-time
|
||||
_next_refresh = millis() + 200;
|
||||
}
|
||||
|
||||
void UITask::gotoRepeaterAdmin(int contactIdx) {
|
||||
// Lazy-initialize on first use (same pattern as audiobook player)
|
||||
if (repeater_admin == nullptr) {
|
||||
repeater_admin = new RepeaterAdminScreen(this, &rtc_clock);
|
||||
}
|
||||
|
||||
// Get contact name for the screen header
|
||||
ContactInfo contact;
|
||||
char name[32] = "Unknown";
|
||||
if (the_mesh.getContactByIdx(contactIdx, contact)) {
|
||||
strncpy(name, contact.name, sizeof(name) - 1);
|
||||
name[sizeof(name) - 1] = '\0';
|
||||
}
|
||||
|
||||
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)repeater_admin;
|
||||
admin->openForContact(contactIdx, name);
|
||||
setCurrScreen(repeater_admin);
|
||||
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoDiscoveryScreen() {
|
||||
((DiscoveryScreen*)discovery_screen)->resetScroll();
|
||||
setCurrScreen(discovery_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
#ifdef MECK_WEB_READER
|
||||
void UITask::gotoWebReader() {
|
||||
// Lazy-initialize on first use (same pattern as audiobook player)
|
||||
if (web_reader == nullptr) {
|
||||
Serial.printf("WebReader: lazy init - free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
web_reader = new WebReaderScreen(this);
|
||||
Serial.printf("WebReader: init complete - free heap: %d\n", ESP.getFreeHeap());
|
||||
}
|
||||
WebReaderScreen* wr = (WebReaderScreen*)web_reader;
|
||||
if (_display != NULL) {
|
||||
wr->enter(*_display);
|
||||
}
|
||||
// Heap diagnostic — check state after web reader entry (WiFi connects later)
|
||||
Serial.printf("[HEAP] WebReader enter - free: %u, largest: %u, PSRAM: %u\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap(), ESP.getFreePsram());
|
||||
setCurrScreen(web_reader);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
#endif
|
||||
|
||||
void UITask::gotoMapScreen() {
|
||||
MapScreen* map = (MapScreen*)map_screen;
|
||||
if (_display != NULL) {
|
||||
map->enter(*_display);
|
||||
}
|
||||
setCurrScreen(map_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
|
||||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||||
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
|
||||
_next_refresh = 100; // trigger re-render
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::onAdminCliResponse(const char* from_name, const char* text) {
|
||||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||||
((RepeaterAdminScreen*)repeater_admin)->onCliResponse(text);
|
||||
_next_refresh = 100; // trigger re-render
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::onAdminTelemetryResult(const uint8_t* data, uint8_t len) {
|
||||
Serial.printf("[UITask] onAdminTelemetryResult: %d bytes, onAdmin=%d\n", len, isOnRepeaterAdmin());
|
||||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||||
((RepeaterAdminScreen*)repeater_admin)->onTelemetryResult(data, len);
|
||||
_next_refresh = 100; // trigger re-render
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
bool UITask::isAudioPlayingInBackground() const {
|
||||
if (!audiobook_screen) return false;
|
||||
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
|
||||
return player->isAudioActive();
|
||||
}
|
||||
|
||||
bool UITask::isAudioPausedInBackground() const {
|
||||
if (!audiobook_screen) return false;
|
||||
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
|
||||
return player->isBookOpen() && !player->isAudioActive();
|
||||
}
|
||||
#endif
|
||||
@@ -22,6 +22,17 @@
|
||||
#include "../AbstractUITask.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "SMSScreen.h"
|
||||
#endif
|
||||
|
||||
#ifdef MECK_WEB_READER
|
||||
#include "WebReaderScreen.h"
|
||||
#endif
|
||||
|
||||
// MapScreen.h included in UITask.cpp and main.cpp only (PNGdec headers
|
||||
// conflict with BLE if pulled into the global include chain)
|
||||
|
||||
class UITask : public AbstractUITask {
|
||||
DisplayDriver* _display;
|
||||
SensorManager* _sensors;
|
||||
@@ -32,11 +43,18 @@ class UITask : public AbstractUITask {
|
||||
GenericVibration vibration;
|
||||
#endif
|
||||
unsigned long _next_refresh, _auto_off;
|
||||
unsigned long _kb_flash_off_at; // Keyboard flash turn-off timer
|
||||
#ifdef HAS_4G_MODEM
|
||||
bool _incomingCallRinging; // Currently ringing (incoming call)
|
||||
unsigned long _nextCallFlash; // Next LED toggle time
|
||||
bool _callFlashState; // Current LED state during ring
|
||||
#endif
|
||||
NodePrefs* _node_prefs;
|
||||
char _alert[80];
|
||||
unsigned long _alert_expiry;
|
||||
int _msgcount;
|
||||
unsigned long ui_started_at, next_batt_chck;
|
||||
uint8_t _low_batt_count = 0; // Consecutive low-voltage readings for debounce
|
||||
int next_backlight_btn_check = 0;
|
||||
#ifdef PIN_STATUS_LED
|
||||
int led_state = 0;
|
||||
@@ -56,6 +74,16 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* text_reader; // *** NEW: Text reader screen ***
|
||||
UIScreen* notes_screen; // Notes editor screen
|
||||
UIScreen* settings_screen; // Settings/onboarding screen
|
||||
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
|
||||
#ifdef HAS_4G_MODEM
|
||||
UIScreen* sms_screen; // SMS messaging screen (4G variant only)
|
||||
#endif
|
||||
UIScreen* repeater_admin; // Repeater admin screen
|
||||
UIScreen* discovery_screen; // Node discovery scan screen
|
||||
#ifdef MECK_WEB_READER
|
||||
UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required)
|
||||
#endif
|
||||
UIScreen* map_screen; // Map tile screen (GPS + SD card tiles)
|
||||
UIScreen* curr;
|
||||
|
||||
void userLedHandler();
|
||||
@@ -72,6 +100,12 @@ public:
|
||||
|
||||
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
|
||||
next_batt_chck = _next_refresh = 0;
|
||||
_kb_flash_off_at = 0;
|
||||
#ifdef HAS_4G_MODEM
|
||||
_incomingCallRinging = false;
|
||||
_nextCallFlash = 0;
|
||||
_callFlashState = false;
|
||||
#endif
|
||||
ui_started_at = 0;
|
||||
curr = NULL;
|
||||
}
|
||||
@@ -84,9 +118,28 @@ public:
|
||||
void gotoNotesScreen(); // Navigate to notes editor
|
||||
void gotoSettingsScreen(); // Navigate to settings
|
||||
void gotoOnboarding(); // Navigate to settings in onboarding mode
|
||||
void gotoAudiobookPlayer(); // Navigate to audiobook player
|
||||
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
|
||||
void gotoDiscoveryScreen(); // Navigate to node discovery scan
|
||||
void gotoMapScreen(); // Navigate to map tile screen
|
||||
#ifdef MECK_WEB_READER
|
||||
void gotoWebReader(); // Navigate to web reader (browser)
|
||||
#endif
|
||||
#ifdef HAS_4G_MODEM
|
||||
void gotoSMSScreen();
|
||||
bool isOnSMSScreen() const { return curr == sms_screen; }
|
||||
SMSScreen* getSMSScreen() const { return (SMSScreen*)sms_screen; }
|
||||
#endif
|
||||
void showAlert(const char* text, int duration_millis) override;
|
||||
void forceRefresh() override { _next_refresh = 100; }
|
||||
// Wake display and extend auto-off timer. Call this when handling keys
|
||||
// outside of injectKey() to prevent display auto-off during direct input.
|
||||
void keepAlive() {
|
||||
if (_display != NULL && !_display->isOn()) _display->turnOn();
|
||||
_auto_off = millis() + 15000; // matches AUTO_OFF_MILLIS default
|
||||
}
|
||||
int getMsgCount() const { return _msgcount; }
|
||||
int getUnreadMsgCount() const; // Per-channel unread tracking (standalone)
|
||||
bool hasDisplay() const { return _display != NULL; }
|
||||
bool isButtonPressed() const;
|
||||
bool isOnChannelScreen() const { return curr == channel_screen; }
|
||||
@@ -94,6 +147,19 @@ public:
|
||||
bool isOnTextReader() const { return curr == text_reader; } // *** NEW ***
|
||||
bool isOnNotesScreen() const { return curr == notes_screen; }
|
||||
bool isOnSettingsScreen() const { return curr == settings_screen; }
|
||||
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
|
||||
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
|
||||
bool isOnDiscoveryScreen() const { return curr == discovery_screen; }
|
||||
bool isOnMapScreen() const { return curr == map_screen; }
|
||||
#ifdef MECK_WEB_READER
|
||||
bool isOnWebReader() const { return curr == web_reader; }
|
||||
#endif
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
// Check if audio is playing/paused in the background (for status indicators)
|
||||
bool isAudioPlayingInBackground() const;
|
||||
bool isAudioPausedInBackground() const;
|
||||
#endif
|
||||
uint8_t getChannelScreenViewIdx() const;
|
||||
|
||||
void toggleBuzzer();
|
||||
@@ -108,6 +174,14 @@ 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; }
|
||||
@@ -117,10 +191,19 @@ public:
|
||||
UIScreen* getContactsScreen() const { return contacts_screen; }
|
||||
UIScreen* getChannelScreen() const { return channel_screen; }
|
||||
UIScreen* getSettingsScreen() const { return settings_screen; }
|
||||
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
|
||||
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
|
||||
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
|
||||
UIScreen* getDiscoveryScreen() const { return discovery_screen; }
|
||||
UIScreen* getMapScreen() const { return map_screen; }
|
||||
#ifdef MECK_WEB_READER
|
||||
UIScreen* getWebReaderScreen() const { return web_reader; }
|
||||
#endif
|
||||
|
||||
// from AbstractUITask
|
||||
void msgRead(int msgcount) override;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
|
||||
const uint8_t* path = nullptr) override;
|
||||
void notify(UIEventType t = UIEventType::none) override;
|
||||
void loop() override;
|
||||
|
||||
|
||||
5128
examples/companion_radio/ui-new/Webreaderscreen.h
Normal file
5128
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
|
||||
@@ -1,8 +1,11 @@
|
||||
#include "UITask.h"
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include "../MyMesh.h"
|
||||
#include "NotesScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
#include "MapScreen.h"
|
||||
#include "target.h"
|
||||
#ifdef WIFI_SSID
|
||||
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
#include <WiFi.h>
|
||||
#endif
|
||||
|
||||
@@ -34,11 +37,18 @@
|
||||
#include "ContactsScreen.h"
|
||||
#include "TextReaderScreen.h"
|
||||
#include "SettingsScreen.h"
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
#include "AudiobookPlayerScreen.h"
|
||||
#endif
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "SMSScreen.h"
|
||||
#include "ModemManager.h"
|
||||
#endif
|
||||
|
||||
class SplashScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
unsigned long dismiss_after;
|
||||
char _version_info[12];
|
||||
char _version_info[24];
|
||||
|
||||
public:
|
||||
SplashScreen(UITask* task) : _task(task) {
|
||||
@@ -85,13 +95,20 @@ class HomeScreen : public UIScreen {
|
||||
FIRST,
|
||||
RECENT,
|
||||
RADIO,
|
||||
#ifdef BLE_PIN_CODE
|
||||
BLUETOOTH,
|
||||
#elif defined(MECK_WIFI_COMPANION)
|
||||
WIFI_STATUS,
|
||||
#endif
|
||||
ADVERT,
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
GPS,
|
||||
#endif
|
||||
#if UI_SENSORS_PAGE == 1
|
||||
SENSORS,
|
||||
#endif
|
||||
#if HAS_BQ27220
|
||||
BATTERY,
|
||||
#endif
|
||||
SHUTDOWN,
|
||||
Count // keep as last
|
||||
@@ -103,12 +120,13 @@ class HomeScreen : public UIScreen {
|
||||
NodePrefs* _node_prefs;
|
||||
uint8_t _page;
|
||||
bool _shutdown_init;
|
||||
unsigned long _shutdown_at; // earliest time to proceed with shutdown (after e-ink refresh)
|
||||
bool _editing_utc;
|
||||
int8_t _saved_utc_offset; // for cancel/undo
|
||||
AdvertPath recent[UI_RECENT_LIST_SIZE];
|
||||
|
||||
|
||||
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
|
||||
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int* outIconX = nullptr) {
|
||||
// Use voltage-based estimation to match BLE app readings
|
||||
uint8_t batteryPercentage = 0;
|
||||
if (batteryMilliVolts > 0) {
|
||||
@@ -137,6 +155,8 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
|
||||
int iconX = display.width() - totalWidth;
|
||||
int iconY = 0; // vertically align with node name text
|
||||
|
||||
if (outIconX) *outIconX = iconX;
|
||||
|
||||
// battery outline
|
||||
display.drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||
|
||||
@@ -156,6 +176,24 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
|
||||
display.setTextSize(1); // restore default text size
|
||||
}
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
// ---- Audio background playback indicator ----
|
||||
// Shows a small play symbol to the left of the battery icon when an
|
||||
// audiobook is actively playing in the background.
|
||||
// Uses the font renderer (not manual pixel drawing) since it handles
|
||||
// the e-ink coordinate scaling correctly.
|
||||
void renderAudioIndicator(DisplayDriver& display, int batteryLeftX) {
|
||||
if (!_task->isAudioPlayingInBackground()) return;
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(0); // tiny font (same as clock & battery %)
|
||||
int x = batteryLeftX - display.getTextWidth(">>") - 2;
|
||||
display.setCursor(x, -3); // align vertically with battery text
|
||||
display.print(">>");
|
||||
display.setTextSize(1); // restore
|
||||
}
|
||||
#endif
|
||||
|
||||
CayenneLPP sensors_lpp;
|
||||
int sensors_nb = 0;
|
||||
bool sensors_scroll = false;
|
||||
@@ -186,7 +224,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
|
||||
public:
|
||||
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs)
|
||||
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
|
||||
_shutdown_init(false), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
|
||||
_shutdown_init(false), _shutdown_at(0), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
|
||||
|
||||
bool isEditingUTC() const { return _editing_utc; }
|
||||
void cancelEditUTC() {
|
||||
@@ -197,23 +235,31 @@ public:
|
||||
}
|
||||
|
||||
void poll() override {
|
||||
if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released
|
||||
if (_shutdown_init && millis() >= _shutdown_at && !_task->isButtonPressed()) {
|
||||
_task->shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
char tmp[80];
|
||||
// node name
|
||||
display.setTextSize(1);
|
||||
// node name (tinyfont to avoid overlapping clock)
|
||||
display.setTextSize(0);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
char filtered_name[sizeof(_node_prefs->node_name)];
|
||||
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
|
||||
display.setCursor(0, 0);
|
||||
display.setCursor(0, -3);
|
||||
display.print(filtered_name);
|
||||
|
||||
// battery voltage
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
int battLeftX = display.width(); // default if battery doesn't render
|
||||
renderBatteryIndicator(display, _task->getBattMilliVolts(), &battLeftX);
|
||||
|
||||
// audio background playback indicator (>> icon next to battery)
|
||||
renderAudioIndicator(display, battLeftX);
|
||||
#else
|
||||
renderBatteryIndicator(display, _task->getBattMilliVolts());
|
||||
#endif
|
||||
|
||||
// centered clock (tinyfont) - only show when time is valid
|
||||
{
|
||||
@@ -250,28 +296,70 @@ public:
|
||||
}
|
||||
|
||||
if (_page == HomePage::FIRST) {
|
||||
int y = 20;
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "MSG: %d", _task->getMsgCount());
|
||||
display.drawTextCentered(display.width() / 2, 20, tmp);
|
||||
sprintf(tmp, "MSG: %d", _task->getUnreadMsgCount());
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 18;
|
||||
|
||||
#ifdef WIFI_SSID
|
||||
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
IPAddress ip = WiFi.localIP();
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 54, tmp);
|
||||
if (ip != IPAddress(0,0,0,0)) {
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d:%d", ip[0], ip[1], ip[2], ip[3], TCP_PORT);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 12;
|
||||
}
|
||||
#endif
|
||||
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 43, "< Connected >");
|
||||
|
||||
} else if (the_mesh.getBLEPin() != 0) { // BT pin
|
||||
display.drawTextCentered(display.width() / 2, y, "< Connected >");
|
||||
y += 12;
|
||||
#ifdef BLE_PIN_CODE
|
||||
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
|
||||
display.drawTextCentered(display.width() / 2, 43, tmp);
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 18;
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
// Menu shortcuts - tinyfont monospaced grid
|
||||
y += 6;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(0); // tinyfont 6x8 monospaced
|
||||
display.drawTextCentered(display.width() / 2, y, "Press:");
|
||||
y += 12;
|
||||
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts ");
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps ");
|
||||
y += 10;
|
||||
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[T] Phone [B] Browser ");
|
||||
#elif defined(HAS_4G_MODEM)
|
||||
display.drawTextCentered(display.width() / 2, y, "[T] Phone ");
|
||||
#elif defined(MECK_AUDIO_VARIANT) && defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [B] Browser ");
|
||||
#elif defined(MECK_AUDIO_VARIANT)
|
||||
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks ");
|
||||
#elif defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
|
||||
#else
|
||||
y -= 10; // reclaim the row for standalone
|
||||
#endif
|
||||
y += 14;
|
||||
|
||||
// Nav hint
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(display.width() / 2, y, "Press A/D to cycle home views");
|
||||
display.setTextSize(1); // restore
|
||||
} else if (_page == HomePage::RECENT) {
|
||||
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -316,34 +404,83 @@ public:
|
||||
display.setCursor(0, 53);
|
||||
sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
|
||||
display.print(tmp);
|
||||
#ifdef BLE_PIN_CODE
|
||||
} else if (_page == HomePage::BLUETOOTH) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawXbm((display.width() - 32) / 2, 18,
|
||||
_task->isSerialEnabled() ? bluetooth_on : bluetooth_off,
|
||||
32, 32);
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 53, "< Connected >");
|
||||
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
|
||||
display.drawTextCentered(display.width() / 2, 53, tmp);
|
||||
}
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL);
|
||||
display.drawTextCentered(display.width() / 2, 72, "toggle: " PRESS_LABEL);
|
||||
#endif
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
} else if (_page == HomePage::WIFI_STATUS) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 18, "WiFi Companion");
|
||||
|
||||
int wy = 36;
|
||||
display.setTextSize(0);
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
snprintf(tmp, sizeof(tmp), "SSID: %s", WiFi.SSID().c_str());
|
||||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||||
wy += 10;
|
||||
IPAddress ip = WiFi.localIP();
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||||
wy += 10;
|
||||
snprintf(tmp, sizeof(tmp), "Port: %d", TCP_PORT);
|
||||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||||
wy += 12;
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, wy, "< App Connected >");
|
||||
} else {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, wy, "Waiting for app...");
|
||||
}
|
||||
} else {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.drawTextCentered(display.width() / 2, wy, "Not connected");
|
||||
wy += 12;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawTextCentered(display.width() / 2, wy, "Configure in Settings");
|
||||
}
|
||||
display.setTextSize(1);
|
||||
#endif
|
||||
} else if (_page == HomePage::ADVERT) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL);
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
} else if (_page == HomePage::GPS) {
|
||||
extern GPSStreamCounter gpsStream;
|
||||
LocationProvider* nmea = sensors.getLocationProvider();
|
||||
char buf[50];
|
||||
int y = 18;
|
||||
bool gps_state = _task->getGPSState();
|
||||
#ifdef PIN_GPS_SWITCH
|
||||
bool hw_gps_state = digitalRead(PIN_GPS_SWITCH);
|
||||
if (gps_state != hw_gps_state) {
|
||||
strcpy(buf, gps_state ? "gps off(hw)" : "gps off(sw)");
|
||||
|
||||
// GPS state line
|
||||
if (!_node_prefs->gps_enabled) {
|
||||
strcpy(buf, "gps off");
|
||||
} else {
|
||||
strcpy(buf, gps_state ? "gps on" : "gps off");
|
||||
strcpy(buf, "gps on");
|
||||
}
|
||||
#else
|
||||
strcpy(buf, gps_state ? "gps on" : "gps off");
|
||||
#endif
|
||||
display.drawTextLeftAlign(0, y, buf);
|
||||
|
||||
if (nmea == NULL) {
|
||||
y = y + 12;
|
||||
display.drawTextLeftAlign(0, y, "Can't access GPS");
|
||||
@@ -355,6 +492,19 @@ public:
|
||||
sprintf(buf, "%d", nmea->satellitesCount());
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
|
||||
// NMEA sentence counter — confirms baud rate and data flow
|
||||
display.drawTextLeftAlign(0, y, "sentences");
|
||||
if (_node_prefs->gps_enabled) {
|
||||
uint16_t sps = gpsStream.getSentencesPerSec();
|
||||
uint32_t total = gpsStream.getSentenceCount();
|
||||
sprintf(buf, "%u/s (%lu)", sps, (unsigned long)total);
|
||||
} else {
|
||||
strcpy(buf, "hw off");
|
||||
}
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
|
||||
display.drawTextLeftAlign(0, y, "pos");
|
||||
sprintf(buf, "%.4f %.4f",
|
||||
nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.);
|
||||
@@ -473,6 +623,68 @@ public:
|
||||
}
|
||||
if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb;
|
||||
else sensors_scroll_offset = 0;
|
||||
#endif
|
||||
#if HAS_BQ27220
|
||||
} else if (_page == HomePage::BATTERY) {
|
||||
char buf[30];
|
||||
int y = 18;
|
||||
|
||||
// Title
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(display.width() / 2, y, "Battery Gauge");
|
||||
y += 12;
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
// Time to empty
|
||||
uint16_t tte = board.getTimeToEmpty();
|
||||
display.drawTextLeftAlign(0, y, "remaining");
|
||||
if (tte == 0xFFFF || tte == 0) {
|
||||
strcpy(buf, tte == 0 ? "depleted" : "charging");
|
||||
} else if (tte >= 60) {
|
||||
sprintf(buf, "%dh %dm", tte / 60, tte % 60);
|
||||
} else {
|
||||
sprintf(buf, "%d min", tte);
|
||||
}
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y += 10;
|
||||
|
||||
// Average current
|
||||
int16_t avgCur = board.getAvgCurrent();
|
||||
display.drawTextLeftAlign(0, y, "avg current");
|
||||
sprintf(buf, "%d mA", avgCur);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y += 10;
|
||||
|
||||
// Average power
|
||||
int16_t avgPow = board.getAvgPower();
|
||||
display.drawTextLeftAlign(0, y, "avg power");
|
||||
sprintf(buf, "%d mW", avgPow);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y += 10;
|
||||
|
||||
// Voltage (already available)
|
||||
uint16_t mv = board.getBattMilliVolts();
|
||||
display.drawTextLeftAlign(0, y, "voltage");
|
||||
sprintf(buf, "%d.%03d V", mv / 1000, mv % 1000);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y += 10;
|
||||
|
||||
// Remaining capacity (clamped to design capacity — gauge FCC may be
|
||||
// stale from factory defaults until a full charge cycle re-learns it)
|
||||
uint16_t remCap = board.getRemainingCapacity();
|
||||
uint16_t desCap = board.getDesignCapacity();
|
||||
if (desCap > 0 && remCap > desCap) remCap = desCap;
|
||||
display.drawTextLeftAlign(0, y, "remaining cap");
|
||||
sprintf(buf, "%d mAh", remCap);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y += 10;
|
||||
|
||||
// Battery temperature
|
||||
int16_t battTemp = board.getBattTemperature();
|
||||
display.drawTextLeftAlign(0, y, "temperature");
|
||||
sprintf(buf, "%d.%d C", battTemp / 10, abs(battTemp % 10));
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
#endif
|
||||
} else if (_page == HomePage::SHUTDOWN) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -533,6 +745,7 @@ public:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#ifdef BLE_PIN_CODE
|
||||
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
|
||||
if (_task->isSerialEnabled()) { // toggle Bluetooth on/off
|
||||
_task->disableSerial();
|
||||
@@ -541,6 +754,7 @@ public:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
|
||||
_task->notify(UIEventType::ack);
|
||||
if (the_mesh.advert()) {
|
||||
@@ -569,7 +783,8 @@ public:
|
||||
}
|
||||
#endif
|
||||
if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) {
|
||||
_shutdown_init = true; // need to wait for button to be released
|
||||
_shutdown_init = true;
|
||||
_shutdown_at = millis() + 900; // allow e-ink refresh (644ms) before shutdown
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -708,6 +923,17 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
vibration.begin();
|
||||
#endif
|
||||
|
||||
// Keyboard backlight for message flash notifications
|
||||
#ifdef KB_BL_PIN
|
||||
pinMode(KB_BL_PIN, OUTPUT);
|
||||
digitalWrite(KB_BL_PIN, LOW);
|
||||
#endif
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
// Sync ringtone enabled state to modem manager
|
||||
modemManager.setRingtoneEnabled(node_prefs->ringtone_enabled);
|
||||
#endif
|
||||
|
||||
ui_started_at = millis();
|
||||
_alert_expiry = 0;
|
||||
|
||||
@@ -717,7 +943,14 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
channel_screen = new ChannelScreen(this, &rtc_clock);
|
||||
contacts_screen = new ContactsScreen(this, &rtc_clock);
|
||||
text_reader = new TextReaderScreen(this);
|
||||
notes_screen = new NotesScreen(this);
|
||||
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
|
||||
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
|
||||
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
|
||||
#ifdef HAS_4G_MODEM
|
||||
sms_screen = new SMSScreen(this);
|
||||
#endif
|
||||
map_screen = new MapScreen(this);
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
|
||||
@@ -759,12 +992,13 @@ switch(t){
|
||||
|
||||
void UITask::msgRead(int msgcount) {
|
||||
_msgcount = msgcount;
|
||||
if (msgcount == 0) {
|
||||
if (msgcount == 0 && curr == msg_preview) {
|
||||
gotoHomeScreen();
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
|
||||
const uint8_t* path) {
|
||||
_msgcount = msgcount;
|
||||
|
||||
// Add to preview screen (for notifications on non-keyboard devices)
|
||||
@@ -782,15 +1016,25 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
||||
}
|
||||
}
|
||||
|
||||
// Add to channel history screen with channel index
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text);
|
||||
// Add to channel history screen with channel index and path data
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path);
|
||||
|
||||
// If user is currently viewing this channel, mark it as read immediately
|
||||
// (they can see the message arrive in real-time)
|
||||
if (isOnChannelScreen() &&
|
||||
((ChannelScreen *) channel_screen)->getViewChannelIdx() == channel_idx) {
|
||||
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
|
||||
}
|
||||
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
// T-Deck Pro: Don't interrupt user with popup - just show brief notification
|
||||
// Messages are stored in channel history, accessible via 'M' key
|
||||
char alertBuf[40];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
||||
showAlert(alertBuf, 2000);
|
||||
// Suppress alert entirely on admin screen - it needs focused interaction
|
||||
if (!isOnRepeaterAdmin()) {
|
||||
char alertBuf[40];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
||||
showAlert(alertBuf, 2000);
|
||||
}
|
||||
#else
|
||||
// Other devices: Show full preview screen (legacy behavior)
|
||||
setCurrScreen(msg_preview);
|
||||
@@ -805,6 +1049,14 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
||||
_next_refresh = 100; // trigger refresh
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard flash notification
|
||||
#ifdef KB_BL_PIN
|
||||
if (_node_prefs->kb_flash_notify) {
|
||||
digitalWrite(KB_BL_PIN, HIGH);
|
||||
_kb_flash_off_at = millis() + 200; // 200ms flash
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void UITask::userLedHandler() {
|
||||
@@ -854,8 +1106,32 @@ void UITask::shutdown(bool restart){
|
||||
if (restart) {
|
||||
_board->reboot();
|
||||
} else {
|
||||
_display->turnOff();
|
||||
// Disable BLE if active
|
||||
if (_serial != NULL && _serial->isEnabled()) {
|
||||
_serial->disable();
|
||||
}
|
||||
|
||||
// Disable WiFi if active
|
||||
#ifdef WIFI_SSID
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
#endif
|
||||
|
||||
// Disable GPS if active
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
{
|
||||
if (_sensors != NULL && _node_prefs != NULL && _node_prefs->gps_enabled) {
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Power off LoRa radio, display, and board
|
||||
radio_driver.powerOff();
|
||||
_display->turnOff();
|
||||
_board->powerOff();
|
||||
}
|
||||
}
|
||||
@@ -940,6 +1216,63 @@ void UITask::loop() {
|
||||
|
||||
userLedHandler();
|
||||
|
||||
// Turn off keyboard flash after timeout
|
||||
#ifdef KB_BL_PIN
|
||||
if (_kb_flash_off_at && millis() >= _kb_flash_off_at) {
|
||||
#ifdef HAS_4G_MODEM
|
||||
// Don't turn off LED if incoming call flash is active
|
||||
if (!_incomingCallRinging) {
|
||||
digitalWrite(KB_BL_PIN, LOW);
|
||||
}
|
||||
#else
|
||||
digitalWrite(KB_BL_PIN, LOW);
|
||||
#endif
|
||||
_kb_flash_off_at = 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Incoming call LED flash — rapid repeated pulse while ringing
|
||||
#if defined(HAS_4G_MODEM) && defined(KB_BL_PIN)
|
||||
{
|
||||
bool ringing = modemManager.isRinging();
|
||||
|
||||
if (ringing && !_incomingCallRinging) {
|
||||
// Ringing just started
|
||||
_incomingCallRinging = true;
|
||||
_callFlashState = false;
|
||||
_nextCallFlash = 0; // Start immediately
|
||||
|
||||
// Wake display for incoming call
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + 60000; // Keep display on while ringing (60s)
|
||||
|
||||
} else if (!ringing && _incomingCallRinging) {
|
||||
// Ringing stopped
|
||||
_incomingCallRinging = false;
|
||||
// Only turn off LED if message flash isn't also active
|
||||
if (!_kb_flash_off_at) {
|
||||
digitalWrite(KB_BL_PIN, LOW);
|
||||
}
|
||||
_callFlashState = false;
|
||||
}
|
||||
|
||||
// Rapid LED flash while ringing (if kb_flash_notify is ON)
|
||||
if (_incomingCallRinging && _node_prefs->kb_flash_notify) {
|
||||
unsigned long now = millis();
|
||||
if (now >= _nextCallFlash) {
|
||||
_callFlashState = !_callFlashState;
|
||||
digitalWrite(KB_BL_PIN, _callFlashState ? HIGH : LOW);
|
||||
// 250ms on, 250ms off — fast pulse to distinguish from single msg flash
|
||||
_nextCallFlash = now + 250;
|
||||
}
|
||||
// Extend auto-off while ringing
|
||||
_auto_off = millis() + 60000;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
if (buzzer.isPlaying()) buzzer.loop();
|
||||
#endif
|
||||
@@ -980,10 +1313,11 @@ if (curr) curr->poll();
|
||||
if (millis() > next_batt_chck) {
|
||||
uint16_t milliVolts = getBattMilliVolts();
|
||||
if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) {
|
||||
_low_batt_count++;
|
||||
if (_low_batt_count >= 3) { // 3 consecutive low readings (~24s) to avoid transient sags
|
||||
|
||||
// show low battery shutdown alert
|
||||
// we should only do this for eink displays, which will persist after power loss
|
||||
#if defined(THINKNODE_M1) || defined(LILYGO_TECHO)
|
||||
// show low battery shutdown alert on e-ink (persists after power loss)
|
||||
#if defined(THINKNODE_M1) || defined(LILYGO_TECHO) || defined(LilyGo_TDeck_Pro)
|
||||
if (_display != NULL) {
|
||||
_display->startFrame();
|
||||
_display->setTextSize(2);
|
||||
@@ -995,7 +1329,9 @@ if (curr) curr->poll();
|
||||
#endif
|
||||
|
||||
shutdown();
|
||||
|
||||
}
|
||||
} else {
|
||||
_low_batt_count = 0;
|
||||
}
|
||||
next_batt_chck = millis() + 8000;
|
||||
}
|
||||
@@ -1037,39 +1373,38 @@ char UITask::handleTripleClick(char c) {
|
||||
}
|
||||
|
||||
bool UITask::getGPSState() {
|
||||
if (_sensors != NULL) {
|
||||
int num = _sensors->getNumSettings();
|
||||
for (int i = 0; i < num; i++) {
|
||||
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
|
||||
return !strcmp(_sensors->getSettingValue(i), "1");
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
return _node_prefs != NULL && _node_prefs->gps_enabled;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void UITask::toggleGPS() {
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
if (_sensors != NULL) {
|
||||
// toggle GPS on/off
|
||||
int num = _sensors->getNumSettings();
|
||||
for (int i = 0; i < num; i++) {
|
||||
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
|
||||
if (strcmp(_sensors->getSettingValue(i), "1") == 0) {
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
_node_prefs->gps_enabled = 0;
|
||||
notify(UIEventType::ack);
|
||||
} else {
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
_node_prefs->gps_enabled = 1;
|
||||
notify(UIEventType::ack);
|
||||
}
|
||||
the_mesh.savePrefs();
|
||||
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
|
||||
_next_refresh = 0;
|
||||
break;
|
||||
if (_node_prefs->gps_enabled) {
|
||||
// Disable GPS — cut hardware power
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
_node_prefs->gps_enabled = 0;
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
|
||||
#endif
|
||||
notify(UIEventType::ack);
|
||||
} else {
|
||||
// Enable GPS — power on hardware
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
_node_prefs->gps_enabled = 1;
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
|
||||
#endif
|
||||
notify(UIEventType::ack);
|
||||
}
|
||||
the_mesh.savePrefs();
|
||||
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
|
||||
_next_refresh = 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void UITask::toggleBuzzer() {
|
||||
@@ -1126,6 +1461,10 @@ bool UITask::isEditingHomeScreen() const {
|
||||
|
||||
void UITask::gotoChannelScreen() {
|
||||
((ChannelScreen *) channel_screen)->resetScroll();
|
||||
// Mark the currently viewed channel as read
|
||||
((ChannelScreen *) channel_screen)->markChannelRead(
|
||||
((ChannelScreen *) channel_screen)->getViewChannelIdx()
|
||||
);
|
||||
setCurrScreen(channel_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
@@ -1157,8 +1496,21 @@ void UITask::gotoTextReader() {
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoNotesScreen() {
|
||||
NotesScreen* notes = (NotesScreen*)notes_screen;
|
||||
if (_display != NULL) {
|
||||
notes->enter(*_display);
|
||||
}
|
||||
setCurrScreen(notes_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoSettingsScreen() {
|
||||
((SettingsScreen*)settings_screen)->enter();
|
||||
((SettingsScreen *) settings_screen)->enter();
|
||||
setCurrScreen(settings_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
@@ -1168,7 +1520,7 @@ void UITask::gotoSettingsScreen() {
|
||||
}
|
||||
|
||||
void UITask::gotoOnboarding() {
|
||||
((SettingsScreen*)settings_screen)->enterOnboarding();
|
||||
((SettingsScreen *) settings_screen)->enterOnboarding();
|
||||
setCurrScreen(settings_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
@@ -1177,10 +1529,43 @@ void UITask::gotoOnboarding() {
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoAudiobookPlayer() {
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
if (audiobook_screen == nullptr) return; // No audio hardware
|
||||
AudiobookPlayerScreen* abPlayer = (AudiobookPlayerScreen*)audiobook_screen;
|
||||
if (_display != NULL) {
|
||||
abPlayer->enter(*_display);
|
||||
}
|
||||
setCurrScreen(audiobook_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
void UITask::gotoSMSScreen() {
|
||||
SMSScreen* smsScr = (SMSScreen*)sms_screen;
|
||||
smsScr->activate();
|
||||
setCurrScreen(sms_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
#endif
|
||||
|
||||
uint8_t UITask::getChannelScreenViewIdx() const {
|
||||
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
|
||||
}
|
||||
|
||||
int UITask::getUnreadMsgCount() const {
|
||||
return ((ChannelScreen *) channel_screen)->getTotalUnread();
|
||||
}
|
||||
|
||||
void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {
|
||||
// Format the message as "Sender: message"
|
||||
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
|
||||
@@ -1188,4 +1573,109 @@ void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, cons
|
||||
|
||||
// Add to channel history with path_len=0 (local message)
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::markChannelReadFromBLE(uint8_t channel_idx) {
|
||||
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
|
||||
// Trigger a refresh so the home screen unread count updates in real-time
|
||||
_next_refresh = millis() + 200;
|
||||
}
|
||||
|
||||
void UITask::gotoRepeaterAdmin(int contactIdx) {
|
||||
// Lazy-initialize on first use (same pattern as audiobook player)
|
||||
if (repeater_admin == nullptr) {
|
||||
repeater_admin = new RepeaterAdminScreen(this, &rtc_clock);
|
||||
}
|
||||
|
||||
// Get contact name for the screen header
|
||||
ContactInfo contact;
|
||||
char name[32] = "Unknown";
|
||||
if (the_mesh.getContactByIdx(contactIdx, contact)) {
|
||||
strncpy(name, contact.name, sizeof(name) - 1);
|
||||
name[sizeof(name) - 1] = '\0';
|
||||
}
|
||||
|
||||
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)repeater_admin;
|
||||
admin->openForContact(contactIdx, name);
|
||||
setCurrScreen(repeater_admin);
|
||||
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
#ifdef MECK_WEB_READER
|
||||
void UITask::gotoWebReader() {
|
||||
// Lazy-initialize on first use (same pattern as audiobook player)
|
||||
if (web_reader == nullptr) {
|
||||
Serial.printf("WebReader: lazy init - free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
web_reader = new WebReaderScreen(this);
|
||||
Serial.printf("WebReader: init complete - free heap: %d\n", ESP.getFreeHeap());
|
||||
}
|
||||
WebReaderScreen* wr = (WebReaderScreen*)web_reader;
|
||||
if (_display != NULL) {
|
||||
wr->enter(*_display);
|
||||
}
|
||||
// Heap diagnostic — check state after web reader entry (WiFi connects later)
|
||||
Serial.printf("[HEAP] WebReader enter - free: %u, largest: %u, PSRAM: %u\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap(), ESP.getFreePsram());
|
||||
setCurrScreen(web_reader);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
#endif
|
||||
|
||||
void UITask::gotoMapScreen() {
|
||||
MapScreen* map = (MapScreen*)map_screen;
|
||||
if (_display != NULL) {
|
||||
map->enter(*_display);
|
||||
}
|
||||
setCurrScreen(map_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
|
||||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||||
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
|
||||
_next_refresh = 100; // trigger re-render
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::onAdminCliResponse(const char* from_name, const char* text) {
|
||||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||||
((RepeaterAdminScreen*)repeater_admin)->onCliResponse(text);
|
||||
_next_refresh = 100; // trigger re-render
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::onAdminTelemetryResult(const uint8_t* data, uint8_t len) {
|
||||
Serial.printf("[UITask] onAdminTelemetryResult: %d bytes, onAdmin=%d\n", len, isOnRepeaterAdmin());
|
||||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||||
((RepeaterAdminScreen*)repeater_admin)->onTelemetryResult(data, len);
|
||||
_next_refresh = 100; // trigger re-render
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
bool UITask::isAudioPlayingInBackground() const {
|
||||
if (!audiobook_screen) return false;
|
||||
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
|
||||
return player->isAudioActive();
|
||||
}
|
||||
|
||||
bool UITask::isAudioPausedInBackground() const {
|
||||
if (!audiobook_screen) return false;
|
||||
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
|
||||
return player->isBookOpen() && !player->isAudioActive();
|
||||
}
|
||||
#endif
|
||||
@@ -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
|
||||
) {
|
||||
@@ -249,4 +257,4 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
|
||||
|
||||
bool SerialBLEInterface::isConnected() const {
|
||||
return deviceConnected; //pServer != NULL && pServer->getConnectedCount() > 0;
|
||||
}
|
||||
}
|
||||
@@ -88,4 +88,4 @@ public:
|
||||
#else
|
||||
#define BLE_DEBUG_PRINT(...) {}
|
||||
#define BLE_DEBUG_PRINTLN(...) {}
|
||||
#endif
|
||||
#endif
|
||||
@@ -12,7 +12,31 @@
|
||||
#include <Fonts/FreeSans9pt7b.h>
|
||||
#include <Fonts/FreeSansBold12pt7b.h>
|
||||
#include <Fonts/FreeSans18pt7b.h>
|
||||
#include <CRC32.h>
|
||||
|
||||
// Inline CRC32 for frame change detection (replaces bakercp/CRC32
|
||||
// to avoid naming collision with PNGdec's bundled CRC32.h)
|
||||
class FrameCRC32 {
|
||||
uint32_t _crc = 0xFFFFFFFF;
|
||||
public:
|
||||
void reset() { _crc = 0xFFFFFFFF; }
|
||||
template<typename T> void update(T val) {
|
||||
const uint8_t* p = (const uint8_t*)&val;
|
||||
for (size_t i = 0; i < sizeof(T); i++) {
|
||||
_crc ^= p[i];
|
||||
for (int b = 0; b < 8; b++)
|
||||
_crc = (_crc >> 1) ^ (0xEDB88320 & -(int32_t)(_crc & 1));
|
||||
}
|
||||
}
|
||||
template<typename T> void update(const T* data, size_t len) {
|
||||
const uint8_t* p = (const uint8_t*)data;
|
||||
for (size_t i = 0; i < len * sizeof(T); i++) {
|
||||
_crc ^= p[i];
|
||||
for (int b = 0; b < 8; b++)
|
||||
_crc = (_crc >> 1) ^ (0xEDB88320 & -(int32_t)(_crc & 1));
|
||||
}
|
||||
}
|
||||
uint32_t finalize() { return _crc ^ 0xFFFFFFFF; }
|
||||
};
|
||||
|
||||
#include "DisplayDriver.h"
|
||||
|
||||
@@ -34,7 +58,7 @@ class GxEPDDisplay : public DisplayDriver {
|
||||
bool _init = false;
|
||||
bool _isOn = false;
|
||||
uint16_t _curr_color;
|
||||
CRC32 display_crc;
|
||||
FrameCRC32 display_crc;
|
||||
int last_display_crc_value = 0;
|
||||
|
||||
public:
|
||||
@@ -60,4 +84,24 @@ public:
|
||||
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;
|
||||
uint16_t getTextWidth(const char* str) override;
|
||||
void endFrame() override;
|
||||
};
|
||||
|
||||
// --- Raw pixel access for MapScreen (bypasses scaling) ---
|
||||
void drawPixelRaw(int16_t x, int16_t y, uint16_t color) {
|
||||
display.drawPixel(x, y, color);
|
||||
}
|
||||
int16_t rawWidth() { return display.width(); }
|
||||
int16_t rawHeight() { return display.height(); }
|
||||
|
||||
// Draw text at raw (unscaled) physical coordinates using built-in 5x7 font
|
||||
void drawTextRaw(int16_t x, int16_t y, const char* text, uint16_t color) {
|
||||
display.setFont(NULL); // Built-in 5x7 font
|
||||
display.setTextSize(1);
|
||||
display.setTextColor(color);
|
||||
display.setCursor(x, y);
|
||||
display.print(text);
|
||||
}
|
||||
|
||||
// Force endFrame() to push to display even if CRC unchanged
|
||||
// (needed because drawPixelRaw bypasses CRC tracking)
|
||||
void invalidateFrameCRC() { last_display_crc_value = 0; }
|
||||
};
|
||||
@@ -1,185 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include "variant.h"
|
||||
#include "GPSStreamCounter.h"
|
||||
|
||||
// GPS Duty Cycle Manager
|
||||
// Controls the hardware GPS enable pin (PIN_GPS_EN) to save power.
|
||||
// When enabled, cycles between acquiring a fix and sleeping with power cut.
|
||||
//
|
||||
// States:
|
||||
// OFF – User has disabled GPS. Hardware power is cut.
|
||||
// ACQUIRING – GPS module powered on, waiting for a fix or timeout.
|
||||
// SLEEPING – GPS module powered off, timer counting down to next cycle.
|
||||
|
||||
#if HAS_GPS
|
||||
|
||||
// How long to leave GPS powered on while acquiring a fix (ms)
|
||||
#ifndef GPS_ACQUIRE_TIMEOUT_MS
|
||||
#define GPS_ACQUIRE_TIMEOUT_MS 180000 // 3 minutes
|
||||
#endif
|
||||
|
||||
// How long to sleep between acquisition cycles (ms)
|
||||
#ifndef GPS_SLEEP_DURATION_MS
|
||||
#define GPS_SLEEP_DURATION_MS 900000 // 15 minutes
|
||||
#endif
|
||||
|
||||
// If we get a fix quickly, power off immediately but still respect
|
||||
// a minimum on-time so the RTC can sync properly
|
||||
#ifndef GPS_MIN_ON_TIME_MS
|
||||
#define GPS_MIN_ON_TIME_MS 5000 // 5 seconds after fix
|
||||
#endif
|
||||
|
||||
enum class GPSDutyState : uint8_t {
|
||||
OFF = 0, // User-disabled, hardware power off
|
||||
ACQUIRING, // Hardware on, waiting for fix
|
||||
SLEEPING // Hardware off, timer running
|
||||
};
|
||||
|
||||
class GPSDutyCycle {
|
||||
public:
|
||||
GPSDutyCycle() : _state(GPSDutyState::OFF), _state_entered(0),
|
||||
_last_fix_time(0), _got_fix(false), _time_synced(false),
|
||||
_stream(nullptr) {}
|
||||
|
||||
// Attach the stream counter so we can reset it on power cycles
|
||||
void setStreamCounter(GPSStreamCounter* stream) { _stream = stream; }
|
||||
|
||||
// Call once in setup() after board.begin() and GPS serial init.
|
||||
void begin(bool initial_enable) {
|
||||
if (initial_enable) {
|
||||
_powerOn();
|
||||
_setState(GPSDutyState::ACQUIRING);
|
||||
} else {
|
||||
_powerOff();
|
||||
_setState(GPSDutyState::OFF);
|
||||
}
|
||||
}
|
||||
|
||||
// Call every iteration of loop().
|
||||
// Returns true if GPS hardware is currently powered on.
|
||||
bool loop() {
|
||||
switch (_state) {
|
||||
case GPSDutyState::OFF:
|
||||
return false;
|
||||
|
||||
case GPSDutyState::ACQUIRING: {
|
||||
unsigned long elapsed = millis() - _state_entered;
|
||||
|
||||
if (_got_fix && elapsed >= GPS_MIN_ON_TIME_MS) {
|
||||
MESH_DEBUG_PRINTLN("GPS duty: fix acquired, powering off for %u min",
|
||||
(unsigned)(GPS_SLEEP_DURATION_MS / 60000));
|
||||
_powerOff();
|
||||
_setState(GPSDutyState::SLEEPING);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (elapsed >= GPS_ACQUIRE_TIMEOUT_MS) {
|
||||
MESH_DEBUG_PRINTLN("GPS duty: acquire timeout (%us), sleeping",
|
||||
(unsigned)(GPS_ACQUIRE_TIMEOUT_MS / 1000));
|
||||
_powerOff();
|
||||
_setState(GPSDutyState::SLEEPING);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
case GPSDutyState::SLEEPING: {
|
||||
if (millis() - _state_entered >= GPS_SLEEP_DURATION_MS) {
|
||||
MESH_DEBUG_PRINTLN("GPS duty: waking up for next acquisition cycle");
|
||||
_got_fix = false;
|
||||
_powerOn();
|
||||
_setState(GPSDutyState::ACQUIRING);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void notifyFix() {
|
||||
if (_state == GPSDutyState::ACQUIRING && !_got_fix) {
|
||||
_got_fix = true;
|
||||
_last_fix_time = millis();
|
||||
MESH_DEBUG_PRINTLN("GPS duty: fix notification received");
|
||||
}
|
||||
}
|
||||
|
||||
void notifyTimeSync() {
|
||||
_time_synced = true;
|
||||
}
|
||||
|
||||
void enable() {
|
||||
if (_state == GPSDutyState::OFF) {
|
||||
_got_fix = false;
|
||||
_powerOn();
|
||||
_setState(GPSDutyState::ACQUIRING);
|
||||
MESH_DEBUG_PRINTLN("GPS duty: enabled, starting acquisition");
|
||||
}
|
||||
}
|
||||
|
||||
void disable() {
|
||||
_powerOff();
|
||||
_setState(GPSDutyState::OFF);
|
||||
_got_fix = false;
|
||||
MESH_DEBUG_PRINTLN("GPS duty: disabled, power off");
|
||||
}
|
||||
|
||||
void forceWake() {
|
||||
if (_state == GPSDutyState::SLEEPING) {
|
||||
_got_fix = false;
|
||||
_powerOn();
|
||||
_setState(GPSDutyState::ACQUIRING);
|
||||
MESH_DEBUG_PRINTLN("GPS duty: forced wake for user request");
|
||||
}
|
||||
}
|
||||
|
||||
GPSDutyState getState() const { return _state; }
|
||||
bool isHardwareOn() const { return _state == GPSDutyState::ACQUIRING; }
|
||||
bool hadFix() const { return _got_fix; }
|
||||
bool hasTimeSynced() const { return _time_synced; }
|
||||
|
||||
uint32_t sleepRemainingSecs() const {
|
||||
if (_state != GPSDutyState::SLEEPING) return 0;
|
||||
unsigned long elapsed = millis() - _state_entered;
|
||||
if (elapsed >= GPS_SLEEP_DURATION_MS) return 0;
|
||||
return (GPS_SLEEP_DURATION_MS - elapsed) / 1000;
|
||||
}
|
||||
|
||||
uint32_t acquireElapsedSecs() const {
|
||||
if (_state != GPSDutyState::ACQUIRING) return 0;
|
||||
return (millis() - _state_entered) / 1000;
|
||||
}
|
||||
|
||||
private:
|
||||
void _powerOn() {
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
|
||||
delay(10);
|
||||
#endif
|
||||
if (_stream) _stream->resetCounters();
|
||||
}
|
||||
|
||||
void _powerOff() {
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
|
||||
#endif
|
||||
}
|
||||
|
||||
void _setState(GPSDutyState s) {
|
||||
_state = s;
|
||||
_state_entered = millis();
|
||||
}
|
||||
|
||||
GPSDutyState _state;
|
||||
unsigned long _state_entered;
|
||||
unsigned long _last_fix_time;
|
||||
bool _got_fix;
|
||||
bool _time_synced;
|
||||
GPSStreamCounter* _stream;
|
||||
};
|
||||
|
||||
#endif // HAS_GPS
|
||||
@@ -72,10 +72,30 @@ void TDeckBoard::begin() {
|
||||
rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1);
|
||||
}
|
||||
|
||||
// Test BQ27220 communication
|
||||
// Test BQ27220 communication and configure design capacity
|
||||
#if HAS_BQ27220
|
||||
uint16_t voltage = getBattMilliVolts();
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - Battery voltage: %d mV", voltage);
|
||||
configureFuelGauge();
|
||||
#endif
|
||||
|
||||
// --- Early low-voltage protection ---
|
||||
// If we boot below the shutdown threshold, go straight to deep sleep
|
||||
// WITHOUT touching the filesystem. This breaks the brown-out reboot
|
||||
// loop that corrupts contacts when battery is deeply depleted (~2.5V).
|
||||
#if HAS_BQ27220 && defined(AUTO_SHUTDOWN_MILLIVOLTS)
|
||||
{
|
||||
uint16_t bootMv = getBattMilliVolts();
|
||||
if (bootMv > 0 && bootMv < AUTO_SHUTDOWN_MILLIVOLTS) {
|
||||
Serial.printf("CRITICAL: Boot voltage %dmV < %dmV — sleeping immediately\n",
|
||||
bootMv, AUTO_SHUTDOWN_MILLIVOLTS);
|
||||
// Don't mount SD, don't load contacts, don't pass Go.
|
||||
// Only wake on user button press (presumably after plugging in charger).
|
||||
esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
|
||||
esp_sleep_enable_ext1_wakeup(1ULL << PIN_USER_BTN, ESP_EXT1_WAKEUP_ANY_HIGH);
|
||||
esp_deep_sleep_start(); // CPU halts here
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - complete");
|
||||
@@ -123,4 +143,450 @@ uint8_t TDeckBoard::getBatteryPercent() {
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---- BQ27220 extended register helpers ----
|
||||
|
||||
#if HAS_BQ27220
|
||||
// Read a 16-bit register from BQ27220. Returns 0 on I2C error.
|
||||
static uint16_t bq27220_read16(uint8_t reg) {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(reg);
|
||||
if (Wire.endTransmission(false) != 0) return 0;
|
||||
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2) != 2) return 0;
|
||||
uint16_t val = Wire.read();
|
||||
val |= (Wire.read() << 8);
|
||||
return val;
|
||||
}
|
||||
|
||||
// Read a single byte from BQ27220 register.
|
||||
static uint8_t bq27220_read8(uint8_t reg) {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(reg);
|
||||
if (Wire.endTransmission(false) != 0) return 0;
|
||||
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)1) != 1) return 0;
|
||||
return Wire.read();
|
||||
}
|
||||
|
||||
// Write a 16-bit subcommand to BQ27220 Control register (0x00).
|
||||
// Subcommands control unsealing, config mode, sealing, etc.
|
||||
static bool bq27220_writeControl(uint16_t subcmd) {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x00); // Control register
|
||||
Wire.write(subcmd & 0xFF); // LSB first
|
||||
Wire.write((subcmd >> 8) & 0xFF); // MSB
|
||||
return Wire.endTransmission() == 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---- BQ27220 Design Capacity configuration ----
|
||||
// The BQ27220 ships with a 3000 mAh default. The T-Deck Pro uses a 2000 mAh
|
||||
// cell. This function checks on boot and writes the correct value via the
|
||||
// MAC Data Memory interface if needed. The value persists in battery-backed
|
||||
// RAM, so this typically only writes once (or after a full battery disconnect).
|
||||
//
|
||||
// Procedure follows TI TRM SLUUBD4A Section 6.1:
|
||||
// 1. Unseal → 2. Full Access → 3. Enter CFG_UPDATE
|
||||
// 4. Write Design Capacity via MAC → 5. Exit CFG_UPDATE → 6. Seal
|
||||
|
||||
bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
#if HAS_BQ27220
|
||||
// Read current design capacity from standard command register
|
||||
uint16_t currentDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
Serial.printf("BQ27220: Design Capacity = %d mAh (target %d)\n", currentDC, designCapacity_mAh);
|
||||
|
||||
if (currentDC == designCapacity_mAh) {
|
||||
// Design Capacity correct, but check if Full Charge Capacity is sane.
|
||||
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: Design Capacity already correct, FCC=%d mAh\n", fcc);
|
||||
if (fcc >= designCapacity_mAh * 3 / 2) {
|
||||
// FCC is >=150% of design — stale from factory defaults (typically 3000 mAh).
|
||||
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
|
||||
Serial.printf("BQ27220: FCC %d >> DC %d, checking Design Energy (target %d mWh)\n",
|
||||
fcc, designCapacity_mAh, designEnergy);
|
||||
|
||||
// Unseal to read data memory and issue RESET
|
||||
bq27220_writeControl(0x0414); delay(2);
|
||||
bq27220_writeControl(0x3672); delay(2);
|
||||
// Full Access
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
|
||||
// Read current Design Energy from data memory to check if it needs writing
|
||||
// Enter CFG_UPDATE to access data memory
|
||||
bq27220_writeControl(0x0090);
|
||||
bool ready = false;
|
||||
for (int i = 0; i < 50; i++) {
|
||||
delay(20);
|
||||
uint16_t opSt = bq27220_read16(BQ27220_REG_OP_STATUS);
|
||||
if (opSt & 0x0400) { ready = true; break; }
|
||||
}
|
||||
if (ready) {
|
||||
// Read Design Energy at data memory address 0x92A1
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
uint8_t oldMSB = bq27220_read8(0x40);
|
||||
uint8_t oldLSB = bq27220_read8(0x41);
|
||||
uint16_t currentDE = (oldMSB << 8) | oldLSB;
|
||||
|
||||
if (currentDE != designEnergy) {
|
||||
// Design Energy actually needs updating — write it
|
||||
uint8_t oldChk = bq27220_read8(0x60);
|
||||
uint8_t dLen = bq27220_read8(0x61);
|
||||
uint8_t newMSB = (designEnergy >> 8) & 0xFF;
|
||||
uint8_t newLSB = designEnergy & 0xFF;
|
||||
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
|
||||
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: DE old=%d new=%d mWh, writing\n", currentDE, designEnergy);
|
||||
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.write(newMSB); Wire.write(newLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60); Wire.write(newChk); Wire.write(dLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
// Exit with reinit since we actually changed data
|
||||
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
|
||||
delay(200);
|
||||
Serial.println("BQ27220: Design Energy written, exited CFG_UPDATE");
|
||||
} else {
|
||||
// DC=2000, DE=7400, Update Status=0x00, but FCC is stuck at 3000.
|
||||
// Diagnostic scan found the culprits:
|
||||
// 0x9106 = Qmax Cell 0 (IT Cfg class) — the raw capacity the
|
||||
// gauge uses for FCC calculation. Factory default 3000.
|
||||
// 0x929D = Stored FCC reference (Gas Gauging class, 2 bytes
|
||||
// before Design Capacity). Also stuck at 3000.
|
||||
//
|
||||
// Fix: overwrite both with designCapacity_mAh (2000).
|
||||
Serial.printf("BQ27220: DE correct (%d mWh) — fixing Qmax + stored FCC\n", currentDE);
|
||||
|
||||
// --- Helper lambda for MAC data memory 2-byte write ---
|
||||
// Reads old value + checksum, computes differential checksum, writes new value.
|
||||
auto writeDM16 = [](uint16_t addr, uint16_t newVal) -> bool {
|
||||
// Select address
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E);
|
||||
Wire.write(addr & 0xFF);
|
||||
Wire.write((addr >> 8) & 0xFF);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
uint8_t oldMSB = bq27220_read8(0x40);
|
||||
uint8_t oldLSB = bq27220_read8(0x41);
|
||||
uint8_t oldChk = bq27220_read8(0x60);
|
||||
uint8_t dLen = bq27220_read8(0x61);
|
||||
uint16_t oldVal = (oldMSB << 8) | oldLSB;
|
||||
|
||||
if (oldVal == newVal) {
|
||||
Serial.printf("BQ27220: [0x%04X] already %d, skip\n", addr, newVal);
|
||||
return true; // already correct
|
||||
}
|
||||
|
||||
uint8_t newMSB = (newVal >> 8) & 0xFF;
|
||||
uint8_t newLSB = newVal & 0xFF;
|
||||
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
|
||||
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: [0x%04X] %d -> %d\n", addr, oldVal, newVal);
|
||||
|
||||
// Write new value
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E);
|
||||
Wire.write(addr & 0xFF);
|
||||
Wire.write((addr >> 8) & 0xFF);
|
||||
Wire.write(newMSB);
|
||||
Wire.write(newLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
|
||||
// Write checksum
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60);
|
||||
Wire.write(newChk);
|
||||
Wire.write(dLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Overwrite Qmax Cell 0 (IT Cfg) — this is what FCC is derived from
|
||||
writeDM16(0x9106, designCapacity_mAh);
|
||||
|
||||
// Overwrite stored FCC reference (Gas Gauging, 2 bytes before DC)
|
||||
writeDM16(0x929D, designCapacity_mAh);
|
||||
|
||||
// Exit with reinit to apply the new values
|
||||
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
|
||||
delay(200);
|
||||
Serial.println("BQ27220: Qmax + stored FCC updated, exited CFG_UPDATE");
|
||||
}
|
||||
} else {
|
||||
Serial.println("BQ27220: Failed to enter CFG_UPDATE for DE check");
|
||||
}
|
||||
|
||||
// Seal first, then issue RESET.
|
||||
// RESET forces the gauge to fully reinitialize its Impedance Track
|
||||
// algorithm and recalculate FCC from the current DC/DE values.
|
||||
// This is the actual fix when DC and DE are correct but FCC is stuck.
|
||||
bq27220_writeControl(0x0030); // SEAL
|
||||
delay(5);
|
||||
Serial.println("BQ27220: Issuing RESET to force FCC recalculation...");
|
||||
bq27220_writeControl(0x0041); // RESET
|
||||
delay(2000); // Full reset needs generous settle time
|
||||
|
||||
fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: FCC after RESET: %d mAh (target <= %d)\n", fcc, designCapacity_mAh);
|
||||
|
||||
if (fcc > designCapacity_mAh * 3 / 2) {
|
||||
// RESET didn't fix FCC — the gauge IT algorithm is stubbornly
|
||||
// retaining its learned value. This typically resolves after one
|
||||
// full charge/discharge cycle. Software clamp in
|
||||
// getFullChargeCapacity() ensures correct display regardless.
|
||||
Serial.printf("BQ27220: FCC still stale at %d — software clamp active\n", fcc);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Serial.printf("BQ27220: Updating Design Capacity from %d to %d mAh\n", currentDC, designCapacity_mAh);
|
||||
|
||||
// Step 1: Unseal (default unseal keys)
|
||||
bq27220_writeControl(0x0414);
|
||||
delay(2);
|
||||
bq27220_writeControl(0x3672);
|
||||
delay(2);
|
||||
|
||||
// Step 2: Enter Full Access mode
|
||||
bq27220_writeControl(0xFFFF);
|
||||
delay(2);
|
||||
bq27220_writeControl(0xFFFF);
|
||||
delay(2);
|
||||
|
||||
// Step 3: Enter CFG_UPDATE mode
|
||||
bq27220_writeControl(0x0090);
|
||||
|
||||
// Wait for CFGUPMODE bit (bit 10) in OperationStatus register
|
||||
bool cfgReady = false;
|
||||
for (int i = 0; i < 50; i++) {
|
||||
delay(20);
|
||||
uint16_t opStatus = bq27220_read16(BQ27220_REG_OP_STATUS);
|
||||
Serial.printf("BQ27220: OperationStatus = 0x%04X (attempt %d)\n", opStatus, i);
|
||||
if (opStatus & 0x0400) { // CFGUPMODE is bit 10
|
||||
cfgReady = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!cfgReady) {
|
||||
Serial.println("BQ27220: ERROR - Timeout waiting for CFGUPDATE mode");
|
||||
bq27220_writeControl(0x0092); // Try to exit cleanly
|
||||
bq27220_writeControl(0x0030); // Re-seal
|
||||
return false;
|
||||
}
|
||||
Serial.println("BQ27220: Entered CFGUPDATE mode");
|
||||
|
||||
// Step 4: Write Design Capacity via MAC Data Memory interface
|
||||
// Design Capacity mAh lives at data memory address 0x929F
|
||||
|
||||
// 4a. Select the data memory block by writing address to 0x3E-0x3F
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); // MACDataControl register
|
||||
Wire.write(0x9F); // Address low byte
|
||||
Wire.write(0x92); // Address high byte
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
// 4b. Read old data (MSB, LSB) and checksum for differential update
|
||||
uint8_t oldMSB = bq27220_read8(0x40);
|
||||
uint8_t oldLSB = bq27220_read8(0x41);
|
||||
uint8_t oldChksum = bq27220_read8(0x60);
|
||||
uint8_t dataLen = bq27220_read8(0x61);
|
||||
|
||||
Serial.printf("BQ27220: Old DC bytes=0x%02X 0x%02X chk=0x%02X len=%d\n",
|
||||
oldMSB, oldLSB, oldChksum, dataLen);
|
||||
|
||||
// 4c. Compute new values (BQ27220 stores big-endian in data memory)
|
||||
uint8_t newMSB = (designCapacity_mAh >> 8) & 0xFF;
|
||||
uint8_t newLSB = designCapacity_mAh & 0xFF;
|
||||
|
||||
// Differential checksum: remove old bytes, add new bytes
|
||||
uint8_t temp = (255 - oldChksum - oldMSB - oldLSB);
|
||||
uint8_t newChksum = 255 - ((temp + newMSB + newLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: New DC bytes=0x%02X 0x%02X chk=0x%02X\n",
|
||||
newMSB, newLSB, newChksum);
|
||||
|
||||
// 4d. Write address + new data as a single block transaction
|
||||
// BQ27220 MAC requires: [0x3E] [addr_lo] [addr_hi] [data...]
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); // Start at MACDataControl
|
||||
Wire.write(0x9F); // Address low byte
|
||||
Wire.write(0x92); // Address high byte
|
||||
Wire.write(newMSB); // Data byte 0 (at 0x40)
|
||||
Wire.write(newLSB); // Data byte 1 (at 0x41)
|
||||
uint8_t writeResult = Wire.endTransmission();
|
||||
Serial.printf("BQ27220: Write block result = %d\n", writeResult);
|
||||
|
||||
// 4e. Write updated checksum and length
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60);
|
||||
Wire.write(newChksum);
|
||||
Wire.write(dataLen);
|
||||
writeResult = Wire.endTransmission();
|
||||
Serial.printf("BQ27220: Write checksum result = %d\n", writeResult);
|
||||
delay(10);
|
||||
|
||||
// 4f. Verify the write took effect before exiting config mode
|
||||
// Re-read the block to confirm
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E);
|
||||
Wire.write(0x9F);
|
||||
Wire.write(0x92);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
uint8_t verMSB = bq27220_read8(0x40);
|
||||
uint8_t verLSB = bq27220_read8(0x41);
|
||||
Serial.printf("BQ27220: Verify in CFGUPDATE: DC bytes=0x%02X 0x%02X (%d mAh)\n",
|
||||
verMSB, verLSB, (verMSB << 8) | verLSB);
|
||||
|
||||
// Step 4g: Also update Design Energy (address 0x92A1) while in CFG_UPDATE.
|
||||
// Design Energy = capacity × 3.7V (nominal LiPo voltage).
|
||||
// The gauge uses both DC and DE to compute Full Charge Capacity.
|
||||
{
|
||||
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
uint8_t deOldMSB = bq27220_read8(0x40);
|
||||
uint8_t deOldLSB = bq27220_read8(0x41);
|
||||
uint8_t deOldChk = bq27220_read8(0x60);
|
||||
uint8_t deLen = bq27220_read8(0x61);
|
||||
|
||||
uint8_t deNewMSB = (designEnergy >> 8) & 0xFF;
|
||||
uint8_t deNewLSB = designEnergy & 0xFF;
|
||||
uint8_t deTemp = (255 - deOldChk - deOldMSB - deOldLSB);
|
||||
uint8_t deNewChk = 255 - ((deTemp + deNewMSB + deNewLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: Design Energy: old=%d new=%d mWh\n",
|
||||
(deOldMSB << 8) | deOldLSB, designEnergy);
|
||||
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.write(deNewMSB); Wire.write(deNewLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60); Wire.write(deNewChk); Wire.write(deLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
}
|
||||
|
||||
// Step 5: Exit CFG_UPDATE (with reinit to apply changes immediately)
|
||||
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
|
||||
Serial.println("BQ27220: Sent EXIT_CFG_UPDATE_REINIT, waiting...");
|
||||
delay(200); // Allow gauge to reinitialize
|
||||
|
||||
// Verify
|
||||
uint16_t verifyDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
Serial.printf("BQ27220: Design Capacity now reads %d mAh (expected %d)\n",
|
||||
verifyDC, designCapacity_mAh);
|
||||
|
||||
uint16_t newFCC = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: Full Charge Capacity: %d mAh\n", newFCC);
|
||||
|
||||
if (verifyDC == designCapacity_mAh) {
|
||||
Serial.println("BQ27220: Configuration SUCCESS");
|
||||
} else {
|
||||
Serial.println("BQ27220: Configuration FAILED");
|
||||
}
|
||||
|
||||
// Step 6: Seal the device
|
||||
bq27220_writeControl(0x0030);
|
||||
delay(5);
|
||||
|
||||
// Step 7: Force full gauge RESET to reinitialize FCC from new DC/DE.
|
||||
// Without this, the Impedance Track algorithm retains the old FCC
|
||||
// (often 3000 mAh from factory) until a full charge/discharge cycle.
|
||||
bq27220_writeControl(0x0041); // RESET
|
||||
delay(1000); // Gauge needs time to fully reinitialize
|
||||
|
||||
// Re-verify after hard reset
|
||||
verifyDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
newFCC = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: Post-RESET DC=%d FCC=%d mAh\n", verifyDC, newFCC);
|
||||
|
||||
return verifyDC == designCapacity_mAh;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
int16_t TDeckBoard::getAvgCurrent() {
|
||||
#if HAS_BQ27220
|
||||
return (int16_t)bq27220_read16(BQ27220_REG_AVG_CURRENT);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int16_t TDeckBoard::getAvgPower() {
|
||||
#if HAS_BQ27220
|
||||
return (int16_t)bq27220_read16(BQ27220_REG_AVG_POWER);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t TDeckBoard::getTimeToEmpty() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_TIME_TO_EMPTY);
|
||||
#else
|
||||
return 0xFFFF;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t TDeckBoard::getRemainingCapacity() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_REMAIN_CAP);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t TDeckBoard::getFullChargeCapacity() {
|
||||
#if HAS_BQ27220
|
||||
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
// Clamp to design capacity — the gauge may report a stale factory FCC
|
||||
// (e.g. 3000 mAh) until it completes a full learning cycle. Never let
|
||||
// the reported FCC exceed what the actual cell can hold.
|
||||
if (fcc > BQ27220_DESIGN_CAPACITY_MAH) fcc = BQ27220_DESIGN_CAPACITY_MAH;
|
||||
return fcc;
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t TDeckBoard::getDesignCapacity() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int16_t TDeckBoard::getBattTemperature() {
|
||||
#if HAS_BQ27220
|
||||
uint16_t raw = bq27220_read16(BQ27220_REG_TEMPERATURE);
|
||||
// BQ27220 returns 0.1°K, convert to 0.1°C (273.1K = 0°C)
|
||||
return (int16_t)(raw - 2731);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
@@ -7,11 +7,24 @@
|
||||
#include <driver/rtc_io.h>
|
||||
|
||||
// BQ27220 Fuel Gauge Registers
|
||||
#define BQ27220_REG_VOLTAGE 0x08
|
||||
#define BQ27220_REG_CURRENT 0x0C
|
||||
#define BQ27220_REG_SOC 0x2C
|
||||
#define BQ27220_REG_TEMPERATURE 0x06 // Temperature (0.1°K)
|
||||
#define BQ27220_REG_VOLTAGE 0x08
|
||||
#define BQ27220_REG_CURRENT 0x0C // Instantaneous current (mA, signed)
|
||||
#define BQ27220_REG_SOC 0x2C
|
||||
#define BQ27220_REG_REMAIN_CAP 0x10 // Remaining capacity (mAh)
|
||||
#define BQ27220_REG_FULL_CAP 0x12 // Full charge capacity (mAh)
|
||||
#define BQ27220_REG_AVG_CURRENT 0x14 // Average current (mA, signed)
|
||||
#define BQ27220_REG_TIME_TO_EMPTY 0x16 // Minutes until empty
|
||||
#define BQ27220_REG_AVG_POWER 0x24 // Average power (mW, signed)
|
||||
#define BQ27220_REG_DESIGN_CAP 0x3C // Design capacity (mAh, read-only standard cmd)
|
||||
#define BQ27220_REG_OP_STATUS 0x3A // Operation status
|
||||
#define BQ27220_I2C_ADDR 0x55
|
||||
|
||||
// T-Deck Pro battery capacity (all variants use 1400 mAh cell)
|
||||
#ifndef BQ27220_DESIGN_CAPACITY_MAH
|
||||
#define BQ27220_DESIGN_CAPACITY_MAH 1400
|
||||
#endif
|
||||
|
||||
class TDeckBoard : public ESP32Board {
|
||||
public:
|
||||
void begin();
|
||||
@@ -52,6 +65,30 @@ public:
|
||||
// Read state of charge percentage from BQ27220
|
||||
uint8_t getBatteryPercent();
|
||||
|
||||
// Read average current in mA (negative = discharging, positive = charging)
|
||||
int16_t getAvgCurrent();
|
||||
|
||||
// Read average power in mW (negative = discharging, positive = charging)
|
||||
int16_t getAvgPower();
|
||||
|
||||
// Read time-to-empty in minutes (0xFFFF if charging/unavailable)
|
||||
uint16_t getTimeToEmpty();
|
||||
|
||||
// Read remaining capacity in mAh
|
||||
uint16_t getRemainingCapacity();
|
||||
|
||||
// Read full charge capacity in mAh (learned value, may need cycling to update)
|
||||
uint16_t getFullChargeCapacity();
|
||||
|
||||
// Read design capacity in mAh (the configured battery size)
|
||||
uint16_t getDesignCapacity();
|
||||
|
||||
// Read battery temperature in 0.1°C units (e.g., 256 = 25.6°C)
|
||||
int16_t getBattTemperature();
|
||||
|
||||
// Configure BQ27220 design capacity (checks on boot, writes only if wrong)
|
||||
bool configureFuelGauge(uint16_t designCapacity_mAh = BQ27220_DESIGN_CAPACITY_MAH);
|
||||
|
||||
const char* getManufacturerName() const {
|
||||
return "LilyGo T-Deck Pro";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -62,6 +62,8 @@ build_flags =
|
||||
-D EINK_MOSI=33
|
||||
-D EINK_BL=45
|
||||
-D EINK_NOT_HIBERNATE=1
|
||||
-D HAS_BQ27220=1
|
||||
-D AUTO_SHUTDOWN_MILLIVOLTS=2800
|
||||
-D EINK_LIMIT_FASTREFRESH=10
|
||||
-D EINK_LIMIT_GHOSTING_PX=2000
|
||||
-D DISPLAY_ROTATION=0
|
||||
@@ -80,6 +82,7 @@ build_flags =
|
||||
-D PIN_DISPLAY_BL=45
|
||||
-D PIN_USER_BTN=0
|
||||
-D CST328_PIN_RST=38
|
||||
-D ARDUINO_LOOP_STACK_SIZE=32768
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/LilyGo_TDeck_Pro>
|
||||
+<helpers/sensors/*.cpp>
|
||||
@@ -88,35 +91,108 @@ lib_deps =
|
||||
${sensor_base.lib_deps}
|
||||
zinggjm/GxEPD2@^1.5.9
|
||||
adafruit/Adafruit GFX Library@^1.11.0
|
||||
bakercp/CRC32@^2.0.0
|
||||
bitbank2/PNGdec@^1.0.1
|
||||
|
||||
[env:LilyGo_TDeck_Pro_companion_radio_usb]
|
||||
; ---------------------------------------------------------------------------
|
||||
; Meck unified builds — one codebase, six variants via build flags
|
||||
; ---------------------------------------------------------------------------
|
||||
|
||||
; Audio + BLE companion (audio-player hardware with BLE phone bridging)
|
||||
; MAX_CONTACTS=500 is near BLE protocol ceiling (MAX_CONTACTS/2 sent as uint8_t, max 510)
|
||||
[env:meck_audio_ble]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
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
|
||||
|
||||
[env:LilyGo_TDeck_Pro_companion_radio_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>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
|
||||
bitbank2/JPEGDEC
|
||||
|
||||
; Audio + WiFi companion (audio-player hardware with WiFi app bridging)
|
||||
; No BLE — WiFi companion uses SerialWifiInterface (TCP socket on port 5000).
|
||||
; Connect via MeshCore web app, meshcore.js, or Python CLI over local network.
|
||||
; No BLE protocol ceiling on contacts; bumped to 1500 (PSRAM-backed).
|
||||
; WiFi always on from boot — web reader works without teardown, extra free heap.
|
||||
; WiFi credentials loaded from SD card at runtime (/web/wifi.cfg).
|
||||
; Configure via Settings > WiFi Setup, or through the web reader.
|
||||
[env:meck_audio_wifi]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=1500
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D MECK_WIFI_COMPANION=1
|
||||
-D TCP_PORT=5000
|
||||
-D WIFI_DEBUG_LOGGING=1
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D MECK_AUDIO_VARIANT
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.8.1WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
|
||||
bitbank2/JPEGDEC
|
||||
|
||||
; Audio standalone (audio-player hardware, no BLE/WiFi — maximum battery life)
|
||||
; No MECK_WEB_READER: WiFi power draw conflicts with zero-radio-power design.
|
||||
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
|
||||
[env:meck_audio_standalone]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=1500
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D MECK_AUDIO_VARIANT
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
|
||||
bitbank2/JPEGDEC
|
||||
|
||||
; 4G + BLE companion (4G modem hardware, no audio — GPIO conflict with PCM5102A)
|
||||
; MAX_CONTACTS=500 is near BLE protocol ceiling (MAX_CONTACTS/2 sent as uint8_t, max 510)
|
||||
[env:meck_4g_ble]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=500
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.8.14G"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -127,20 +203,58 @@ lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
[env:LilyGo_TDeck_Pro_repeater]
|
||||
; 4G + WiFi companion (4G modem hardware with WiFi app bridging, no audio)
|
||||
; No BLE — WiFi companion uses SerialWifiInterface (TCP socket on port 5000).
|
||||
; Connect via MeshCore web app, meshcore.js, or Python CLI over local network.
|
||||
; WiFi credentials loaded from SD card at runtime (/web/wifi.cfg).
|
||||
; Configure via Settings > WiFi Setup, or through the web reader.
|
||||
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
|
||||
[env:meck_4g_wifi]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-D ADVERT_NAME='"TDeck Pro Repeater"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
-D NO_OTA=1
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=1500
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D MECK_WIFI_COMPANION=1
|
||||
-D TCP_PORT=5000
|
||||
-D WIFI_DEBUG_LOGGING=1
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.8.14G.WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<../examples/simple_repeater>
|
||||
+<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}
|
||||
me-no-dev/AsyncTCP @ ^1.1.1
|
||||
me-no-dev/ESPAsyncWebServer @ ^1.2.3
|
||||
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.8.14G.SA"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
Reference in New Issue
Block a user