mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96f171a1fc | ||
|
|
099d9a5b6c | ||
|
|
fce999347f | ||
|
|
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)
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
45
README.md
45
README.md
@@ -1,8 +1,6 @@
|
||||
## Meshcore + Fork = Meck
|
||||
This fork was created specifically to focus on enabling BLE companion firmware for the LilyGo T-Deck Pro. Created with the assistance of Claude AI using Meshcore v1.11 code.
|
||||
|
||||
⭐ ***Please note as of 1 Feb 2026, the T-Deck Pro repeater & usb firmware has not been finalised nor confirmed as functioning.*** ⭐
|
||||
|
||||
### Contents
|
||||
- [T-Deck Pro Keyboard Controls](#t-deck-pro-keyboard-controls)
|
||||
- [Navigation (Home Screen)](#navigation-home-screen)
|
||||
@@ -24,10 +22,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
|
||||
|
||||
@@ -251,7 +250,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 +264,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 +285,35 @@ 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
|
||||
- [ ] Fix M4B rendering to enable chaptered audiobook playback
|
||||
- [ ] Expand SMS app to enable phone calls
|
||||
- [ ] Better JPEG and PNG decoding
|
||||
- [ ] Improve EPUB rendering and EPUB format handling
|
||||
- [ ] Map support with GPS
|
||||
- [ ] Basic web reader app for text-centric websites
|
||||
|
||||
## 📞 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.
|
||||
165
SMS & Phone App Guide.md
Normal file
165
SMS & Phone App Guide.md
Normal file
@@ -0,0 +1,165 @@
|
||||
## SMS & Phone App (4G variant only) - Meck v0.9.3 (Alpha)
|
||||
|
||||
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.
|
||||
|
||||
### Key Mapping
|
||||
|
||||
| Context | Key | Action |
|
||||
|---------|-----|--------|
|
||||
| Home screen | T | Open SMS & Phone app |
|
||||
| Inbox | W / S | Scroll conversations |
|
||||
| Inbox | Enter | Open conversation |
|
||||
| Inbox | C | Compose new SMS (enter phone number) |
|
||||
| Inbox | D | Open contacts directory |
|
||||
| Inbox | Q | Back to home screen |
|
||||
| Conversation | W / S | Scroll messages |
|
||||
| Conversation | C | Reply to this conversation |
|
||||
| Conversation | 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 |
|
||||
| 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
|
||||
|
||||
Press **F** to call from either the conversation view or the contacts
|
||||
directory. The display switches to a dialing screen showing the contact name
|
||||
(or phone number) and an animated progress indicator. Once the remote party
|
||||
answers, the screen transitions to the in-call view with a live call timer.
|
||||
|
||||
There are two ways to start a call:
|
||||
|
||||
1. **From a conversation** — open a conversation and press **F**. You can call
|
||||
any number you have previously exchanged messages with, whether or not it is
|
||||
saved as a named contact.
|
||||
2. **From the contacts directory** — press **D** from the inbox, scroll to a
|
||||
contact, and press **F**.
|
||||
|
||||
> **Note:** There is currently no way to dial an arbitrary phone number without
|
||||
> first creating a conversation. To call a new number, press **C** from the
|
||||
> inbox to compose a new SMS, enter the phone number, send a short message,
|
||||
> then open the resulting conversation and press **F** to call.
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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 |
|
||||
| Cannot dial a number | You must first have a conversation or saved contact for that number. Send a short SMS to create a conversation, then press F |
|
||||
|
||||
> **Note:** The SMS & Phone app is only available on the 4G modem variant of
|
||||
> the T-Deck Pro. It is not present on the audio or standalone BLE builds due
|
||||
> to shared GPIO pin conflicts between the A7682E modem and PCM5102A DAC.
|
||||
57
Web App Guide.md
Normal file
57
Web App Guide.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Web Reader - Integration Summary
|
||||
|
||||
### Conditional Compilation
|
||||
All web reader code is wrapped in `#ifdef MECK_WEB_READER` guards. The flag is set:
|
||||
- **meck_audio_ble**: Yes (`-D MECK_WEB_READER=1`) — WiFi available via BLE radio stack
|
||||
- **meck_4g_ble**: Yes (`-D MECK_WEB_READER=1`) — WiFi now, PPP via A7682E in future
|
||||
- **meck_audio_standalone**: No — excluded to preserve zero-radio-power design
|
||||
|
||||
### 4G Modem / PPP Support
|
||||
The web reader uses `isNetworkAvailable()` which checks both WiFi and (future) PPP connectivity. The `fetchPage()` method uses ESP32's standard `HTTPClient` which routes through whatever network interface is active — WiFi or PPP.
|
||||
|
||||
When PPP support is added to the 4G modem driver, the web reader will work over cellular automatically without code changes. The `isNetworkAvailable()` method has a `TODO` placeholder for the PPP status check.
|
||||
|
||||
---
|
||||
|
||||
## Key Bindings
|
||||
|
||||
### From Home Screen
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `b` | Open web reader |
|
||||
|
||||
### Web Reader - Home View
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `w` / `s` | Navigate up/down in bookmarks/history |
|
||||
| `Enter` | Select URL bar or 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 |
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
## SD Card Structure
|
||||
```
|
||||
/web/
|
||||
wifi.cfg - Saved WiFi credentials (auto-reconnect)
|
||||
bookmarks.txt - One URL per line
|
||||
history.txt - Recent URLs, newest first
|
||||
```
|
||||
@@ -40,7 +40,8 @@ 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) {}
|
||||
|
||||
@@ -439,7 +439,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 +524,13 @@ void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint3
|
||||
const char *text) {
|
||||
markConnectionActive(from); // in case this is from a server, and we have a connection
|
||||
queueMessage(from, TXT_TYPE_CLI_DATA, pkt, sender_timestamp, NULL, 0, text);
|
||||
|
||||
// Forward CLI response to UI admin screen if admin session is active
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (_admin_contact_idx >= 0 && _ui) {
|
||||
_ui->onAdminCliResponse(from.name, text);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
||||
@@ -574,7 +582,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 +659,53 @@ bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password) {
|
||||
ContactInfo contact;
|
||||
if (!getContactByIdx(contact_idx, contact)) return false;
|
||||
|
||||
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
|
||||
if (!recipient) return false;
|
||||
|
||||
uint32_t est_timeout;
|
||||
int result = sendLogin(*recipient, password, est_timeout);
|
||||
if (result == MSG_SEND_FAILED) {
|
||||
MESH_DEBUG_PRINTLN("UI: Admin login send failed to %s", recipient->name);
|
||||
return false;
|
||||
}
|
||||
|
||||
clearPendingReqs();
|
||||
memcpy(&pending_login, recipient->id.pub_key, 4);
|
||||
_admin_contact_idx = contact_idx;
|
||||
|
||||
MESH_DEBUG_PRINTLN("UI: Admin login sent to %s (%s), timeout=%dms",
|
||||
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
|
||||
est_timeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MyMesh::uiSendCliCommand(uint32_t contact_idx, const char* command) {
|
||||
ContactInfo contact;
|
||||
if (!getContactByIdx(contact_idx, contact)) return false;
|
||||
|
||||
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
|
||||
if (!recipient) return false;
|
||||
|
||||
uint32_t timestamp = getRTCClock()->getCurrentTimeUnique();
|
||||
uint32_t est_timeout;
|
||||
int result = sendCommandData(*recipient, timestamp, 0, command, est_timeout);
|
||||
if (result == MSG_SEND_FAILED) {
|
||||
MESH_DEBUG_PRINTLN("UI: CLI command send failed to %s: %s", recipient->name, command);
|
||||
return false;
|
||||
}
|
||||
|
||||
_admin_contact_idx = contact_idx;
|
||||
|
||||
MESH_DEBUG_PRINTLN("UI: CLI command sent to %s (%s): %s, timeout=%dms",
|
||||
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
|
||||
command, est_timeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
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 +764,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 +782,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
|
||||
@@ -897,6 +968,7 @@ 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;
|
||||
|
||||
// defaults
|
||||
memset(&_prefs, 0, sizeof(_prefs));
|
||||
@@ -1111,6 +1183,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 +1193,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
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
#define FIRMWARE_VER_CODE 8
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "12 Feb 2026"
|
||||
#define FIRMWARE_BUILD_DATE "24 Feb 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v0.8.5"
|
||||
#define FIRMWARE_VERSION "Meck v0.9.3"
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
|
||||
@@ -29,4 +29,5 @@ 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)
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
#include <Arduino.h> // needed for PlatformIO
|
||||
#include <esp_bt.h> // for esp_bt_controller_mem_release (web reader WiFi)
|
||||
#include <Mesh.h>
|
||||
#include "MyMesh.h"
|
||||
#include "variant.h" // Board-specific defines (HAS_GPS, etc.)
|
||||
@@ -15,6 +16,10 @@
|
||||
#include "ContactsScreen.h"
|
||||
#include "ChannelScreen.h"
|
||||
#include "SettingsScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
#ifdef MECK_WEB_READER
|
||||
#include "WebReaderScreen.h"
|
||||
#endif
|
||||
extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus
|
||||
|
||||
TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire);
|
||||
@@ -32,6 +37,11 @@
|
||||
static bool composeDM = false;
|
||||
static int composeDMContactIdx = -1;
|
||||
static char composeDMName[32];
|
||||
#ifdef MECK_WEB_READER
|
||||
static unsigned long lastWebReaderRefresh = 0;
|
||||
static bool webReaderNeedsRefresh = false;
|
||||
static bool webReaderTextEntry = false; // True when URL/password entry active
|
||||
#endif
|
||||
// AGC reset - periodically re-assert RX boosted gain to prevent sensitivity drift
|
||||
#define AGC_RESET_INTERVAL_MS 500
|
||||
static unsigned long lastAGCReset = 0;
|
||||
@@ -47,6 +57,26 @@
|
||||
// Notes mode state
|
||||
static bool notesMode = false;
|
||||
|
||||
// Audiobook player — Audio object is heap-allocated on first use to avoid
|
||||
// consuming ~40KB of DMA/decode buffers at boot (starves BLE stack).
|
||||
// Audiobook player — Audio object is heap-allocated on first use to avoid
|
||||
// consuming ~40KB of DMA/decode buffers at boot (starves BLE stack).
|
||||
// Not available on 4G variant (I2S pins conflict with modem control lines).
|
||||
#ifndef HAS_4G_MODEM
|
||||
#include "AudiobookPlayerScreen.h"
|
||||
#include "Audio.h"
|
||||
Audio* audio = nullptr;
|
||||
#endif
|
||||
static bool audiobookMode = false;
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "ModemManager.h"
|
||||
#include "SMSStore.h"
|
||||
#include "SMSContacts.h"
|
||||
#include "SMSScreen.h"
|
||||
static bool smsMode = false;
|
||||
#endif
|
||||
|
||||
// Power management
|
||||
#if HAS_GPS
|
||||
GPSDutyCycle gpsDuty;
|
||||
@@ -320,6 +350,9 @@ void setup() {
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("setup() - radio_init() done");
|
||||
|
||||
// CPU frequency scaling — drop to 80 MHz for idle mesh listening
|
||||
cpuPower.begin();
|
||||
|
||||
MESH_DEBUG_PRINTLN("setup() - about to call fast_rng.begin()");
|
||||
fast_rng.begin(radio_get_rng_seed());
|
||||
MESH_DEBUG_PRINTLN("setup() - fast_rng.begin() done");
|
||||
@@ -404,16 +437,44 @@ void setup() {
|
||||
MESH_DEBUG_PRINTLN("setup() - SPIFFS.begin() done");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Early SD card init  needed BEFORE the_mesh.begin() so we can restore
|
||||
// Early SD card init  needed BEFORE the_mesh.begin() so we can restore
|
||||
// settings from a previous firmware flash. The display SPI bus is already
|
||||
// up (display.begin() ran earlier), so SD can share it now.
|
||||
// ---------------------------------------------------------------------------
|
||||
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
|
||||
{
|
||||
// Deselect ALL SPI devices before SD init to prevent bus contention.
|
||||
// E-ink, LoRa, and SD share the same SPI bus (SCK=36, MOSI=33, MISO=47).
|
||||
// If LoRa CS is still asserted from board/radio init, it responds on the
|
||||
// shared MISO line and corrupts SD card replies (CMD0 fails intermittently).
|
||||
pinMode(SDCARD_CS, OUTPUT);
|
||||
digitalWrite(SDCARD_CS, HIGH); // Deselect SD initially
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
if (SD.begin(SDCARD_CS, displaySpi, 4000000)) {
|
||||
pinMode(PIN_EINK_CS, OUTPUT);
|
||||
digitalWrite(PIN_EINK_CS, HIGH);
|
||||
|
||||
pinMode(LORA_CS, OUTPUT);
|
||||
digitalWrite(LORA_CS, HIGH);
|
||||
|
||||
// SD cards need 74+ SPI clock cycles after power stabilization before
|
||||
// accepting CMD0. A brief delay avoids race conditions on cold boot
|
||||
// or with slow-starting cards.
|
||||
delay(100);
|
||||
|
||||
// Retry loop — some SD cards are slow to initialise, especially on
|
||||
// cold boot or marginal USB power. Three attempts with increasing
|
||||
// settle time covers the vast majority of transient failures.
|
||||
bool mounted = false;
|
||||
for (int attempt = 0; attempt < 3 && !mounted; attempt++) {
|
||||
if (attempt > 0) {
|
||||
digitalWrite(SDCARD_CS, HIGH); // Ensure CS released between retries
|
||||
delay(250);
|
||||
MESH_DEBUG_PRINTLN("setup() - SD card retry %d/3", attempt + 1);
|
||||
}
|
||||
mounted = SD.begin(SDCARD_CS, displaySpi, 4000000);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
sdCardReady = true;
|
||||
MESH_DEBUG_PRINTLN("setup() - SD card initialized (early)");
|
||||
|
||||
@@ -422,7 +483,7 @@ void setup() {
|
||||
MESH_DEBUG_PRINTLN("setup() - Settings restored from SD backup");
|
||||
}
|
||||
} else {
|
||||
MESH_DEBUG_PRINTLN("setup() - SD card not available");
|
||||
MESH_DEBUG_PRINTLN("setup() - SD card not available after 3 attempts");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -518,8 +579,39 @@ void setup() {
|
||||
notesScr->setSDReady(true);
|
||||
}
|
||||
|
||||
// Audiobook player screen creation is deferred to first use (case 'p' in
|
||||
// handleKeyboardInput) to avoid allocating Audio I2S/DMA buffers at boot,
|
||||
// which would starve BLE of heap memory.
|
||||
MESH_DEBUG_PRINTLN("setup() - Audiobook player deferred (lazy init on first use)");
|
||||
|
||||
// Do an initial settings backup to SD (captures any first-boot defaults)
|
||||
backupSettingsToSD();
|
||||
|
||||
// SMS / 4G modem init (after SD is ready)
|
||||
#ifdef HAS_4G_MODEM
|
||||
{
|
||||
smsStore.begin();
|
||||
smsContacts.begin();
|
||||
|
||||
// Tell SMS screen that SD is ready
|
||||
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
|
||||
if (smsScr) {
|
||||
smsScr->setSDReady(true);
|
||||
}
|
||||
|
||||
// Start modem if enabled in config (default = enabled)
|
||||
bool modemEnabled = ModemManager::loadEnabledConfig();
|
||||
if (modemEnabled) {
|
||||
modemManager.begin();
|
||||
MESH_DEBUG_PRINTLN("setup() - 4G modem manager started");
|
||||
} else {
|
||||
// Ensure modem power is off (kills red LED too)
|
||||
pinMode(MODEM_POWER_EN, OUTPUT);
|
||||
digitalWrite(MODEM_POWER_EN, LOW);
|
||||
MESH_DEBUG_PRINTLN("setup() - 4G modem disabled by config");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -540,7 +632,7 @@ void setup() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// GPS duty cycle — honour saved pref, default to enabled on first boot
|
||||
// GPS duty cycle  honour saved pref, default to enabled on first boot
|
||||
#if HAS_GPS
|
||||
{
|
||||
bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled;
|
||||
@@ -555,9 +647,6 @@ void setup() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// CPU frequency scaling — drop to 80 MHz for idle mesh listening
|
||||
cpuPower.begin();
|
||||
|
||||
// T-Deck Pro: BLE starts disabled for standalone-first operation
|
||||
// User can toggle it on from the Bluetooth home page (Enter or long-press)
|
||||
#if defined(LilyGo_TDeck_Pro) && defined(BLE_PIN_CODE)
|
||||
@@ -565,13 +654,15 @@ void setup() {
|
||||
MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)");
|
||||
#endif
|
||||
|
||||
Serial.printf("setup() complete — free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ===");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
the_mesh.loop();
|
||||
|
||||
// GPS duty cycle — check for fix and manage power state
|
||||
// GPS duty cycle  check for fix and manage power state
|
||||
#if HAS_GPS
|
||||
{
|
||||
bool gps_hw_on = gpsDuty.loop();
|
||||
@@ -588,6 +679,95 @@ void loop() {
|
||||
|
||||
// CPU frequency auto-timeout back to idle
|
||||
cpuPower.loop();
|
||||
|
||||
// Audiobook: service audio decode regardless of which screen is active
|
||||
#ifndef HAS_4G_MODEM
|
||||
{
|
||||
AudiobookPlayerScreen* abPlayer =
|
||||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||||
if (abPlayer) {
|
||||
abPlayer->audioTick();
|
||||
// Keep CPU at high freq during active audio decode
|
||||
if (abPlayer->isAudioActive()) {
|
||||
cpuPower.setBoost();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// SMS: poll for incoming messages from modem
|
||||
#ifdef HAS_4G_MODEM
|
||||
{
|
||||
SMSIncoming incoming;
|
||||
while (modemManager.recvSMS(incoming)) {
|
||||
// Save to store and notify UI
|
||||
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
|
||||
if (smsScr) {
|
||||
smsScr->onIncomingSMS(incoming.phone, incoming.body, incoming.timestamp);
|
||||
}
|
||||
|
||||
// Alert + buzzer
|
||||
char alertBuf[48];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "SMS: %s", incoming.phone);
|
||||
ui_task.showAlert(alertBuf, 2000);
|
||||
ui_task.notify(UIEventType::contactMessage);
|
||||
|
||||
Serial.printf("[SMS] Received from %s: %.40s...\n", incoming.phone, incoming.body);
|
||||
}
|
||||
|
||||
// Poll for voice call events from modem
|
||||
CallEvent callEvt;
|
||||
while (modemManager.pollCallEvent(callEvt)) {
|
||||
SMSScreen* smsScr2 = (SMSScreen*)ui_task.getSMSScreen();
|
||||
if (smsScr2) {
|
||||
smsScr2->onCallEvent(callEvt);
|
||||
}
|
||||
|
||||
if (callEvt.type == CallEventType::INCOMING) {
|
||||
// Incoming call — auto-switch to SMS screen if not already there
|
||||
char alertBuf[48];
|
||||
char dispName[SMS_CONTACT_NAME_LEN];
|
||||
smsContacts.displayName(callEvt.phone, dispName, sizeof(dispName));
|
||||
snprintf(alertBuf, sizeof(alertBuf), "Call: %s", dispName);
|
||||
ui_task.showAlert(alertBuf, 3000);
|
||||
ui_task.notify(UIEventType::contactMessage);
|
||||
|
||||
if (!smsMode) {
|
||||
ui_task.gotoSMSScreen();
|
||||
}
|
||||
ui_task.forceRefresh();
|
||||
Serial.printf("[Call] Incoming from %s\n", callEvt.phone);
|
||||
} else if (callEvt.type == CallEventType::CONNECTED) {
|
||||
Serial.printf("[Call] Connected to %s\n", callEvt.phone);
|
||||
ui_task.forceRefresh();
|
||||
} else if (callEvt.type == CallEventType::ENDED) {
|
||||
Serial.printf("[Call] Ended (%lus) with %s\n",
|
||||
(unsigned long)callEvt.duration, callEvt.phone);
|
||||
ui_task.forceRefresh();
|
||||
} else if (callEvt.type == CallEventType::MISSED) {
|
||||
char alertBuf[48];
|
||||
char dispName[SMS_CONTACT_NAME_LEN];
|
||||
smsContacts.displayName(callEvt.phone, dispName, sizeof(dispName));
|
||||
snprintf(alertBuf, sizeof(alertBuf), "Missed: %s", dispName);
|
||||
ui_task.showAlert(alertBuf, 3000);
|
||||
Serial.printf("[Call] Missed from %s\n", callEvt.phone);
|
||||
ui_task.forceRefresh();
|
||||
} else if (callEvt.type == CallEventType::BUSY) {
|
||||
ui_task.showAlert("Line busy", 2000);
|
||||
Serial.printf("[Call] Busy: %s\n", callEvt.phone);
|
||||
ui_task.forceRefresh();
|
||||
} else if (callEvt.type == CallEventType::NO_ANSWER) {
|
||||
ui_task.showAlert("No answer", 2000);
|
||||
Serial.printf("[Call] No answer: %s\n", callEvt.phone);
|
||||
ui_task.forceRefresh();
|
||||
} else if (callEvt.type == CallEventType::DIAL_FAILED) {
|
||||
ui_task.showAlert("Call failed", 2000);
|
||||
Serial.printf("[Call] Dial failed: %s\n", callEvt.phone);
|
||||
ui_task.forceRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef DISPLAY_CLASS
|
||||
// Skip UITask rendering when in compose mode to prevent flickering
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
@@ -595,10 +775,26 @@ void loop() {
|
||||
bool notesEditing = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isEditing();
|
||||
bool notesRenaming = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isRenaming();
|
||||
bool notesSuppressLoop = notesEditing || notesRenaming;
|
||||
if (!composeMode && !notesSuppressLoop) {
|
||||
#ifdef HAS_4G_MODEM
|
||||
bool smsSuppressLoop = smsMode && ((SMSScreen*)ui_task.getSMSScreen())->isComposing();
|
||||
#else
|
||||
bool smsSuppressLoop = false;
|
||||
#endif
|
||||
#ifdef MECK_WEB_READER
|
||||
// Safety: clear web reader text entry flag if we're no longer on the web reader
|
||||
if (webReaderTextEntry && !ui_task.isOnWebReader()) {
|
||||
webReaderTextEntry = false;
|
||||
webReaderNeedsRefresh = false;
|
||||
}
|
||||
#endif
|
||||
if (!composeMode && !notesSuppressLoop && !smsSuppressLoop
|
||||
#ifdef MECK_WEB_READER
|
||||
&& !webReaderTextEntry
|
||||
#endif
|
||||
) {
|
||||
ui_task.loop();
|
||||
} else {
|
||||
// Handle debounced screen refresh (compose, emoji picker, or notes editor)
|
||||
// Handle debounced screen refresh (compose, emoji picker, notes, or web reader text entry)
|
||||
if (composeNeedsRefresh && (millis() - lastComposeRefresh) >= COMPOSE_REFRESH_INTERVAL) {
|
||||
if (composeMode) {
|
||||
if (emojiPickerMode) {
|
||||
@@ -610,14 +806,47 @@ void loop() {
|
||||
// Notes editor/rename renders through UITask - force a refresh cycle
|
||||
ui_task.forceRefresh();
|
||||
ui_task.loop();
|
||||
} else if (smsSuppressLoop) {
|
||||
// SMS compose: render directly to display, same as mesh compose
|
||||
#if defined(DISPLAY_CLASS) && defined(HAS_4G_MODEM)
|
||||
display.startFrame();
|
||||
((SMSScreen*)ui_task.getSMSScreen())->render(display);
|
||||
display.endFrame();
|
||||
#endif
|
||||
}
|
||||
lastComposeRefresh = millis();
|
||||
composeNeedsRefresh = false;
|
||||
}
|
||||
#ifdef MECK_WEB_READER
|
||||
if (webReaderNeedsRefresh && (millis() - lastWebReaderRefresh) >= COMPOSE_REFRESH_INTERVAL) {
|
||||
WebReaderScreen* wr2 = (WebReaderScreen*)ui_task.getWebReaderScreen();
|
||||
if (wr2) {
|
||||
display.startFrame();
|
||||
wr2->render(display);
|
||||
display.endFrame();
|
||||
}
|
||||
lastWebReaderRefresh = millis();
|
||||
webReaderNeedsRefresh = false;
|
||||
}
|
||||
// Password reveal expiry: re-render to mask character after 800ms
|
||||
if (webReaderTextEntry && !webReaderNeedsRefresh) {
|
||||
WebReaderScreen* wr3 = (WebReaderScreen*)ui_task.getWebReaderScreen();
|
||||
if (wr3 && wr3->needsRevealRefresh() && (millis() - lastWebReaderRefresh) >= 850) {
|
||||
display.startFrame();
|
||||
wr3->render(display);
|
||||
display.endFrame();
|
||||
lastWebReaderRefresh = millis();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
// Track reader/notes mode state for key routing
|
||||
// Track reader/notes/audiobook mode state for key routing
|
||||
readerMode = ui_task.isOnTextReader();
|
||||
notesMode = ui_task.isOnNotesScreen();
|
||||
audiobookMode = ui_task.isOnAudiobookPlayer();
|
||||
#ifdef HAS_4G_MODEM
|
||||
smsMode = ui_task.isOnSMSScreen();
|
||||
#endif
|
||||
#else
|
||||
ui_task.loop();
|
||||
#endif
|
||||
@@ -807,6 +1036,42 @@ void handleKeyboardInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
// *** AUDIOBOOK MODE ***
|
||||
#ifndef HAS_4G_MODEM
|
||||
if (audiobookMode) {
|
||||
AudiobookPlayerScreen* abPlayer =
|
||||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||||
|
||||
// Q key: behavior depends on playback state
|
||||
// - Playing: navigate home, audio continues in background
|
||||
// - Paused/stopped: close book, return to file list
|
||||
// - File list: exit player entirely
|
||||
if (key == 'q') {
|
||||
if (abPlayer->isBookOpen()) {
|
||||
if (abPlayer->isAudioActive()) {
|
||||
// Audio is playing — leave screen, audio continues via audioTick()
|
||||
Serial.println("Leaving audiobook player (audio continues in background)");
|
||||
ui_task.gotoHomeScreen();
|
||||
} else {
|
||||
// Paused or stopped — close book, show file list
|
||||
abPlayer->closeCurrentBook();
|
||||
Serial.println("Closed audiobook (was paused/stopped)");
|
||||
// Stay on audiobook screen showing file list
|
||||
}
|
||||
} else {
|
||||
abPlayer->exitPlayer();
|
||||
Serial.println("Exiting audiobook player");
|
||||
ui_task.gotoHomeScreen();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// All other keys pass through to the player screen
|
||||
ui_task.injectKey(key);
|
||||
return;
|
||||
}
|
||||
#endif // !HAS_4G_MODEM
|
||||
|
||||
// *** TEXT READER MODE ***
|
||||
if (readerMode) {
|
||||
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
|
||||
@@ -982,11 +1247,148 @@ void handleKeyboardInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
// All other keys → settings screen via injectKey
|
||||
// All other keys → settings screen via injectKey
|
||||
ui_task.injectKey(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// *** REPEATER ADMIN MODE ***
|
||||
if (ui_task.isOnRepeaterAdmin()) {
|
||||
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen();
|
||||
RepeaterAdminScreen::AdminState astate = admin->getState();
|
||||
bool shiftDel = (key == '\b' && keyboard.wasShiftConsumed());
|
||||
|
||||
// In password entry: Shift+Del exits, all other keys pass through normally
|
||||
if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) {
|
||||
if (shiftDel) {
|
||||
Serial.println("Nav: Back to contacts from admin login");
|
||||
ui_task.gotoContactsScreen();
|
||||
} else {
|
||||
ui_task.injectKey(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In menu state: Shift+Del exits to contacts, C opens compose
|
||||
if (astate == RepeaterAdminScreen::STATE_MENU) {
|
||||
if (shiftDel) {
|
||||
Serial.println("Nav: Back to contacts from admin menu");
|
||||
ui_task.gotoContactsScreen();
|
||||
return;
|
||||
}
|
||||
// C key: allow entering compose mode from admin menu
|
||||
if (key == 'c' || key == 'C') {
|
||||
composeDM = false;
|
||||
composeDMContactIdx = -1;
|
||||
composeMode = true;
|
||||
composeBuffer[0] = '\0';
|
||||
composePos = 0;
|
||||
drawComposeScreen();
|
||||
lastComposeRefresh = millis();
|
||||
return;
|
||||
}
|
||||
// All other keys pass to admin screen
|
||||
ui_task.injectKey(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// In waiting/response/error states: convert Shift+Del to exit signal,
|
||||
// pass all other keys through
|
||||
if (shiftDel) {
|
||||
ui_task.injectKey(KEY_ADMIN_EXIT);
|
||||
} else {
|
||||
ui_task.injectKey(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// SMS mode key routing (when on SMS screen)
|
||||
#ifdef HAS_4G_MODEM
|
||||
if (smsMode) {
|
||||
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
|
||||
if (smsScr) {
|
||||
// During active call views, route all keys directly to the screen
|
||||
// and force a refresh after each keypress (no debounce needed)
|
||||
if (smsScr->isInCallView()) {
|
||||
smsScr->handleInput(key);
|
||||
ui_task.forceRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// Q from inbox → go home; Q from inner views is handled by SMSScreen
|
||||
if ((key == 'q' || key == '\b') && smsScr->getSubView() == SMSScreen::INBOX) {
|
||||
Serial.println("Nav: SMS -> Home");
|
||||
ui_task.gotoHomeScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
if (smsScr->isComposing()) {
|
||||
// Composing/text input: route directly to screen, bypass injectKey()
|
||||
// to avoid UITask scheduling its own competing refresh
|
||||
smsScr->handleInput(key);
|
||||
if (smsScr->isComposing()) {
|
||||
// Still composing — debounced refresh
|
||||
composeNeedsRefresh = true;
|
||||
lastComposeRefresh = millis();
|
||||
} else {
|
||||
// View changed (sent/cancelled) — immediate UITask refresh
|
||||
composeNeedsRefresh = false;
|
||||
ui_task.forceRefresh();
|
||||
}
|
||||
} else {
|
||||
// Non-compose views (inbox, conversation, contacts): use normal inject
|
||||
ui_task.injectKey(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// *** WEB READER TEXT INPUT MODE ***
|
||||
// Match compose mode pattern: key handler sets a flag and returns instantly.
|
||||
// Main loop renders with 100ms debounce (same as COMPOSE_REFRESH_INTERVAL).
|
||||
// This way the key handler never blocks for 648ms during a render.
|
||||
#ifdef MECK_WEB_READER
|
||||
if (ui_task.isOnWebReader()) {
|
||||
WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen();
|
||||
bool urlEdit = wr ? wr->isUrlEditing() : false;
|
||||
bool passEdit = wr ? wr->isPasswordEntry() : false;
|
||||
bool formEdit = wr ? wr->isFormFilling() : false;
|
||||
if (wr && (urlEdit || passEdit || formEdit)) {
|
||||
webReaderTextEntry = true; // Suppress ui_task.loop() in main loop
|
||||
wr->handleInput(key); // Updates buffer instantly, no render
|
||||
|
||||
// Check if text entry ended (submitted, cancelled, etc.)
|
||||
if (!wr->isUrlEditing() && !wr->isPasswordEntry() && !wr->isFormFilling()) {
|
||||
// Text entry ended
|
||||
webReaderTextEntry = false;
|
||||
webReaderNeedsRefresh = false;
|
||||
// fetchPage()/submitForm() handle their own rendering, or mode changed —
|
||||
// let ui_task.loop() resume on next iteration
|
||||
} else {
|
||||
// Still typing — request debounced refresh
|
||||
webReaderNeedsRefresh = true;
|
||||
lastWebReaderRefresh = millis();
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
// Not in text entry — clear flag so ui_task.loop() resumes
|
||||
webReaderTextEntry = false;
|
||||
|
||||
// Q from HOME mode exits the web reader entirely (like text reader)
|
||||
if ((key == 'q' || key == 'Q') && wr && wr->isHome() && !wr->isUrlEditing()) {
|
||||
Serial.println("Exiting web reader");
|
||||
ui_task.gotoHomeScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
// Route keys through normal UITask for navigation/scrolling
|
||||
ui_task.injectKey(key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Normal mode - not composing
|
||||
switch (key) {
|
||||
case 'c':
|
||||
@@ -1007,6 +1409,75 @@ void handleKeyboardInput() {
|
||||
ui_task.gotoTextReader();
|
||||
break;
|
||||
|
||||
#ifndef HAS_4G_MODEM
|
||||
case 'p':
|
||||
// Open audiobook player - lazy-init Audio + screen on first use
|
||||
Serial.println("Opening audiobook player");
|
||||
if (!ui_task.getAudiobookScreen()) {
|
||||
Serial.printf("Audiobook: lazy init - free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
audio = new Audio();
|
||||
AudiobookPlayerScreen* abScreen = new AudiobookPlayerScreen(&ui_task, audio);
|
||||
abScreen->setSDReady(sdCardReady);
|
||||
ui_task.setAudiobookScreen(abScreen);
|
||||
Serial.printf("Audiobook: init complete - free heap: %d\n", ESP.getFreeHeap());
|
||||
}
|
||||
ui_task.gotoAudiobookPlayer();
|
||||
break;
|
||||
#endif
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
case 't':
|
||||
// Open SMS (4G variant only)
|
||||
Serial.println("Opening SMS");
|
||||
ui_task.gotoSMSScreen();
|
||||
break;
|
||||
#endif
|
||||
|
||||
#ifdef MECK_WEB_READER
|
||||
case 'b':
|
||||
// Open web reader (browser)
|
||||
Serial.println("Opening web reader");
|
||||
{
|
||||
static bool webReaderWifiReady = false;
|
||||
if (!webReaderWifiReady) {
|
||||
// WiFi needs ~40KB contiguous heap. The BLE controller holds ~30KB,
|
||||
// leaving only ~30KB largest block. We MUST release BLE memory first.
|
||||
//
|
||||
// This disables BLE for the duration of the session.
|
||||
// BLE comes back on reboot.
|
||||
Serial.printf("WebReader: heap BEFORE BT release: free=%d, largest=%d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
|
||||
// 1) Stop BLE controller (disable + deinit)
|
||||
btStop();
|
||||
delay(50);
|
||||
|
||||
// 2) Release the BT controller's reserved memory region back to heap
|
||||
esp_bt_controller_mem_release(ESP_BT_MODE_BTDM);
|
||||
delay(50);
|
||||
|
||||
Serial.printf("WebReader: heap AFTER BT release: free=%d, largest=%d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
|
||||
// 3) Now init WiFi while we have maximum contiguous heap
|
||||
if (WiFi.mode(WIFI_STA)) {
|
||||
Serial.println("WebReader: WiFi STA init OK");
|
||||
webReaderWifiReady = true;
|
||||
} else {
|
||||
Serial.println("WebReader: WiFi STA init FAILED even after BT release");
|
||||
// Clean up partial WiFi init to avoid memory leak
|
||||
WiFi.mode(WIFI_OFF);
|
||||
}
|
||||
|
||||
Serial.printf("WebReader: heap after WiFi init: free=%d, largest=%d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
}
|
||||
}
|
||||
ui_task.gotoWebReader();
|
||||
break;
|
||||
#endif
|
||||
|
||||
case 'n':
|
||||
// Open notes
|
||||
Serial.println("Opening notes");
|
||||
@@ -1022,9 +1493,13 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
|
||||
case 's':
|
||||
// Open settings (from home), or navigate down on channel/contacts
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
||||
ui_task.injectKey('s'); // Pass directly for channel/contacts scrolling
|
||||
// Open settings (from home), or navigate down on channel/contacts/admin/web
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()
|
||||
#ifdef MECK_WEB_READER
|
||||
|| ui_task.isOnWebReader()
|
||||
#endif
|
||||
) {
|
||||
ui_task.injectKey('s'); // Pass directly for scrolling
|
||||
} else {
|
||||
Serial.println("Opening settings");
|
||||
ui_task.gotoSettingsScreen();
|
||||
@@ -1033,8 +1508,12 @@ void handleKeyboardInput() {
|
||||
|
||||
case 'w':
|
||||
// Navigate up/previous (scroll on channel screen)
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
||||
ui_task.injectKey('w'); // Pass directly for channel/contacts switching
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()
|
||||
#ifdef MECK_WEB_READER
|
||||
|| ui_task.isOnWebReader()
|
||||
#endif
|
||||
) {
|
||||
ui_task.injectKey('w'); // Pass directly for scrolling
|
||||
} else {
|
||||
Serial.println("Nav: Previous");
|
||||
ui_task.injectKey(0xF2); // KEY_PREV
|
||||
@@ -1062,7 +1541,8 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
|
||||
case '\r':
|
||||
// Enter = compose (only from channel or contacts screen)
|
||||
// Select/Enter - if on contacts screen, enter DM compose for chat contacts
|
||||
// or repeater admin for repeater contacts
|
||||
if (ui_task.isOnContactsScreen()) {
|
||||
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
|
||||
int idx = cs->getSelectedContactIdx();
|
||||
@@ -1077,10 +1557,22 @@ void handleKeyboardInput() {
|
||||
Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx);
|
||||
drawComposeScreen();
|
||||
lastComposeRefresh = millis();
|
||||
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
|
||||
// Open repeater admin screen
|
||||
char rname[32];
|
||||
cs->getSelectedContactName(rname, sizeof(rname));
|
||||
Serial.printf("Opening repeater admin for %s (idx %d)\n", rname, idx);
|
||||
ui_task.gotoRepeaterAdmin(idx);
|
||||
} else if (idx >= 0) {
|
||||
Serial.printf("Selected non-chat contact type=%d idx=%d\n", ctype, idx);
|
||||
// Non-chat, non-repeater contact (room, sensor, etc.) - future use
|
||||
Serial.printf("Selected contact type=%d idx=%d\n", ctype, idx);
|
||||
}
|
||||
} else if (ui_task.isOnChannelScreen()) {
|
||||
// Don't enter compose if path overlay is showing
|
||||
ChannelScreen* chScr2 = (ChannelScreen*)ui_task.getChannelScreen();
|
||||
if (chScr2 && chScr2->isShowingPathOverlay()) {
|
||||
break;
|
||||
}
|
||||
composeDM = false;
|
||||
composeDMContactIdx = -1;
|
||||
composeChannelIdx = ui_task.getChannelScreenViewIdx();
|
||||
@@ -1098,7 +1590,26 @@ void handleKeyboardInput() {
|
||||
|
||||
case 'q':
|
||||
case '\b':
|
||||
// Go back to home screen
|
||||
// If channel screen path overlay is showing, dismiss it instead of going home
|
||||
if (ui_task.isOnChannelScreen()) {
|
||||
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
|
||||
if (chScr && chScr->isShowingPathOverlay()) {
|
||||
ui_task.injectKey('q');
|
||||
break;
|
||||
}
|
||||
}
|
||||
#ifdef MECK_WEB_READER
|
||||
// If web reader is in reading/link/wifi mode, inject q for internal navigation
|
||||
// (reading→home, wifi→home). Only exit to firmware home if already on web home.
|
||||
if (ui_task.isOnWebReader()) {
|
||||
WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen();
|
||||
if (wr && !wr->isHome()) {
|
||||
ui_task.injectKey('q');
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// Go back to home screen (admin mode handled above)
|
||||
Serial.println("Nav: Back to home");
|
||||
ui_task.gotoHomeScreen();
|
||||
break;
|
||||
@@ -1113,8 +1624,22 @@ void handleKeyboardInput() {
|
||||
// UTC offset edit (home screen GPS page handles this)
|
||||
ui_task.injectKey('u');
|
||||
break;
|
||||
|
||||
case 'v':
|
||||
// View path overlay (channel screen only)
|
||||
if (ui_task.isOnChannelScreen()) {
|
||||
ui_task.injectKey('v');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
#ifdef MECK_WEB_READER
|
||||
// Pass unhandled keys to web reader (l=link, g=go, k=bookmark, 0-9=link#)
|
||||
if (ui_task.isOnWebReader()) {
|
||||
ui_task.injectKey(key);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key);
|
||||
break;
|
||||
}
|
||||
@@ -1308,4 +1833,26 @@ void sendComposedMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ESP32-audioI2S CALLBACKS
|
||||
// ============================================================================
|
||||
// The audio library calls these global functions - must be defined at file scope.
|
||||
// Not available on 4G variant (no audio hardware).
|
||||
|
||||
#ifndef HAS_4G_MODEM
|
||||
void audio_info(const char *info) {
|
||||
Serial.printf("Audio: %s\n", info);
|
||||
}
|
||||
|
||||
void audio_eof_mp3(const char *info) {
|
||||
Serial.printf("Audio: End of file - %s\n", info);
|
||||
// Signal the player screen for auto-advance to next track
|
||||
AudiobookPlayerScreen* abPlayer =
|
||||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||||
if (abPlayer) {
|
||||
abPlayer->onEOF();
|
||||
}
|
||||
}
|
||||
#endif // !HAS_4G_MODEM
|
||||
|
||||
#endif // LilyGo_TDeck_Pro
|
||||
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
|
||||
#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,25 @@ 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 _pathOverlayScroll; // Scroll offset for hop list in path overlay
|
||||
|
||||
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), _pathOverlayScroll(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);
|
||||
}
|
||||
}
|
||||
|
||||
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 +101,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 +118,8 @@ public:
|
||||
|
||||
// Reset scroll to show newest message
|
||||
_scrollPos = 0;
|
||||
_showPathOverlay = false; // Dismiss overlay on new message
|
||||
_pathOverlayScroll = 0;
|
||||
|
||||
// Persist to SD card
|
||||
saveToSD();
|
||||
@@ -123,14 +139,30 @@ 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; _pathOverlayScroll = 0; }
|
||||
bool isShowingPathOverlay() const { return _showPathOverlay; }
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SD card persistence
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Save the entire message buffer to SD card.
|
||||
// File: /meshcore/messages.bin (~50 KB for 300 messages)
|
||||
// File: /meshcore/messages.bin (~56 KB for 300 messages)
|
||||
void saveToSD() {
|
||||
#if defined(HAS_SDCARD) && defined(ESP32)
|
||||
if (!_sdReady) return;
|
||||
@@ -163,6 +195,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 +261,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 +314,151 @@ 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 footerHeight = 14;
|
||||
int scrollBarW = 4;
|
||||
int maxY = display.height() - footerHeight;
|
||||
|
||||
// Calculate how many hops fit in the visible area
|
||||
int hopsAreaTop = y;
|
||||
int visibleHops = (maxY - y) / lineH;
|
||||
if (visibleHops < 1) visibleHops = 1;
|
||||
|
||||
// Clamp scroll position
|
||||
int maxScroll = displayHops > visibleHops ? displayHops - visibleHops : 0;
|
||||
if (_pathOverlayScroll > maxScroll) _pathOverlayScroll = maxScroll;
|
||||
|
||||
int startHop = _pathOverlayScroll;
|
||||
|
||||
for (int h = startHop; h < displayHops && y + lineH <= maxY; h++) {
|
||||
uint8_t hopHash = msg->path[h];
|
||||
display.setCursor(0, y);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
sprintf(tmp, " %d: ", h + 1);
|
||||
display.print(tmp);
|
||||
|
||||
// Try to resolve: prefer repeaters, then any contact
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: show hex hash
|
||||
if (!resolved) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
sprintf(tmp, "?%02X", hopHash);
|
||||
display.print(tmp);
|
||||
}
|
||||
y += lineH;
|
||||
}
|
||||
|
||||
// --- Scroll bar for hop list ---
|
||||
if (displayHops > visibleHops) {
|
||||
int sbX = display.width() - scrollBarW;
|
||||
int sbTop = hopsAreaTop;
|
||||
int sbHeight = maxY - hopsAreaTop;
|
||||
|
||||
// Draw track outline
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(sbX, sbTop, scrollBarW, sbHeight);
|
||||
|
||||
// Draw proportional thumb
|
||||
int thumbH = (visibleHops * sbHeight) / displayHops;
|
||||
if (thumbH < 4) thumbH = 4;
|
||||
int thumbY = sbTop + (_pathOverlayScroll * (sbHeight - thumbH)) / maxScroll;
|
||||
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 W/S:Scroll");
|
||||
|
||||
#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 +474,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 +482,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 +501,10 @@ public:
|
||||
int tmp = channelMsgs[l]; channelMsgs[l] = channelMsgs[r]; channelMsgs[r] = tmp;
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -361,7 +546,7 @@ public:
|
||||
|
||||
// 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};
|
||||
|
||||
@@ -460,13 +645,39 @@ public:
|
||||
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) {
|
||||
// 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) {
|
||||
_msgsPerPage = msgsDrawn;
|
||||
}
|
||||
|
||||
// --- 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,11 +687,11 @@ public:
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
// Left side: Q:Back A/D:Ch
|
||||
display.print("Q:Back A/D:Ch");
|
||||
// Left side: abbreviated controls
|
||||
display.print("Q:Bck A/D:Ch V:Pth");
|
||||
|
||||
// Right side: Entr:New
|
||||
const char* rightText = "Entr:New";
|
||||
// Right side: Ent:New
|
||||
const char* rightText = "Ent:New";
|
||||
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
|
||||
display.print(rightText);
|
||||
|
||||
@@ -492,8 +703,39 @@ public:
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// If overlay is showing, only handle dismiss
|
||||
if (_showPathOverlay) {
|
||||
if (c == 'q' || c == 'Q' || c == '\b' || c == 'v' || c == 'V') {
|
||||
_showPathOverlay = false;
|
||||
return true;
|
||||
}
|
||||
// W - scroll up in hop list
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_pathOverlayScroll > 0) {
|
||||
_pathOverlayScroll--;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// S - scroll down in hop list
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
_pathOverlayScroll++; // Clamped during render
|
||||
return true;
|
||||
}
|
||||
return true; // Consume all keys while overlay is up
|
||||
}
|
||||
|
||||
int channelMsgCount = getMessageCountForChannel();
|
||||
|
||||
// V - show path detail for last received message
|
||||
if (c == 'v' || c == 'V') {
|
||||
if (getNewestReceivedMsg() != nullptr) {
|
||||
_showPathOverlay = true;
|
||||
_pathOverlayScroll = 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') {
|
||||
if (_scrollPos + _msgsPerPage < channelMsgCount) {
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
1022
examples/companion_radio/ui-new/ModemManager.cpp
Normal file
1022
examples/companion_radio/ui-new/ModemManager.cpp
Normal file
File diff suppressed because it is too large
Load Diff
222
examples/companion_radio/ui-new/ModemManager.h
Normal file
222
examples/companion_radio/ui-new/ModemManager.h
Normal file
@@ -0,0 +1,222 @@
|
||||
#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"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modem pins (from variant.h, always defined for reference)
|
||||
// MODEM_POWER_EN 41 Board 6609 enable
|
||||
// MODEM_PWRKEY 40 Power key toggle
|
||||
// MODEM_RST 9 Reset (shared with I2S BCLK on audio board)
|
||||
// MODEM_RI 7 Ring indicator (shared with I2S DOUT on audio)
|
||||
// MODEM_DTR 8 Data terminal ready (shared with I2S LRC on audio)
|
||||
// MODEM_RX 10 UART RX (shared with PIN_PERF_POWERON)
|
||||
// MODEM_TX 11 UART TX
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SMS field limits
|
||||
#define SMS_PHONE_LEN 20
|
||||
#define SMS_BODY_LEN 161 // 160 chars + null
|
||||
|
||||
// Task configuration
|
||||
#define MODEM_TASK_PRIORITY 1 // Below mesh (default loop = priority 1 on core 1)
|
||||
#define MODEM_TASK_STACK_SIZE 6144 // Increased for call handling
|
||||
#define MODEM_TASK_CORE 0 // Run on core 0 (mesh runs on core 1)
|
||||
|
||||
// Queue sizes
|
||||
#define MODEM_SEND_QUEUE_SIZE 4
|
||||
#define MODEM_RECV_QUEUE_SIZE 8
|
||||
#define MODEM_CALL_CMD_QUEUE_SIZE 4
|
||||
#define MODEM_CALL_EVT_QUEUE_SIZE 4
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modem state machine
|
||||
// ---------------------------------------------------------------------------
|
||||
enum class ModemState {
|
||||
OFF,
|
||||
POWERING_ON,
|
||||
INITIALIZING,
|
||||
REGISTERING,
|
||||
READY,
|
||||
ERROR,
|
||||
SENDING_SMS,
|
||||
// Voice call states
|
||||
DIALING, // ATD sent, waiting for connect/carrier
|
||||
RINGING_IN, // Incoming call detected (RING URC)
|
||||
IN_CALL // Voice call active
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SMS structures (unchanged)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Outgoing SMS (queued from main loop to modem task)
|
||||
struct SMSOutgoing {
|
||||
char phone[SMS_PHONE_LEN];
|
||||
char body[SMS_BODY_LEN];
|
||||
};
|
||||
|
||||
// Incoming SMS (queued from modem task to main loop)
|
||||
struct SMSIncoming {
|
||||
char phone[SMS_PHONE_LEN];
|
||||
char body[SMS_BODY_LEN];
|
||||
uint32_t timestamp; // epoch seconds (from modem RTC or millis-based)
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voice call structures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Commands from main loop → modem task
|
||||
enum class CallCmd : uint8_t {
|
||||
DIAL, // Initiate outgoing call
|
||||
ANSWER, // Answer incoming call
|
||||
HANGUP, // End active call or reject incoming
|
||||
DTMF, // Send DTMF tone during call
|
||||
SET_VOLUME // Set speaker volume
|
||||
};
|
||||
|
||||
struct CallCommand {
|
||||
CallCmd cmd;
|
||||
char phone[SMS_PHONE_LEN]; // Used by DIAL
|
||||
char dtmf; // Used by DTMF (single digit: 0-9, *, #)
|
||||
uint8_t volume; // Used by SET_VOLUME (0-5)
|
||||
};
|
||||
|
||||
// Events from modem task → main loop
|
||||
enum class CallEventType : uint8_t {
|
||||
INCOMING, // Incoming call ringing (+CLIP parsed)
|
||||
CONNECTED, // Call answered / outgoing connected
|
||||
ENDED, // Call ended (local hangup, remote hangup, or no carrier)
|
||||
MISSED, // Incoming call ended before answer
|
||||
BUSY, // Outgoing call got busy signal
|
||||
NO_ANSWER, // Outgoing call not answered
|
||||
DIAL_FAILED // ATD command failed
|
||||
};
|
||||
|
||||
struct CallEvent {
|
||||
CallEventType type;
|
||||
char phone[SMS_PHONE_LEN]; // Caller/callee number (from +CLIP or dial)
|
||||
uint32_t duration; // Call duration in seconds (for ENDED)
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ModemManager class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ModemManager {
|
||||
public:
|
||||
void begin();
|
||||
void shutdown();
|
||||
|
||||
// --- SMS API (unchanged) ---
|
||||
bool sendSMS(const char* phone, const char* body);
|
||||
bool recvSMS(SMSIncoming& out);
|
||||
|
||||
// --- Voice Call API ---
|
||||
bool dialCall(const char* phone); // Queue outgoing call
|
||||
bool answerCall(); // Answer incoming call
|
||||
bool hangupCall(); // End active / reject incoming
|
||||
bool sendDTMF(char digit); // Send DTMF during call
|
||||
bool setCallVolume(uint8_t level); // Set volume 0-5
|
||||
bool pollCallEvent(CallEvent& out); // Poll from main loop
|
||||
|
||||
// --- State queries (lock-free reads) ---
|
||||
ModemState getState() const { return _state; }
|
||||
int getSignalBars() const; // 0-5
|
||||
int getCSQ() const { return _csq; }
|
||||
bool isReady() const { return _state == ModemState::READY; }
|
||||
bool isInCall() const { return _state == ModemState::IN_CALL; }
|
||||
bool isRinging() const { return _state == ModemState::RINGING_IN; }
|
||||
bool isDialing() const { return _state == ModemState::DIALING; }
|
||||
bool isCallActive() const {
|
||||
return _state == ModemState::IN_CALL ||
|
||||
_state == ModemState::DIALING ||
|
||||
_state == ModemState::RINGING_IN;
|
||||
}
|
||||
const char* getOperator() const { return _operator; }
|
||||
const char* getCallPhone() const { return _callPhone; }
|
||||
uint32_t getCallStartTime() const { return _callStartTime; }
|
||||
|
||||
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
|
||||
char _operator[24] = {0};
|
||||
|
||||
// Call state (written by modem task, read by main loop)
|
||||
char _callPhone[SMS_PHONE_LEN] = {0}; // Current call number
|
||||
volatile uint32_t _callStartTime = 0; // millis() when call connected
|
||||
|
||||
TaskHandle_t _taskHandle = nullptr;
|
||||
|
||||
// SMS queues
|
||||
QueueHandle_t _sendQueue = nullptr;
|
||||
QueueHandle_t _recvQueue = nullptr;
|
||||
|
||||
// Call queues
|
||||
QueueHandle_t _callCmdQueue = nullptr; // main loop → modem task
|
||||
QueueHandle_t _callEvtQueue = nullptr; // modem task → main loop
|
||||
|
||||
SemaphoreHandle_t _uartMutex = nullptr;
|
||||
|
||||
// URC line buffer (accumulated between AT commands)
|
||||
static const int URC_BUF_SIZE = 256;
|
||||
char _urcBuf[URC_BUF_SIZE];
|
||||
int _urcPos = 0;
|
||||
|
||||
// UART AT command helpers (called only from modem task)
|
||||
bool modemPowerOn();
|
||||
bool sendAT(const char* cmd, const char* expect, uint32_t timeout_ms = 2000);
|
||||
bool waitResponse(const char* expect, uint32_t timeout_ms, char* buf = nullptr, size_t bufLen = 0);
|
||||
void pollCSQ();
|
||||
void pollIncomingSMS();
|
||||
bool doSendSMS(const char* phone, const char* body);
|
||||
|
||||
// URC (unsolicited result code) handling
|
||||
void drainURCs(); // Read available UART data, process complete lines
|
||||
void processURCLine(const char* line); // Handle a single URC line
|
||||
|
||||
// Call control (called from modem task)
|
||||
bool doDialCall(const char* phone);
|
||||
bool doAnswerCall();
|
||||
bool doHangup();
|
||||
bool doSendDTMF(char digit);
|
||||
bool doSetVolume(uint8_t level);
|
||||
void queueCallEvent(CallEventType type, const char* phone = nullptr, uint32_t duration = 0);
|
||||
|
||||
// FreeRTOS task
|
||||
static void taskEntry(void* param);
|
||||
void taskLoop();
|
||||
};
|
||||
|
||||
// Global singleton
|
||||
extern ModemManager modemManager;
|
||||
|
||||
#endif // MODEM_MANAGER_H
|
||||
#endif // HAS_4G_MODEM
|
||||
@@ -12,6 +12,7 @@ extern MyMesh the_mesh;
|
||||
#define ADMIN_PASSWORD_MAX 32
|
||||
#define ADMIN_RESPONSE_MAX 512 // CLI responses can be multi-line
|
||||
#define ADMIN_TIMEOUT_MS 15000 // 15s timeout for login/commands
|
||||
#define KEY_ADMIN_EXIT 0xFE // Special key: Shift+Backspace exit (injected by main.cpp)
|
||||
|
||||
class RepeaterAdminScreen : public UIScreen {
|
||||
public:
|
||||
@@ -280,31 +281,34 @@ public:
|
||||
|
||||
switch (_state) {
|
||||
case STATE_PASSWORD_ENTRY:
|
||||
display.print("Q:Back");
|
||||
display.print("Sh+Del:Exit");
|
||||
{
|
||||
const char* right = "Enter:Login";
|
||||
const char* right = "Ent:Login";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
break;
|
||||
case STATE_LOGGING_IN:
|
||||
case STATE_COMMAND_PENDING:
|
||||
display.print("Q:Cancel");
|
||||
display.print("Sh+Del:Cancel");
|
||||
break;
|
||||
case STATE_MENU:
|
||||
display.print("Q:Back");
|
||||
display.print("Sh+Del:Exit");
|
||||
{
|
||||
const char* mid = "W/S:Sel";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
const char* right = "Ent:Run";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
int leftEnd = display.getTextWidth("Sh+Del:Exit") + 2;
|
||||
int rightStart = display.width() - display.getTextWidth(right) - 2;
|
||||
int midX = leftEnd + (rightStart - leftEnd - display.getTextWidth(mid)) / 2;
|
||||
display.setCursor(midX, footerY);
|
||||
display.print(mid);
|
||||
display.setCursor(rightStart, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
break;
|
||||
case STATE_RESPONSE_VIEW:
|
||||
case STATE_ERROR:
|
||||
display.print("Q:Menu");
|
||||
display.print("Sh+Del:Menu");
|
||||
if (_responseLen > bodyHeight / 9) { // if scrollable
|
||||
const char* right = "W/S:Scrll";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
@@ -327,8 +331,8 @@ public:
|
||||
return handlePasswordInput(c);
|
||||
case STATE_LOGGING_IN:
|
||||
case STATE_COMMAND_PENDING:
|
||||
// Q to cancel and go back
|
||||
if (c == 'q' || c == 'Q') {
|
||||
// Shift+Del to cancel and go back
|
||||
if (c == KEY_ADMIN_EXIT) {
|
||||
_state = (_state == STATE_LOGGING_IN) ? STATE_PASSWORD_ENTRY : STATE_MENU;
|
||||
return true;
|
||||
}
|
||||
@@ -370,9 +374,9 @@ private:
|
||||
}
|
||||
|
||||
bool handlePasswordInput(char c) {
|
||||
// Q without any password typed = go back (return false to signal "not handled")
|
||||
if ((c == 'q' || c == 'Q') && _pwdLen == 0) {
|
||||
return false;
|
||||
// Shift+Del = exit (always, regardless of password content)
|
||||
if (c == KEY_ADMIN_EXIT) {
|
||||
return false; // signal main.cpp to navigate back
|
||||
}
|
||||
|
||||
// Enter to submit
|
||||
@@ -472,8 +476,8 @@ private:
|
||||
if (c == '\r' || c == '\n' || c == KEY_ENTER) {
|
||||
return executeMenuCommand((MenuItem)_menuSel);
|
||||
}
|
||||
// Q - back to contacts
|
||||
if (c == 'q' || c == 'Q') {
|
||||
// Shift+Del - back to contacts
|
||||
if (c == KEY_ADMIN_EXIT) {
|
||||
return false; // let UITask handle back navigation
|
||||
}
|
||||
// Number keys for quick selection
|
||||
@@ -535,8 +539,8 @@ private:
|
||||
_responseScroll++;
|
||||
return true;
|
||||
}
|
||||
// Q - back to menu (or back to password on error)
|
||||
if (c == 'q' || c == 'Q') {
|
||||
// Shift+Del - back to menu (or back to password on error)
|
||||
if (c == KEY_ADMIN_EXIT) {
|
||||
if (_state == STATE_ERROR && _permissions == 0) {
|
||||
// Not yet logged in, go back to password
|
||||
_state = STATE_PASSWORD_ENTRY;
|
||||
|
||||
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
|
||||
1216
examples/companion_radio/ui-new/SMSScreen.h
Normal file
1216
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
|
||||
@@ -6,6 +6,10 @@
|
||||
#include <MeshCore.h>
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "ModemManager.h"
|
||||
#endif
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
class MyMesh;
|
||||
@@ -24,10 +28,22 @@ struct RadioPreset {
|
||||
};
|
||||
|
||||
static const RadioPreset RADIO_PRESETS[] = {
|
||||
{ "MeshCore Default", 915.0f, 250.0f, 10, 5, 20 },
|
||||
{ "Long Range", 915.0f, 125.0f, 12, 8, 20 },
|
||||
{ "Fast/Short", 915.0f, 500.0f, 7, 5, 20 },
|
||||
{ "EU Default", 869.4f, 250.0f, 10, 5, 14 },
|
||||
{ "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]))
|
||||
|
||||
@@ -43,6 +59,10 @@ enum SettingsRowType : uint8_t {
|
||||
ROW_CR, // Coding rate (5-8)
|
||||
ROW_TX_POWER, // TX power (1-20 dBm)
|
||||
ROW_UTC_OFFSET, // UTC offset (-12 to +14)
|
||||
ROW_MSG_NOTIFY, // Keyboard flash on new msg toggle
|
||||
#ifdef HAS_4G_MODEM
|
||||
ROW_MODEM_TOGGLE, // 4G modem enable/disable toggle (4G builds only)
|
||||
#endif
|
||||
ROW_CH_HEADER, // "--- Channels ---" separator
|
||||
ROW_CHANNEL, // A channel entry (dynamic, index stored separately)
|
||||
ROW_ADD_CHANNEL, // "+ Add Hashtag Channel"
|
||||
@@ -72,7 +92,7 @@ private:
|
||||
mesh::RTCClock* _rtc;
|
||||
NodePrefs* _prefs;
|
||||
|
||||
// Row table — rebuilt whenever channels change
|
||||
// Row table  rebuilt whenever channels change
|
||||
struct Row {
|
||||
SettingsRowType type;
|
||||
uint8_t param; // channel index for ROW_CHANNEL, preset index for ROW_RADIO_PRESET
|
||||
@@ -96,9 +116,14 @@ private:
|
||||
// Onboarding mode
|
||||
bool _onboarding;
|
||||
|
||||
// Dirty flag for radio params — prompt to apply
|
||||
// Dirty flag for radio params  prompt to apply
|
||||
bool _radioChanged;
|
||||
|
||||
// 4G modem state (runtime cache of config)
|
||||
#ifdef HAS_4G_MODEM
|
||||
bool _modemEnabled;
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row table management
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -114,6 +139,10 @@ private:
|
||||
addRow(ROW_CR);
|
||||
addRow(ROW_TX_POWER);
|
||||
addRow(ROW_UTC_OFFSET);
|
||||
addRow(ROW_MSG_NOTIFY);
|
||||
#ifdef HAS_4G_MODEM
|
||||
addRow(ROW_MODEM_TOGGLE);
|
||||
#endif
|
||||
addRow(ROW_CH_HEADER);
|
||||
|
||||
// Enumerate current channels
|
||||
@@ -198,11 +227,11 @@ private:
|
||||
strncpy(newCh.name, chanName, sizeof(newCh.name));
|
||||
newCh.name[31] = '\0';
|
||||
|
||||
// SHA-256 the channel name → first 16 bytes become the secret
|
||||
// SHA-256 the channel name → first 16 bytes become the secret
|
||||
uint8_t hash[32];
|
||||
mesh::Utils::sha256(hash, 32, (const uint8_t*)chanName, strlen(chanName));
|
||||
memcpy(newCh.channel.secret, hash, 16);
|
||||
// Upper 16 bytes left as zero → setChannel uses 128-bit mode
|
||||
// Upper 16 bytes left as zero → setChannel uses 128-bit mode
|
||||
|
||||
// Find next empty slot
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
@@ -275,6 +304,9 @@ public:
|
||||
_cursor = 0;
|
||||
_scrollTop = 0;
|
||||
_radioChanged = false;
|
||||
#ifdef HAS_4G_MODEM
|
||||
_modemEnabled = ModemManager::loadEnabledConfig();
|
||||
#endif
|
||||
rebuildRows();
|
||||
}
|
||||
|
||||
@@ -400,8 +432,8 @@ public:
|
||||
}
|
||||
|
||||
case ROW_FREQ:
|
||||
if (editing && _editMode == EDIT_NUMBER) {
|
||||
snprintf(tmp, sizeof(tmp), "Freq: %.3f <W/S>", _editFloat);
|
||||
if (editing && _editMode == EDIT_TEXT) {
|
||||
snprintf(tmp, sizeof(tmp), "Freq: %s_ MHz", _editBuf);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "Freq: %.3f MHz", _prefs->freq);
|
||||
}
|
||||
@@ -453,6 +485,20 @@ public:
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_MSG_NOTIFY:
|
||||
snprintf(tmp, sizeof(tmp), "Msg Rcvd LED Light Pulse: %s",
|
||||
_prefs->kb_flash_notify ? "ON" : "OFF");
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
case ROW_MODEM_TOGGLE:
|
||||
snprintf(tmp, sizeof(tmp), "4G Modem: %s",
|
||||
_modemEnabled ? "ON" : "OFF");
|
||||
display.print(tmp);
|
||||
break;
|
||||
#endif
|
||||
|
||||
case ROW_CH_HEADER:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("--- Channels ---");
|
||||
@@ -611,6 +657,15 @@ public:
|
||||
_cursor = 1; // ROW_RADIO_PRESET
|
||||
startEditPicker(max(0, detectCurrentPreset()));
|
||||
}
|
||||
} else if (type == ROW_FREQ) {
|
||||
if (_editPos > 0) {
|
||||
float f = strtof(_editBuf, nullptr);
|
||||
f = constrain(f, 400.0f, 2500.0f);
|
||||
_prefs->freq = f;
|
||||
_radioChanged = true;
|
||||
Serial.printf("Settings: Freq typed to %.3f\n", f);
|
||||
}
|
||||
_editMode = EDIT_NONE;
|
||||
} else if (type == ROW_ADD_CHANNEL) {
|
||||
if (_editPos > 0) {
|
||||
createHashtagChannel(_editBuf);
|
||||
@@ -684,7 +739,6 @@ public:
|
||||
|
||||
if (c == 'w' || c == 'W') {
|
||||
switch (type) {
|
||||
case ROW_FREQ: _editFloat += 0.1f; break;
|
||||
case ROW_BW:
|
||||
// Cycle through common bandwidths
|
||||
if (_editFloat < 31.25f) _editFloat = 31.25f;
|
||||
@@ -703,7 +757,6 @@ public:
|
||||
}
|
||||
if (c == 's' || c == 'S') {
|
||||
switch (type) {
|
||||
case ROW_FREQ: _editFloat -= 0.1f; break;
|
||||
case ROW_BW:
|
||||
if (_editFloat > 250.0f) _editFloat = 250.0f;
|
||||
else if (_editFloat > 125.0f) _editFloat = 125.0f;
|
||||
@@ -721,10 +774,6 @@ public:
|
||||
if (c == '\r' || c == 13) {
|
||||
// Confirm number edit
|
||||
switch (type) {
|
||||
case ROW_FREQ:
|
||||
_prefs->freq = constrain(_editFloat, 400.0f, 2500.0f);
|
||||
_radioChanged = true;
|
||||
break;
|
||||
case ROW_BW:
|
||||
_prefs->bw = _editFloat;
|
||||
_radioChanged = true;
|
||||
@@ -787,9 +836,13 @@ public:
|
||||
case ROW_RADIO_PRESET:
|
||||
startEditPicker(max(0, detectCurrentPreset()));
|
||||
break;
|
||||
case ROW_FREQ:
|
||||
startEditFloat(_prefs->freq);
|
||||
case ROW_FREQ: {
|
||||
// Use text input so user can type exact frequencies like 916.575
|
||||
char freqStr[16];
|
||||
snprintf(freqStr, sizeof(freqStr), "%.3f", _prefs->freq);
|
||||
startEditText(freqStr);
|
||||
break;
|
||||
}
|
||||
case ROW_BW:
|
||||
startEditFloat(_prefs->bw);
|
||||
break;
|
||||
@@ -805,6 +858,25 @@ public:
|
||||
case ROW_UTC_OFFSET:
|
||||
startEditInt(_prefs->utc_offset_hours);
|
||||
break;
|
||||
case ROW_MSG_NOTIFY:
|
||||
_prefs->kb_flash_notify = _prefs->kb_flash_notify ? 0 : 1;
|
||||
the_mesh.savePrefs();
|
||||
Serial.printf("Settings: Msg flash notify = %s\n",
|
||||
_prefs->kb_flash_notify ? "ON" : "OFF");
|
||||
break;
|
||||
#ifdef HAS_4G_MODEM
|
||||
case ROW_MODEM_TOGGLE:
|
||||
_modemEnabled = !_modemEnabled;
|
||||
ModemManager::saveEnabledConfig(_modemEnabled);
|
||||
if (_modemEnabled) {
|
||||
modemManager.begin();
|
||||
Serial.println("Settings: 4G modem ENABLED (started)");
|
||||
} else {
|
||||
modemManager.shutdown();
|
||||
Serial.println("Settings: 4G modem DISABLED (shutdown)");
|
||||
}
|
||||
break;
|
||||
#endif
|
||||
case ROW_ADD_CHANNEL:
|
||||
startEditText("");
|
||||
break;
|
||||
@@ -828,7 +900,7 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// Q: back — if radio changed, prompt to apply first
|
||||
// Q: back  if radio changed, prompt to apply first
|
||||
if (c == 'q' || c == 'Q') {
|
||||
if (_radioChanged) {
|
||||
_editMode = EDIT_CONFIRM;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include "../MyMesh.h"
|
||||
#include "NotesScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
#include "target.h"
|
||||
#include "GPSDutyCycle.h"
|
||||
#ifdef WIFI_SSID
|
||||
@@ -36,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) {
|
||||
@@ -87,13 +95,18 @@ class HomeScreen : public UIScreen {
|
||||
FIRST,
|
||||
RECENT,
|
||||
RADIO,
|
||||
#ifdef BLE_PIN_CODE
|
||||
BLUETOOTH,
|
||||
#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 +118,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 +153,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 +174,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 +222,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
|
||||
public:
|
||||
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs)
|
||||
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
|
||||
_shutdown_init(false), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
|
||||
_shutdown_init(false), _shutdown_at(0), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
|
||||
|
||||
bool isEditingUTC() const { return _editing_utc; }
|
||||
void cancelEditUTC() {
|
||||
@@ -199,23 +233,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 +294,64 @@ 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);
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 18;
|
||||
|
||||
#ifdef WIFI_SSID
|
||||
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);
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 12;
|
||||
#endif
|
||||
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID)
|
||||
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;
|
||||
#ifdef HAS_4G_MODEM
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader [T] Phone ");
|
||||
#elif defined(MECK_AUDIO_VARIANT)
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader [P] Audiobooks");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
|
||||
#endif
|
||||
#ifdef MECK_WEB_READER
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
|
||||
#endif
|
||||
y += 14;
|
||||
|
||||
// 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,13 +396,26 @@ 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
|
||||
} else if (_page == HomePage::ADVERT) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
|
||||
@@ -374,7 +465,7 @@ 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()) {
|
||||
uint16_t sps = gpsStream.getSentencesPerSec();
|
||||
@@ -504,6 +595,65 @@ 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
|
||||
uint16_t remCap = board.getRemainingCapacity();
|
||||
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 +714,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 +723,7 @@ public:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
|
||||
_task->notify(UIEventType::ack);
|
||||
if (the_mesh.advert()) {
|
||||
@@ -600,7 +752,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 +892,12 @@ 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
|
||||
|
||||
ui_started_at = millis();
|
||||
_alert_expiry = 0;
|
||||
|
||||
@@ -750,6 +909,11 @@ 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
|
||||
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
|
||||
#ifdef HAS_4G_MODEM
|
||||
sms_screen = new SMSScreen(this);
|
||||
#endif
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
|
||||
@@ -791,12 +955,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 +979,18 @@ 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 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 +1005,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 +1062,31 @@ void UITask::shutdown(bool restart){
|
||||
if (restart) {
|
||||
_board->reboot();
|
||||
} else {
|
||||
_display->turnOff();
|
||||
// Disable BLE if active
|
||||
if (_serial != NULL && _serial->isEnabled()) {
|
||||
_serial->disable();
|
||||
}
|
||||
|
||||
// Disable WiFi if active
|
||||
#ifdef WIFI_SSID
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
#endif
|
||||
|
||||
// Disable GPS if active
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
{
|
||||
extern GPSDutyCycle gpsDuty;
|
||||
if (_sensors != NULL && _node_prefs != NULL && _node_prefs->gps_enabled) {
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
gpsDuty.disable();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Power off LoRa radio, display, and board
|
||||
radio_driver.powerOff();
|
||||
_display->turnOff();
|
||||
_board->powerOff();
|
||||
}
|
||||
}
|
||||
@@ -972,6 +1171,14 @@ void UITask::loop() {
|
||||
|
||||
userLedHandler();
|
||||
|
||||
// Turn off keyboard flash after timeout
|
||||
#ifdef KB_BL_PIN
|
||||
if (_kb_flash_off_at && millis() >= _kb_flash_off_at) {
|
||||
digitalWrite(KB_BL_PIN, LOW);
|
||||
_kb_flash_off_at = 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
if (buzzer.isPlaying()) buzzer.loop();
|
||||
#endif
|
||||
@@ -1082,13 +1289,13 @@ void UITask::toggleGPS() {
|
||||
|
||||
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();
|
||||
notify(UIEventType::ack);
|
||||
} else {
|
||||
// Enable GPS  start duty cycle
|
||||
// Enable GPS  start duty cycle
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
_node_prefs->gps_enabled = 1;
|
||||
gpsDuty.enable();
|
||||
@@ -1219,6 +1426,35 @@ 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();
|
||||
}
|
||||
@@ -1230,4 +1466,79 @@ 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::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);
|
||||
}
|
||||
setCurrScreen(web_reader);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
#endif
|
||||
|
||||
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
|
||||
if (repeater_admin && isOnRepeaterAdmin()) {
|
||||
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
|
||||
_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
|
||||
}
|
||||
}
|
||||
|
||||
#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,14 @@
|
||||
#include "../AbstractUITask.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "SMSScreen.h"
|
||||
#endif
|
||||
|
||||
#ifdef MECK_WEB_READER
|
||||
#include "WebReaderScreen.h"
|
||||
#endif
|
||||
|
||||
class UITask : public AbstractUITask {
|
||||
DisplayDriver* _display;
|
||||
SensorManager* _sensors;
|
||||
@@ -32,6 +40,7 @@ class UITask : public AbstractUITask {
|
||||
GenericVibration vibration;
|
||||
#endif
|
||||
unsigned long _next_refresh, _auto_off;
|
||||
unsigned long _kb_flash_off_at; // Keyboard flash turn-off timer
|
||||
NodePrefs* _node_prefs;
|
||||
char _alert[80];
|
||||
unsigned long _alert_expiry;
|
||||
@@ -56,6 +65,14 @@ 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
|
||||
#ifdef MECK_WEB_READER
|
||||
UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required)
|
||||
#endif
|
||||
UIScreen* curr;
|
||||
|
||||
void userLedHandler();
|
||||
@@ -72,6 +89,7 @@ public:
|
||||
|
||||
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
|
||||
next_batt_chck = _next_refresh = 0;
|
||||
_kb_flash_off_at = 0;
|
||||
ui_started_at = 0;
|
||||
curr = NULL;
|
||||
}
|
||||
@@ -84,6 +102,16 @@ 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
|
||||
#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; }
|
||||
int getMsgCount() const { return _msgcount; }
|
||||
@@ -94,6 +122,17 @@ 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; }
|
||||
#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 +147,10 @@ public:
|
||||
|
||||
// Add a sent message to the channel screen history
|
||||
void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) 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;
|
||||
|
||||
// Get current screen for checking state
|
||||
UIScreen* getCurrentScreen() const { return curr; }
|
||||
@@ -117,10 +160,17 @@ 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; }
|
||||
#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;
|
||||
|
||||
|
||||
3139
examples/companion_radio/ui-new/Webreaderscreen.h
Normal file
3139
examples/companion_radio/ui-new/Webreaderscreen.h
Normal file
File diff suppressed because it is too large
Load Diff
16
examples/companion_radio/ui-new/webreaderdeps.cpp
Normal file
16
examples/companion_radio/ui-new/webreaderdeps.cpp
Normal file
@@ -0,0 +1,16 @@
|
||||
// WebReaderDeps.cpp
|
||||
// -----------------------------------------------------------------------
|
||||
// PlatformIO library dependency finder (LDF) hint file.
|
||||
//
|
||||
// The web reader's WiFi/HTTP includes live in WebReaderScreen.h (header-only),
|
||||
// but PlatformIO's LDF can't always trace framework library dependencies
|
||||
// through conditional #include chains in headers. This .cpp file exposes
|
||||
// the includes at the top level where the scanner reliably finds them.
|
||||
//
|
||||
// No actual code here — just #include directives for the dependency finder.
|
||||
// -----------------------------------------------------------------------
|
||||
#ifdef MECK_WEB_READER
|
||||
#include <WiFi.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#endif
|
||||
@@ -27,6 +27,7 @@ build_flags = -w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1
|
||||
-D LORA_FREQ=869.525
|
||||
-D LORA_BW=250
|
||||
-D LORA_SF=11
|
||||
-D ENABLE_ADVERT_ON_BOOT=1
|
||||
-D ENABLE_PRIVATE_KEY_IMPORT=1 ; NOTE: comment these out for more secure firmware
|
||||
-D ENABLE_PRIVATE_KEY_EXPORT=1
|
||||
-D RADIOLIB_EXCLUDE_CC1101=1
|
||||
@@ -58,6 +59,7 @@ platform = platformio/espressif32@6.11.0
|
||||
monitor_filters = esp32_exception_decoder
|
||||
extra_scripts = merge-bin.py
|
||||
build_flags = ${arduino_base.build_flags}
|
||||
-D ESP32_PLATFORM
|
||||
; -D ESP32_CPU_FREQ=80 ; change it to your need
|
||||
build_src_filter = ${arduino_base.build_src_filter}
|
||||
|
||||
@@ -67,10 +69,10 @@ lib_deps =
|
||||
file://arch/esp32/AsyncElegantOTA
|
||||
|
||||
; esp32c6 uses arduino framework 3.x
|
||||
; WARNING: experimental. pioarduino on esp32c6 needs work - it's not considered stable and has issues.
|
||||
; WARNING: experimental. May not work as stable as other platforms.
|
||||
[esp32c6_base]
|
||||
extends = esp32_base
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.12/platform-espressif32.zip
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.13-1/platform-espressif32.zip
|
||||
|
||||
; ----------------- NRF52 ---------------------
|
||||
|
||||
@@ -79,7 +81,7 @@ extends = arduino_base
|
||||
platform = nordicnrf52
|
||||
platform_packages =
|
||||
framework-arduinoadafruitnrf52 @ 1.10700.0
|
||||
extra_scripts =
|
||||
extra_scripts =
|
||||
create-uf2.py
|
||||
arch/nrf52/extra_scripts/patch_bluefruit.py
|
||||
build_flags = ${arduino_base.build_flags}
|
||||
@@ -147,4 +149,4 @@ lib_deps =
|
||||
adafruit/Adafruit_VL53L0X @ ^1.2.4
|
||||
stevemarple/MicroNMEA @ ^2.0.6
|
||||
adafruit/Adafruit BME680 Library @ ^2.0.4
|
||||
adafruit/Adafruit BMP085 Library @ ^1.2.4
|
||||
adafruit/Adafruit BMP085 Library @ ^1.2.4
|
||||
@@ -249,4 +249,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
|
||||
@@ -72,10 +72,11 @@ 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
|
||||
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - complete");
|
||||
@@ -123,4 +124,243 @@ uint8_t TDeckBoard::getBatteryPercent() {
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---- BQ27220 extended register helpers ----
|
||||
|
||||
#if HAS_BQ27220
|
||||
// Read a 16-bit register from BQ27220. Returns 0 on I2C error.
|
||||
static uint16_t bq27220_read16(uint8_t reg) {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(reg);
|
||||
if (Wire.endTransmission(false) != 0) return 0;
|
||||
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2) != 2) return 0;
|
||||
uint16_t val = Wire.read();
|
||||
val |= (Wire.read() << 8);
|
||||
return val;
|
||||
}
|
||||
|
||||
// Read a single byte from BQ27220 register.
|
||||
static uint8_t bq27220_read8(uint8_t reg) {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(reg);
|
||||
if (Wire.endTransmission(false) != 0) return 0;
|
||||
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)1) != 1) return 0;
|
||||
return Wire.read();
|
||||
}
|
||||
|
||||
// Write a 16-bit subcommand to BQ27220 Control register (0x00).
|
||||
// Subcommands control unsealing, config mode, sealing, etc.
|
||||
static bool bq27220_writeControl(uint16_t subcmd) {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x00); // Control register
|
||||
Wire.write(subcmd & 0xFF); // LSB first
|
||||
Wire.write((subcmd >> 8) & 0xFF); // MSB
|
||||
return Wire.endTransmission() == 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---- BQ27220 Design Capacity configuration ----
|
||||
// The BQ27220 ships with a 3000 mAh default. The T-Deck Pro uses a 1400 mAh
|
||||
// cell. This function checks on boot and writes the correct value via the
|
||||
// MAC Data Memory interface if needed. The value persists in battery-backed
|
||||
// RAM, so this typically only writes once (or after a full battery disconnect).
|
||||
//
|
||||
// Procedure follows TI TRM SLUUBD4A Section 6.1:
|
||||
// 1. Unseal → 2. Full Access → 3. Enter CFG_UPDATE
|
||||
// 4. Write Design Capacity via MAC → 5. Exit CFG_UPDATE → 6. Seal
|
||||
|
||||
bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
#if HAS_BQ27220
|
||||
// Read current design capacity from standard command register
|
||||
uint16_t currentDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
Serial.printf("BQ27220: Design Capacity = %d mAh (target %d)\n", currentDC, designCapacity_mAh);
|
||||
|
||||
if (currentDC == designCapacity_mAh) {
|
||||
Serial.println("BQ27220: Design Capacity already correct, skipping");
|
||||
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 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);
|
||||
|
||||
if (verifyDC == designCapacity_mAh) {
|
||||
Serial.println("BQ27220: Configuration SUCCESS");
|
||||
} else {
|
||||
Serial.println("BQ27220: Configuration FAILED");
|
||||
}
|
||||
|
||||
// Step 7: Seal the device
|
||||
bq27220_writeControl(0x0030);
|
||||
delay(5);
|
||||
|
||||
return verifyDC == designCapacity_mAh;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
int16_t TDeckBoard::getAvgCurrent() {
|
||||
#if HAS_BQ27220
|
||||
return (int16_t)bq27220_read16(BQ27220_REG_AVG_CURRENT);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int16_t TDeckBoard::getAvgPower() {
|
||||
#if HAS_BQ27220
|
||||
return (int16_t)bq27220_read16(BQ27220_REG_AVG_POWER);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t TDeckBoard::getTimeToEmpty() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_TIME_TO_EMPTY);
|
||||
#else
|
||||
return 0xFFFF;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t TDeckBoard::getRemainingCapacity() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_REMAIN_CAP);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t TDeckBoard::getFullChargeCapacity() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t TDeckBoard::getDesignCapacity() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int16_t TDeckBoard::getBattTemperature() {
|
||||
#if HAS_BQ27220
|
||||
uint16_t raw = bq27220_read16(BQ27220_REG_TEMPERATURE);
|
||||
// BQ27220 returns 0.1°K, convert to 0.1°C (273.1K = 0°C)
|
||||
return (int16_t)(raw - 2731);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@ build_flags =
|
||||
-D PIN_DISPLAY_BL=45
|
||||
-D PIN_USER_BTN=0
|
||||
-D CST328_PIN_RST=38
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.3A"'
|
||||
-D ARDUINO_LOOP_STACK_SIZE=32768
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/LilyGo_TDeck_Pro>
|
||||
+<helpers/sensors/*.cpp>
|
||||
@@ -90,25 +92,12 @@ lib_deps =
|
||||
adafruit/Adafruit GFX Library@^1.11.0
|
||||
bakercp/CRC32@^2.0.0
|
||||
|
||||
[env:LilyGo_TDeck_Pro_companion_radio_usb]
|
||||
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
|
||||
; ---------------------------------------------------------------------------
|
||||
; Meck unified builds — one codebase, three variants via build flags
|
||||
; ---------------------------------------------------------------------------
|
||||
|
||||
[env:LilyGo_TDeck_Pro_companion_radio_ble]
|
||||
; Audio + BLE companion (audio-player hardware with BLE phone bridging)
|
||||
[env:meck_audio_ble]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
@@ -117,6 +106,8 @@ build_flags =
|
||||
-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>
|
||||
@@ -126,21 +117,51 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
lib_deps =
|
||||
${LilyGo_TDeck_Pro.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
|
||||
bitbank2/JPEGDEC
|
||||
|
||||
[env:LilyGo_TDeck_Pro_repeater]
|
||||
; Audio standalone (audio-player hardware, no BLE/WiFi — maximum battery life)
|
||||
; No MECK_WEB_READER: WiFi power draw conflicts with zero-radio-power design.
|
||||
[env:meck_audio_standalone]
|
||||
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=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D MECK_AUDIO_VARIANT
|
||||
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
|
||||
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
|
||||
bitbank2/JPEGDEC
|
||||
|
||||
; 4G + BLE companion (4G modem hardware, no audio — GPIO conflict with PCM5102A)
|
||||
[env:meck_4g_ble]
|
||||
extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=400
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D HAS_4G_MODEM=1
|
||||
; -D MECK_WEB_READER=1
|
||||
-D FIRMWARE_VERSION='"Meck v0.9.3-4G"'
|
||||
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