Compare commits

...

43 Commits

Author SHA1 Message Date
pelgraine 60ec294ee6 update readme for Meck v1.6 2026-03-31 03:46:59 +11:00
pelgraine 5497950892 tdpro remote repeater ota firmware update update 2026-03-31 03:15:30 +11:00
pelgraine c687133b05 tdpro refined file export contacts selection json 2026-03-31 02:49:57 +11:00
pelgraine c7d0449181 remove sleep for remote repeater 2026-03-30 13:20:31 +11:00
pelgraine 9ddb692806 fix mqttsubscribe 2026-03-30 13:11:54 +11:00
pelgraine 0cab2ddfa7 fix tdpro remote admin display and lora init sd card mix 2026-03-30 13:02:31 +11:00
pelgraine d07ad71d5d tdpro remote 4g repeater admin via web app 2026-03-30 12:23:02 +11:00
pelgraine b4983e48f0 set custom contact paths 2026-03-29 17:06:45 +11:00
pelgraine b991eb0fe7 bumped up max contacts for BLE companions to 510 2026-03-29 16:15:55 +11:00
pelgraine c15b30079c update f send key for previously recorded voice notes 2026-03-29 14:49:31 +11:00
pelgraine 9d7cbd4866 tdpro audio only - voice notes over lora - 5 seconds stage 1 2026-03-29 14:04:54 +11:00
pelgraine b9283af7fc update serial settings guide 2026-03-28 01:41:40 +11:00
pelgraine 39cd30890b update readme for new v1.5 features 2026-03-28 01:41:16 +11:00
pelgraine 902577ed10 update build date 2026-03-28 01:11:26 +11:00
pelgraine ce93cfa033 sd file manager ota system 2026-03-27 03:36:20 +11:00
pelgraine 2be399f65a undo accidental battery size change commit 2026-03-27 02:59:51 +11:00
pelgraine 5679cda38e tdpro touch paches - dialpad touch system conflict fix and longpress changed to 750ms 2026-03-27 02:43:06 +11:00
pelgraine 1ea883783c update firmware version for incoming ota file handler updates 2026-03-27 02:29:09 +11:00
pelgraine bf8cf32bc2 speed up ble sync time; fix version in tdpro platformio 2026-03-27 01:56:17 +11:00
pelgraine 465a29bb23 fix bootindex method so ereader subdirectory files are recognised and pre-cache is completed properly 2026-03-27 00:58:02 +11:00
pelgraine 81eca29b69 implement meshcore PR 2151 changes 2026-03-27 00:43:10 +11:00
pelgraine 342cf4e745 tdpro large font pref option; various large font ui fixes; fix fcc recognition in t5s3 to match 1500 2026-03-26 15:34:09 +11:00
pelgraine c52a190ace update build date 2026-03-26 00:56:20 +11:00
pelgraine a7bc7a4733 t5s3 only lightsleep mode 2026-03-25 20:17:42 +11:00
pelgraine 47a0d2cc95 Update README.md
Made it really stupidly clear that this is vibecoded
2026-03-25 19:57:47 +11:00
pelgraine 5dda0b686e Incorporate PR 2044 and 2141; tdpro alarm screen - needs 44khz mp3 for sounds 2026-03-25 19:57:35 +11:00
pelgraine 60dcd6a89e tdpro - remove hint after boot for non-first time flash 2026-03-25 07:25:48 +11:00
pelgraine 19efb52521 udpate readme 2026-03-23 15:16:57 +11:00
pelgraine 81ef3ea3c5 update hint text for nav hint for first-time flashers; fix spiffs failure for first-time flash boot 2026-03-23 14:59:31 +11:00
pelgraine 6f07b7a372 update readme to do 2026-03-23 13:36:54 +11:00
pelgraine b0f74b101a tdpro - update firmware build date; improve keyboard responsiveness after boot 2026-03-23 13:33:23 +11:00
pelgraine 06a064538e fix lock screen bug cpupowermanager issue 2026-03-22 22:56:28 +11:00
pelgraine 166a433353 td pro - fix missing F discover prompt on home screen for standalone variants 2026-03-22 19:58:12 +11:00
pelgraine 735fefd203 update readme 2026-03-22 18:50:59 +11:00
pelgraine ed5cda4f44 readme update for v1.3 2026-03-22 18:49:38 +11:00
pelgraine b208af83f6 t5s3 ota 2026-03-22 16:11:37 +11:00
pelgraine bad821ac4b tdpro ota update firmware functionality implemented; roomserver ui sender name display fix and speed up delivery time 2026-03-22 13:16:25 +11:00
pelgraine 8839012153 firmware build date 2026-03-22 11:49:47 +11:00
pelgraine 0958ef079e Fix T5S3 word wrap regression ereader; persist dark mode, portrait mode, baudrate, and auto lock timer in data store 2026-03-22 11:48:57 +11:00
pelgraine 0bf2826110 roomserver additions stage 2 and dm ui functionality updates 2026-03-22 10:51:59 +11:00
pelgraine c2840a43aa roomserver additions stage 1; dms ui functionality improvements; removed t-deck plus device variant 2026-03-21 21:27:20 +11:00
pelgraine e8a8be521a update firmware version and build date 2026-03-21 18:39:06 +11:00
pelgraine a627fbe0e9 t5s3 - fix for del channel ui and touch function 2026-03-20 23:20:20 +11:00
50 changed files with 12149 additions and 2850 deletions
+282 -13
View File
@@ -1,6 +1,6 @@
## Meshcore + Fork = Meck
A fork created specifically to focus on enabling BLE & WiFi companion firmware for the LilyGo T-Deck Pro & LilyGo T5 E-Paper S3 Pro. Created with the assistance of Claude AI using Meshcore v1.11 code.
A fork created specifically to focus on enabling BLE & WiFi companion firmware for the LilyGo T-Deck Pro & LilyGo T5 E-Paper S3 Pro. Created wholly with Claude AI using Meshcore v1.11 code. 100% vibecoded.
[Check out the Meck discussion channel on the MeshCore Discord](https://discord.com/channels/1343693475589263471/1460136499390447670)
@@ -8,10 +8,12 @@ A fork created specifically to focus on enabling BLE & WiFi companion firmware f
### Contents
- [Supported Devices](#supported-devices)
- [SD Card Requirements](#sd-card-requirements)
- [Flashing Firmware](#flashing-firmware)
- [First-Time Flash (Merged Firmware)](#first-time-flash-merged-firmware)
- [Upgrading Firmware](#upgrading-firmware)
- [SD Card Launcher](#sd-card-launcher)
- [Launcher](#launcher)
- [OTA Firmware Update](#ota-firmware-update-v13)
- [Path Hash Mode (v0.9.9+)](#path-hash-mode-v099)
- [T-Deck Pro](#t-deck-pro)
- [Build Variants](#t-deck-pro-build-variants)
@@ -23,6 +25,7 @@ A fork created specifically to focus on enabling BLE & WiFi companion firmware f
- [Channel Message Screen](#channel-message-screen)
- [Contacts Screen](#contacts-screen)
- [Sending a Direct Message](#sending-a-direct-message)
- [Roomservers](#roomservers)
- [Repeater Admin Screen](#repeater-admin-screen)
- [Settings Screen](#settings-screen)
- [Compose Mode](#compose-mode)
@@ -30,7 +33,16 @@ A fork created specifically to focus on enabling BLE & WiFi companion firmware f
- [Emoji Picker](#emoji-picker)
- [SMS & Phone App (4G only)](#sms--phone-app-4g-only)
- [Web Browser & IRC](#web-browser--irc)
- [Alarm Clock (Audio only)](#alarm-clock-audio-only)
- [Voice Notes Over LoRa (Audio only)](#voice-notes-over-lora-audio-only)
- [Contact Management — Select, Export & Import](#contact-management--select-export--import)
- [Lock Screen (T-Deck Pro)](#lock-screen-t-deck-pro)
- [Remote Repeater (T-Deck Pro 4G)](#remote-repeater-t-deck-pro-4g)
- [Remote Repeater Build Variant](#remote-repeater-build-variant)
- [Setting Up HiveMQ Cloud (Free MQTT Broker)](#setting-up-hivemq-cloud-free-mqtt-broker)
- [SD Card Configuration](#remote-repeater-sd-card-configuration)
- [Deploying the Remote Repeater](#deploying-the-remote-repeater)
- [Remote Dashboard (Meck-Mycelium)](#remote-dashboard-meck-mycelium)
- [T5S3 E-Paper Pro](#t5s3-e-paper-pro)
- [Build Variants](#t5s3-build-variants)
- [Touch Navigation](#touch-navigation)
@@ -46,6 +58,7 @@ A fork created specifically to focus on enabling BLE & WiFi companion firmware f
- [Text & EPUB Reader](TXT___EPUB_Reader_Guide.md)
- [Web Browser & IRC Guide](Web_App_Guide.md)
- [SMS & Phone App Guide](SMS___Phone_App_Guide.md)
- [Meck-Mycelium Web App](#meck-mycelium-web-app)
- [About MeshCore](#about-meshcore)
- [What is MeshCore?](#what-is-meshcore)
- [Key Features](#key-features)
@@ -74,6 +87,14 @@ Both devices use the ESP32-S3 with 16 MB flash and 8 MB PSRAM.
---
## SD Card Requirements
**An SD card is essential for Meck to function properly.** Many features — including the e-book reader, notes, bookmarks, web reader cache, audiobook playback, firmware updates, contact import/export, and WiFi credential storage — rely on files stored on the SD card. Without an SD card inserted, the device will boot and handle mesh messaging, but most extended features will be unavailable or will fail silently.
**Recommended:** A **32 GB or larger** microSD card formatted as **FAT32**. MeshCore users have found that **SanDisk** microSD cards are the most reliable across both the T-Deck Pro and T5S3.
---
## Flashing Firmware
Download the latest firmware from the [Releases](https://github.com/pelgraine/Meck/releases) page. Each release includes two types of `.bin` files per build variant:
@@ -118,10 +139,27 @@ esptool.py --chip esp32s3 --port /dev/ttyACM0 --baud 921600 \
> **Tip:** If you're unsure whether the device already has a bootloader, it's always safe to use the merged file and flash at `0x0` — it will overwrite everything cleanly.
### SD Card Launcher
### Launcher
If you're loading firmware from an SD card via the LilyGo Launcher firmware, use the **non-merged** `.bin` file. The Launcher provides its own bootloader and only needs the application image.
### OTA Firmware Update (v1.3+)
Once Meck is installed, you can update firmware directly from your phone — no computer or serial cable required. The device creates a temporary WiFi access point and you upload the new `.bin` via your phone's browser.
1. Download the new **non-merged** `.bin` to your phone (from GitHub Releases, Discord, etc.)
2. On the device: **Settings → OTA Tools → Firmware Update → Enter** (T-Deck Pro) or **tap** (T5S3)
3. The device starts a WiFi network called `Meck-Update-XXXX` and displays connection details
4. On your phone: connect to the `Meck-Update` WiFi network, open a browser, go to `192.168.4.1`
5. Tap **Choose File**, select the `.bin`, tap **Upload**
6. The device receives the file, saves to SD, verifies, flashes, and reboots
The partition layout supports dual OTA slots — the old firmware remains on the inactive partition as an automatic rollback target. If the new firmware fails to boot, the ESP32 bootloader reverts to the previous working version automatically.
> **Note:** Use the **non-merged** `.bin` for OTA updates. The merged binary is only needed for first-time USB flashing.
**OTA Tools (v1.5+):** The firmware update has moved into **Settings → OTA Tools**, a submenu that also contains the new **SD File Manager**. The file manager creates the same WiFi access point and serves a browser-based interface where you can browse, upload, download, and delete files on the SD card from your phone — useful for managing audiobooks, alarm sounds, e-books, and notes without ejecting the SD card. Both OTA tools work on all variants including standalone builds.
---
## Path Hash Mode (v0.9.9+)
@@ -158,8 +196,9 @@ For a detailed explanation of what multibyte path hash means and why it matters,
| 4G + BLE | `meck_4g_ble` | Yes | Yes | A7682E | — | Yes | 500 |
| 4G + WiFi | `meck_4g_wifi` | — | Yes (TCP:5000) | A7682E | — | Yes | 1,500 |
| 4G + Standalone | `meck_4g_standalone` | — | Yes | A7682E | — | Yes | 1,500 |
| Remote Repeater (4G) | `meck_remote_repeater` | — | — | A7682E (MQTT) | — | No | — |
The audio DAC and 4G modem occupy the same hardware slot and are mutually exclusive.
The audio DAC and 4G modem occupy the same hardware slot and are mutually exclusive. The remote repeater variant operates as a dedicated MeshCore repeater with cellular MQTT management — see [Remote Repeater](#remote-repeater-t-deck-pro-4g) below.
### T-Deck Pro Keyboard Controls
@@ -180,6 +219,8 @@ The T-Deck Pro firmware includes full keyboard support for standalone messaging
| B | Open web browser (BLE and 4G variants only) |
| T | Open SMS & Phone app (4G variant only) |
| P | Open audiobook player (audio variant only) |
| K | Open alarm clock (audio variant only) |
| Mic (0) | Open voice messages (audio variant only) |
| F | Open node discovery (search for nearby repeaters/nodes) |
| H | Open last heard list (passive advert history) |
| G | Open map screen (shows contacts with GPS positions) |
@@ -236,7 +277,7 @@ The GPS page also shows the current time, satellite count, position, altitude, a
| Key | Action |
|-----|--------|
| W / S | Scroll messages up/down |
| A / D | Switch between channels |
| A / D | Switch between channels (press D past the last channel to reach the DM inbox, A to return) |
| Enter | Compose new message |
| R | Reply to a message — enter reply select mode, scroll to a message with W/S, then press Enter to compose a reply with an @mention |
| V | View relay path of the last received message (scrollable, up to 20 hops) |
@@ -251,16 +292,32 @@ Press **C** from the home screen to open the contacts list. All known mesh conta
| W / S | Scroll up / down through contacts |
| A / D | Cycle filter: All → Chat → Repeater → Room → Sensor → Favourites |
| Enter | Open DM compose (Chat contact) or repeater admin (Repeater contact) |
| X | Export contacts to SD card (wait 510 seconds for confirmation popup) |
| R | Import contacts from SD card (wait 510 seconds for confirmation popup) |
| Long-press Enter | Enter select mode (see [Contact Management](#contact-management--select-export--import)) |
| P | Open Path Editor for selected contact (set direct or multi-hop path) |
| X | Export contacts to SD card — exports selected contacts if in select mode, or all contacts otherwise |
| R | Import contacts from SD card (auto-selects most recent export by timestamp) |
| Q | Back to home screen |
**Contact limits:** Standalone and WiFi variants support up to 1,500 contacts (stored in PSRAM). BLE variants (Audio-BLE and 4G-BLE) are limited to 500 contacts due to BLE protocol constraints.
For detailed documentation on select mode, bulk operations, export format, and companion app interoperability, see [Contact Management — Select, Export & Import](#contact-management--select-export--import).
### Sending a Direct Message
Select a **Chat** contact in the contacts list and press **Enter** to start composing a direct message. The compose screen will show `DM: ContactName` in the header. Type your message and press **Enter** to send. The DM is sent encrypted directly to that contact (or flooded if no direct path is known). After sending or cancelling, you're returned to the contacts list.
Contacts with unread direct messages show a `*` marker next to their name in the contacts list.
**Reading received DMs:** On the Channel Messages screen, press **D** past the last group channel to reach the **DM inbox**. This shows all received direct messages with sender name and timestamp. Entering the DM inbox marks all DM messages as read and clears the unread indicator. Press **A** to return to group channels.
### Roomservers
Room servers are MeshCore nodes that host persistent chat rooms. Messages sent to a room server are stored and relayed to anyone who logs in. In Meck, room server messages arrive as contact messages and appear in the DM inbox alongside regular direct messages.
To interact with a room server, navigate to the Contacts screen, filter to **Room** contacts, select the room, and press **Enter** to open the Repeater Admin screen. Log in with the room's admin password to access room administration. On successful login, all unread messages from that room are automatically marked as read.
Room server messages are also synced to the companion app when connected via BLE or WiFi — the companion app will pull and display them alongside other messages.
### Repeater Admin Screen
Select a **Repeater** contact in the contacts list and press **Enter** to open the repeater admin screen. You'll be prompted for the repeater's admin password. Characters briefly appear as you type them before being masked, making it easier to enter symbols and numbers on the T-Deck Pro keyboard.
@@ -310,6 +367,7 @@ Press **S** from the home screen to open settings. On first boot (when the devic
| GPS Baud Rate | A / D to cycle (Default 38400 / 4800 / 9600 / 19200 / 38400 / 57600 / 115200), Enter to confirm. **Requires reboot to take effect.** |
| Path Hash Mode | W / S to cycle (1-byte / 2-byte / 3-byte), Enter to confirm |
| Dark Mode | Toggle inverted display — white text on black background (Enter to toggle) |
| Larger Font | Toggle larger text size on channel messages, contacts, DM inbox, and repeater admin screens (Enter to toggle) |
| Auto Lock | A / D to cycle timeout (None / 2 / 5 / 10 / 15 / 30 min), Enter to confirm |
| Contacts >> | Opens the Contacts sub-screen (see below) |
| Channels >> | Opens the Channels sub-screen (see below) |
@@ -388,6 +446,104 @@ The browser is a text-centric reader best suited to text-heavy websites. It also
For full documentation including key mappings, WiFi setup, bookmarks, IRC configuration, and SD card structure, see the [Web App Guide](Web_App_Guide.md).
### Alarm Clock (Audio only)
Press **K** from the home screen to open the alarm clock. This is available on the audio variant of the T-Deck Pro (PCM5102A DAC). Set up to five daily alarms that play custom MP3 files through the headphone jack.
**Setup:**
1. Place MP3 files (44100 Hz sample rate) in `/alarms/` on the SD card
2. Press **K** to open the alarm clock
3. Select an alarm slot (15) with **W / S** and press **Enter** to edit
4. Set the hour and minute, then choose an MP3 file from the list
5. Press **Enter** to save the alarm
| Key | Action |
|-----|--------|
| W / S | Navigate alarm slots / adjust time |
| A / D | Switch between hour and minute fields |
| Enter | Edit slot / save alarm / select MP3 |
| X | Delete selected alarm |
| Q | Back to home screen |
**When an alarm fires:**
The selected MP3 plays through the headphone jack, even if you're on another screen or playing an audiobook.
| Key | Action |
|-----|--------|
| Z | Snooze for 5 minutes |
| Any other key | Dismiss alarm |
Alarm configuration is stored in `/alarms/.alarmcfg` on the SD card. Alarms persist across reboots — if the RTC has valid time (via GPS or companion app sync), alarms fire at the correct time after a restart.
> **Note:** MP3 files should be encoded at **44100 Hz** sample rate. Lower sample rates may cause distortion due to ESP32-S3 I2S hardware limitations (same requirement as the audiobook player).
**SD Card Folder Structure:**
```
SD Card
├── alarms/
│ ├── .alarmcfg (auto-created, stores alarm slot config)
│ ├── morning-chime.mp3
│ ├── rooster.mp3
│ └── gentle-bells.mp3
├── audiobooks/ (existing — audiobook player)
│ └── ...
├── books/ (existing — text reader)
│ └── ...
└── ...
```
### Voice Notes Over LoRa (Audio only)
Press the **Microphone key** (the zero key on the keyboard) to open the Voice Messages screen. This is available on the audio variant of the T-Deck Pro (PCM5102A DAC).
Record and send voice messages of up to 12 seconds over LoRa. Audio is encoded on-device using Codec2 at 1200 bps, compressing each second of speech into a single 150-byte LoRa packet. Voice notes use very little airtime relative to what they deliver — a 5-second message is just 5 packets.
Voice notes can be sent to another T-Deck Pro Audio device (plays automatically through the headphone jack) or to any MeshCore companion device connected to the [Meck-Mycelium web app](https://pelgraine.github.io/Meck-Mycelium) (plays through your phone's speaker as a tappable bubble in the DM view).
**Before sending, your contact must have a path set.** Go to Contacts (press **C**), select your contact, and press **P** to open the Path Editor. Set a direct (zero-hop) or multi-hop path and save.
**Sending a voice note:**
1. Press the **Microphone key** to open the Voice Messages screen
2. Press and **hold** the Microphone key to record — release to stop (max 12 seconds)
3. Press **S** to open the contact picker — contacts with a direct path appear at the top
4. Tap or scroll to your contact, then press **Enter** to send
Packets are sent with staggered 3-second delays to avoid congesting the channel. On a 62.5 kHz / SF7 radio preset (e.g. Australia Narrow), a 5-second voice note arrives in roughly 20 seconds and a 12-second recording in about 42 seconds.
**Receiving voice notes:**
* **On a T-Deck Pro Audio device:** the voice message screen opens automatically and the message plays through the headphone jack. **Headphones are recommended** — the built-in speaker is very quiet.
* **Via Meck-Mycelium:** voice messages appear as "🎙️ Voice message" bubbles in the DM view. Tap to play. Codec2 decoding happens entirely in the browser via WebAssembly.
> **Note:** Voice recording and sending requires the **Audio variant** hardware (PCM5102A DAC). 4G and standalone variants cannot record or send voice notes, but any device connected to Meck-Mycelium can receive and play them.
### Contact Management — Select, Export & Import
The contacts screen supports a **select mode** for fine-grained contact management, as well as full export and import with MeshCore companion app compatibility.
**Select mode (T-Deck Pro):** Long-press **Enter** on the Contacts screen to enter select mode. Use **W / S** to scroll and press **Enter** to toggle selection on individual contacts.
**Select mode (T5S3):** Long-press the screen on the Contacts screen to enter select mode. Tap individual contacts to toggle their selection.
| Action | T-Deck Pro | T5S3 |
|--------|-----------|------|
| Enter select mode | Long-press Enter | Long-press screen |
| Toggle selection | Enter | Tap |
| Export selected | X | — |
| Bulk delete selected | Shift+Del (double-confirm) | — |
| Toggle favourite | F | — |
| Exit select mode | Q | Boot button |
**Exporting contacts:** Press **X** to export. If contacts are selected in select mode, only those contacts are exported. If no contacts are selected (pressing **X** outside select mode), all contacts are exported. Contacts are saved as a JSON file to `/meshcore/meshcore_contacts.json` on the SD card with a timestamp in the filename. The JSON format is compatible with MeshCore companion apps — you can transfer the file to your phone or computer and import it into the Android, iOS, or web companion app.
**Importing contacts:** Press **R** on the Contacts screen (outside select mode) to import. The importer automatically finds the most recent export file by looking at the timestamp in the filename. Import is a non-destructive merge — new contacts are added without removing existing ones.
**Viewing and transferring exports:** Browse and download your exported JSON files using **OTA Tools → SD File Manager** (Settings → OTA Tools → SD File Manager — connects via WiFi AP and browser), or remove the SD card and copy the files directly.
### Lock Screen (T-Deck Pro)
Double-click the Boot button to lock the screen. The lock screen shows the current time, battery percentage, and unread message count. The CPU drops to 40 MHz while locked to reduce power consumption.
@@ -398,6 +554,95 @@ An auto-lock timer can be configured in **Settings → Auto Lock** (None / 2 / 5
---
## Remote Repeater (T-Deck Pro 4G)
The remote repeater firmware turns a T-Deck Pro 4G into a self-contained MeshCore repeater with remote management over the internet. Insert an active SIM card with a data plan, configure your MQTT broker credentials on the SD card, and you can log in and manage the repeater from anywhere in the world via the [Meck-Mycelium remote dashboard](https://pelgraine.github.io/Meck-Mycelium).
The device connects to a free HiveMQ Cloud MQTT broker over the cellular network, publishing status updates (uptime, battery, signal strength, temperature, neighbour count) and subscribing to commands. The web dashboard lets you view live telemetry, sync the repeater's clock, trigger adverts, reboot the device, and more — all from a browser.
This is ideal for deploying repeaters in remote or hard-to-reach locations where you can't physically visit to administer them, but where cellular coverage exists.
### Remote Repeater Build Variant
| Variant | Environment | Companion | 4G Modem | Audio | Max Contacts |
|---------|------------|-----------|----------|-------|-------------|
| Remote Repeater (4G) | `meck_remote_repeater` | — | A7682E (MQTT) | — | — |
The remote repeater variant does not function as a companion device or chat client. It operates exclusively as a MeshCore repeater node with cellular MQTT telemetry and remote command support.
### Setting Up HiveMQ Cloud (Free MQTT Broker)
The remote repeater requires an MQTT broker to relay telemetry and commands between the device and the web dashboard. [HiveMQ Cloud](https://www.hivemq.com/mqtt-cloud-broker/) offers a free tier that is more than sufficient.
**Step 1 — Create a HiveMQ Cloud account:**
1. Go to https://console.hivemq.cloud/ and sign up for a free account
2. After logging in, a **Serverless** cluster is created automatically
3. Note the **cluster URL** shown on the overview page — it will look something like `abc123def456.s1.eu.hivemq.cloud`
4. Note the **port** — for the T-Deck Pro 4G, use the TLS port which is typically **8883**
**Step 2 — Create MQTT credentials:**
1. In the HiveMQ console, go to **Access Management**
2. Create a new set of credentials — enter a **username** and **password**
3. Save these — you'll need them for the SD card configuration file
**Step 3 — Note your connection details:**
You'll need these four values for the config file:
- **Host:** your cluster URL (e.g. `abc123def456.s1.eu.hivemq.cloud`)
- **Port:** `8883`
- **Username:** the credentials you just created
- **Password:** the credentials you just created
### Remote Repeater SD Card Configuration
Create a file called `mqtt.cfg` in the root of the SD card with your MQTT broker details:
```
abc123.s1.eu.hivemq.cloud
8883
your_hivemq_username
your_hivemq_password
repeater-name
```
The `topic` field sets the base MQTT topic. The device publishes status to `<topic>/status` and subscribes to commands on `<topic>/cmd`. If you're running multiple remote repeaters, give each one a unique topic (e.g. `meck/repeater/hilltop`, `meck/repeater/valley`).
**SD Card Folder Structure:**
```
SD Card
├── mqtt.cfg (MQTT broker credentials — required)
├── meshcore/
│ ├── contacts.bin (auto-created, repeater contact table)
│ └── ...
└── ...
```
### Deploying the Remote Repeater
1. Flash `v1.6-Meck-Remote-Repeater-merged.bin` to your T-Deck Pro 4G using the MeshCore Web Flasher or esptool.py
2. Insert a nano SIM card with an active data plan
3. Insert an SD card with your `mqtt.cfg` file
4. Power on the device — the modem will register on the cellular network (red LED indicates modem power)
5. The device boots as a MeshCore repeater, connects to the cellular network, and begins publishing status updates to your MQTT broker
6. Open the Meck-Mycelium remote dashboard to connect and manage it
The e-ink display shows the repeater's current status including node name, uptime, battery level, LoRa activity, cellular signal strength, and MQTT connection state.
### Remote Dashboard (Meck-Mycelium)
Open https://pelgraine.github.io/Meck-Mycelium and navigate to the **Remote** tab. Enter the same MQTT broker credentials and topic from your `mqtt.cfg` file. The dashboard connects directly to HiveMQ Cloud via secure WebSocket (no data passes through any third-party server) and displays live telemetry from your remote repeater.
**Dashboard features:**
- Live status: uptime, battery, cellular signal strength, temperature, neighbour count
- Clock sync: push your browser's clock to the repeater
- Send advert: trigger a MeshCore advertisement broadcast
- Reboot: remotely restart the device
---
## T5S3 E-Paper Pro
The LilyGo T5S3 E-Paper Pro (V2, H752-B) is a 4.7-inch e-ink device with capacitive touch and no physical keyboard. All navigation is done via touch gestures and the Boot button (GPIO0). The larger 960×540 display provides significantly more screen real estate than the T-Deck Pro's 240×320 panel.
@@ -492,6 +737,7 @@ The T5S3 Settings screen includes one additional display option not available on
| Setting | Description |
|---------|-------------|
| **Dark Mode** | Inverts the display — white text on black background. Tap to toggle on/off. Available on both T-Deck Pro and T5S3. |
| **Larger Font** | Increases text size on channel messages, contacts, DM inbox, and repeater admin screens. Tap to toggle on/off. Available on both T-Deck Pro and T5S3. |
| **Portrait Mode** | Rotates the display 90° from landscape (960×540) to portrait (540×960). Touch coordinates are automatically remapped. Text reader layout recalculates on orientation change. T5S3 only. |
These settings are persisted and survive reboots.
@@ -531,7 +777,7 @@ The UTC offset is configured in the Settings screen (same as T-Deck Pro) and is
| Gesture | Action |
|---------|--------|
| Swipe up / down | Scroll messages |
| Swipe left / right | Switch between channels |
| Swipe left / right | Switch between channels (swipe left past the last channel to reach the DM inbox) |
| Tap footer area | View relay path of last received message |
| Tap path overlay | Dismiss overlay |
| Long press (touch) | Open virtual keyboard to compose message to current channel |
@@ -543,7 +789,7 @@ The UTC offset is configured in the Settings screen (same as T-Deck Pro) and is
| Swipe up / down | Scroll through contacts |
| Swipe left / right | Cycle contact filter (All → Chat → Repeater → Room → Sensor → Favourites) |
| Tap | Select contact |
| Long press on Chat contact | Open virtual keyboard to compose DM |
| Long press on Chat contact | View unread DMs (if any), then compose DM |
| Long press on Repeater contact | Open repeater admin login |
#### Text Reader (File List)
@@ -624,6 +870,17 @@ The UTC offset is configured in the Settings screen (same as T-Deck Pro) and is
---
## Meck-Mycelium Web App
[Meck-Mycelium](https://pelgraine.github.io/Meck-Mycelium) is a browser-based companion app that connects to your MeshCore device via BLE (using WebBLE in Chrome). It is a fork of [WattleFoxxo's Mycelium](https://github.com/WattleFoxxo/Mycelium) PWA, extended with Meck-specific features:
- **Voice message playback** — voice notes sent from a Meck Audio device appear as tappable "🎙️ Voice message" bubbles in the DM view. Codec2 decoding happens entirely in the browser via WebAssembly — no native app or plugin required.
- **Remote repeater dashboard** — connect to your MQTT broker to view live telemetry from remote repeater devices, send commands, sync clocks, and reboot remotely.
Open **https://pelgraine.github.io/Meck-Mycelium** in Chrome on your phone or computer. WebBLE requires Chrome or a Chromium-based browser (Edge, Brave, etc.) — Firefox and Safari do not support WebBLE.
---
## About MeshCore
MeshCore is a lightweight, portable C++ library that enables multi-hop packet routing for embedded projects using LoRa and other packet radios. It is designed for developers who want to create resilient, decentralized communication networks that work without the internet.
@@ -712,10 +969,20 @@ There are a number of fairly major features in the pipeline, with no particular
- [X] Last heard passive advert list
- [X] Touch-to-select on contacts, discovery, settings, text reader, notes screens
- [X] Map screen with GPS tile rendering
- [X] WiFi companion environment
- [X] OTA firmware update via phone
- [X] DM inbox with per-contact unread indicators
- [X] Roomserver message handling and mark-read on login
- [X] Alarm clock with custom MP3 sounds (audio variant)
- [X] Customised user option for larger-font mode
- [X] Voice notes over LoRa (audio variant) with Meck-Mycelium web app playback
- [X] Remote repeater firmware with cellular MQTT management (4G variant)
- [X] Contact management: select mode, selective export, JSON import/export, bulk delete
- [ ] Fix M4B rendering to enable chaptered audiobook playback
- [ ] Better JPEG and PNG decoding
- [ ] Improve EPUB rendering and EPUB format handling
- [X] WiFi companion environment
- [ ] Figure out a way to silence the ringtone
- [ ] Figure out a way to customise the ringtone
**T5S3 E-Paper Pro:**
- [X] Core port: display, touch input, LoRa, battery, RTC
@@ -733,9 +1000,11 @@ There are a number of fairly major features in the pipeline, with no particular
- [X] CardKB external keyboard support (via QWIIC)
- [X] Last heard passive advert list
- [X] Tap-to-select on contacts, discovery, settings, text reader, notes screens
- [ ] Emoji sprites on home tiles
- [ ] Portrait mode toggle via quadruple-click Boot button
- [ ] Hibernate should auto-off backlight
- [X] OTA firmware update via phone (WiFi variant)
- [X] DM inbox with per-contact unread indicators
- [X] Roomserver message handling and mark-read on login
- [X] Customised user option for larger-font mode
- [ ] Improve EPUB rendering and EPUB format handling
## 📞 Get Support
+34
View File
@@ -57,6 +57,7 @@ All commands follow a simple pattern: `get` to read, `set` to write.
| `get radio` | All radio params in one line |
| `get utc` | UTC offset (hours) |
| `get notify` | Keyboard flash notification (on/off) |
| `get largefont` | Larger font mode (on/off) |
| `get gps` | GPS status and interval |
| `get pin` | BLE pairing PIN |
| `get path.hash.mode` | Path hash size (0=1-byte, 1=2-byte, 2=3-byte) |
@@ -64,6 +65,8 @@ All commands follow a simple pattern: `get` to read, `set` to write.
| `get af` | Airtime factor |
| `get multi.acks` | Redundant ACKs (0 or 1) |
| `get int.thresh` | Interference threshold (0=disabled) |
| `get tx.fail.reset` | TX fail reset threshold (0=disabled, default 3) |
| `get rx.fail.reboot` | RX stuck reboot threshold (0=disabled, default 3) |
| `get gps.baud` | GPS baud rate (0=compile-time default) |
| `get channels` | List all channels with index numbers |
| `get presets` | List all radio presets with parameters |
@@ -164,6 +167,15 @@ set notify on
set notify off
```
#### Larger Font Mode
Toggle larger text on channel messages, contacts, DM inbox, and repeater admin screens:
```
set largefont on
set largefont off
```
#### BLE PIN
```
@@ -231,6 +243,28 @@ set int.thresh 0
Values: 0 (disabled, default) or 14+ (14 is the typical setting). Values between 113 are not functional and will be rejected.
#### TX Fail Reset Threshold (tx.fail.reset)
Automatically resets the radio hardware after this many consecutive failed transmission attempts. This recovers from "zombie radio" states where the SX1262 stops responding to send commands.
```
set tx.fail.reset 3
set tx.fail.reset 0
```
Values: 0 (disabled) or 110 (default: 3). After the threshold is reached, the radio is reset and the failed packet is re-queued.
#### RX Stuck Reboot Threshold (rx.fail.reboot)
Automatically reboots the device after this many consecutive RX-stuck recovery failures. An RX-stuck event occurs when the radio is not in receive mode for 8 seconds despite automatic recovery attempts.
```
set rx.fail.reboot 3
set rx.fail.reboot 0
```
Values: 0 (disabled) or 110 (default: 3). A full device reboot is a last resort — this should only trigger in rare cases of persistent radio hardware malfunction.
#### GPS Baud Rate (gps.baud)
Override the GPS serial baud rate. The default (0) uses the compile-time value of 38400. **Requires a reboot to take effect** — the GPS serial port is only configured at startup.
+53
View File
@@ -252,6 +252,50 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
if (_prefs.path_hash_mode > 2) _prefs.path_hash_mode = 0;
if (_prefs.autoadd_max_hops > 64) _prefs.autoadd_max_hops = 0;
// v1.1+ Meck fields — may not exist in older prefs files
if (file.read((uint8_t *)&_prefs.gps_baudrate, sizeof(_prefs.gps_baudrate)) != sizeof(_prefs.gps_baudrate)) {
_prefs.gps_baudrate = 0; // default: use compile-time GPS_BAUDRATE
}
if (file.read((uint8_t *)&_prefs.interference_threshold, sizeof(_prefs.interference_threshold)) != sizeof(_prefs.interference_threshold)) {
_prefs.interference_threshold = 0; // default: disabled
}
if (file.read((uint8_t *)&_prefs.dark_mode, sizeof(_prefs.dark_mode)) != sizeof(_prefs.dark_mode)) {
_prefs.dark_mode = 0; // default: light mode
}
if (file.read((uint8_t *)&_prefs.portrait_mode, sizeof(_prefs.portrait_mode)) != sizeof(_prefs.portrait_mode)) {
_prefs.portrait_mode = 0; // default: landscape
}
if (file.read((uint8_t *)&_prefs.auto_lock_minutes, sizeof(_prefs.auto_lock_minutes)) != sizeof(_prefs.auto_lock_minutes)) {
_prefs.auto_lock_minutes = 0; // default: disabled
}
if (file.read((uint8_t *)&_prefs.hint_shown, sizeof(_prefs.hint_shown)) != sizeof(_prefs.hint_shown)) {
_prefs.hint_shown = 0; // default: show boot hint
}
if (file.read((uint8_t *)&_prefs.large_font, sizeof(_prefs.large_font)) != sizeof(_prefs.large_font)) {
_prefs.large_font = 0; // default: tiny font
}
if (file.read((uint8_t *)&_prefs.tx_fail_reset_threshold, sizeof(_prefs.tx_fail_reset_threshold)) != sizeof(_prefs.tx_fail_reset_threshold)) {
_prefs.tx_fail_reset_threshold = 3; // default: 3
}
if (file.read((uint8_t *)&_prefs.rx_fail_reboot_threshold, sizeof(_prefs.rx_fail_reboot_threshold)) != sizeof(_prefs.rx_fail_reboot_threshold)) {
_prefs.rx_fail_reboot_threshold = 3; // default: 3
}
// Clamp to valid ranges
if (_prefs.dark_mode > 1) _prefs.dark_mode = 0;
if (_prefs.portrait_mode > 1) _prefs.portrait_mode = 0;
if (_prefs.hint_shown > 1) _prefs.hint_shown = 0;
if (_prefs.large_font > 1) _prefs.large_font = 0;
if (_prefs.tx_fail_reset_threshold > 10) _prefs.tx_fail_reset_threshold = 3;
if (_prefs.rx_fail_reboot_threshold > 10) _prefs.rx_fail_reboot_threshold = 3;
// auto_lock_minutes: only accept known options (0, 2, 5, 10, 15, 30)
{
uint8_t alm = _prefs.auto_lock_minutes;
if (alm != 0 && alm != 2 && alm != 5 && alm != 10 && alm != 15 && alm != 30) {
_prefs.auto_lock_minutes = 0;
}
}
file.close();
}
}
@@ -291,6 +335,15 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.ringtone_enabled, sizeof(_prefs.ringtone_enabled)); // 90
file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 91
file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 92
file.write((uint8_t *)&_prefs.gps_baudrate, sizeof(_prefs.gps_baudrate)); // 93
file.write((uint8_t *)&_prefs.interference_threshold, sizeof(_prefs.interference_threshold)); // 97
file.write((uint8_t *)&_prefs.dark_mode, sizeof(_prefs.dark_mode)); // 98
file.write((uint8_t *)&_prefs.portrait_mode, sizeof(_prefs.portrait_mode)); // 99
file.write((uint8_t *)&_prefs.auto_lock_minutes, sizeof(_prefs.auto_lock_minutes)); // 100
file.write((uint8_t *)&_prefs.hint_shown, sizeof(_prefs.hint_shown)); // 101
file.write((uint8_t *)&_prefs.large_font, sizeof(_prefs.large_font)); // 102
file.write((uint8_t *)&_prefs.tx_fail_reset_threshold, sizeof(_prefs.tx_fail_reset_threshold)); // 103
file.write((uint8_t *)&_prefs.rx_fail_reboot_threshold, sizeof(_prefs.rx_fail_reboot_threshold)); // 104
file.close();
}
+188 -9
View File
@@ -264,6 +264,16 @@ int MyMesh::getInterferenceThreshold() const {
return _prefs.interference_threshold;
}
uint8_t MyMesh::getTxFailResetThreshold() const {
return _prefs.tx_fail_reset_threshold;
}
uint8_t MyMesh::getRxFailRebootThreshold() const {
return _prefs.rx_fail_reboot_threshold;
}
void MyMesh::onRxUnrecoverable() {
board.reboot();
}
int MyMesh::calcRxDelay(float score, uint32_t air_time) const {
if (_prefs.rx_delay_base <= 0.0f) return 0;
return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time);
@@ -498,7 +508,24 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
if (should_display && _ui) {
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, pkt->_snr);
// For signed messages (room server posts): the extra bytes contain the
// original poster's pub_key prefix. Look up their name and format as
// "PosterName: message" so the UI shows who actually wrote it.
if (txt_type == TXT_TYPE_SIGNED_PLAIN && extra && extra_len >= 4) {
ContactInfo* poster = lookupContactByPubKey(extra, extra_len);
if (poster) {
char formatted[MAX_PACKET_PAYLOAD];
snprintf(formatted, sizeof(formatted), "%s: %s", poster->name, text);
_ui->newMsg(path_len, from.name, formatted, offline_queue_len, msg_path, pkt->_snr);
} else {
// Poster not in contacts — show raw text (no name prefix)
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path, pkt->_snr);
}
} else {
_ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path, pkt->_snr);
}
if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::contactMessage); //buzz if enabled
}
#endif
@@ -543,12 +570,12 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui
recipient.name, delay_millis, _prefs.path_hash_mode, _prefs.path_hash_mode + 1);
// TODO: dynamic send_scope, depending on recipient and current 'home' Region
if (send_scope.isNull()) {
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
sendFlood(pkt, delay_millis, getPathHashSize());
} else {
uint16_t codes[2];
codes[0] = send_scope.calcTransportCode(pkt);
codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region?
sendFlood(pkt, codes, delay_millis, _prefs.path_hash_mode + 1);
sendFlood(pkt, codes, delay_millis, getPathHashSize());
}
}
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
@@ -565,18 +592,25 @@ void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk
// TODO: have per-channel send_scope
if (send_scope.isNull()) {
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
sendFlood(pkt, delay_millis, getPathHashSize());
} else {
uint16_t codes[2];
codes[0] = send_scope.calcTransportCode(pkt);
codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region?
sendFlood(pkt, codes, delay_millis, _prefs.path_hash_mode + 1);
sendFlood(pkt, codes, delay_millis, getPathHashSize());
}
}
void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
const char *text) {
markConnectionActive(from); // in case this is from a server, and we have a connection
// Detect VE3 voice envelope and notify voice handler
if (_voiceEnvHandler && text && strncmp(text, "VE3:", 4) == 0) {
MESH_DEBUG_PRINTLN("Voice: VE3 envelope from %s: %s", from.name, text);
_voiceEnvHandler(from.name, text);
}
queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text);
}
@@ -719,6 +753,31 @@ bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) {
return true;
}
bool MyMesh::uiSendRawToContact(uint32_t contact_idx, const uint8_t* data, uint8_t len) {
ContactInfo contact;
if (!getContactByIdx(contact_idx, contact)) return false;
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (!recipient) return false;
// Raw custom packets are direct-route only — cannot flood
if (recipient->out_path_len == OUT_PATH_UNKNOWN) {
MESH_DEBUG_PRINTLN("UI: Raw send to %s failed — no direct path", recipient->name);
return false;
}
mesh::Packet* pkt = createRawData(data, len);
if (!pkt) {
MESH_DEBUG_PRINTLN("UI: Raw send to %s failed — packet pool empty", recipient->name);
return false;
}
sendDirect(pkt, recipient->out_path, recipient->out_path_len);
MESH_DEBUG_PRINTLN("UI: Raw sent %d bytes to %s (direct, path_len=0x%02X)",
len, recipient->name, recipient->out_path_len);
return true;
}
bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password, uint32_t& est_timeout_ms) {
ContactInfo contact;
if (!getContactByIdx(contact_idx, contact)) {
@@ -737,6 +796,13 @@ bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password, uint3
uint8_t save_path_len = recipient->out_path_len;
recipient->out_path_len = OUT_PATH_UNKNOWN;
// For room servers: reset sync_since to zero so the server pushes ALL posts.
// The device has no persistent DM storage, so every session needs full history.
// sync_since naturally updates as messages arrive (BaseChatMesh::onPeerDataRecv).
if (recipient->type == ADV_TYPE_ROOM) {
recipient->sync_since = 0;
}
Serial.printf("[uiLogin] Sending login to '%s' (idx=%d, path was 0x%02X, now 0x%02X, hash_mode=%d)\n",
recipient->name, contact_idx, save_path_len, recipient->out_path_len, _prefs.path_hash_mode);
@@ -807,6 +873,43 @@ bool MyMesh::uiSendTelemetryRequest(uint32_t contact_idx) {
return true;
}
bool MyMesh::setCustomPath(int contactIdx, const uint8_t* path, uint8_t pathLen, bool lock) {
ContactInfo contact;
if (!getContactByIdx(contactIdx, contact)) return false;
ContactInfo* c = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (!c) return false;
c->out_path_len = pathLen;
int byteLen = mesh::Packet::getPathByteLenFor(pathLen);
if (byteLen > MAX_PATH_SIZE) byteLen = MAX_PATH_SIZE;
memcpy(c->out_path, path, byteLen);
c->lastmod = getRTCClock()->getCurrentTime();
if (lock) {
c->flags |= CONTACT_FLAG_CUSTOM_PATH;
}
MESH_DEBUG_PRINTLN("setCustomPath: contact %s, pathLen=0x%02X (%d hops, %dB/hop), lock=%d",
c->name, pathLen, pathLen & 0x3F, ((pathLen >> 6) & 3) + 1, lock);
return true;
}
void MyMesh::clearCustomPath(int contactIdx) {
ContactInfo contact;
if (!getContactByIdx(contactIdx, contact)) return;
ContactInfo* c = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (!c) return;
c->out_path_len = OUT_PATH_UNKNOWN;
memset(c->out_path, 0, MAX_PATH_SIZE);
c->flags &= ~CONTACT_FLAG_CUSTOM_PATH;
c->lastmod = getRTCClock()->getCurrentTime();
MESH_DEBUG_PRINTLN("clearCustomPath: contact %s — reverted to auto-discovery", c->name);
}
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) {
@@ -977,6 +1080,13 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
}
}
// let base class handle received path and data
// BUT: if this contact has a custom (manually set) path lock, don't let
// auto-discovery overwrite it. Skip the base class call entirely — ACKs
// embedded in path responses will still be delivered via separate ACK packets.
if (contact.flags & CONTACT_FLAG_CUSTOM_PATH) {
MESH_DEBUG_PRINTLN("onContactPathRecv: skipping path update for custom-path contact %s", contact.name);
return false;
}
return BaseChatMesh::onContactPathRecv(contact, in_path, in_path_len, out_path, out_path_len, extra_type, extra, extra_len);
}
@@ -1061,6 +1171,32 @@ void MyMesh::onRawDataRecv(mesh::Packet *packet) {
MESH_DEBUG_PRINTLN("onRawDataRecv(), payload_len too long: %d", packet->payload_len);
return;
}
// Log ALL incoming raw packets for diagnosis
Serial.printf("onRawDataRecv: len=%d, magic=0x%02X, route=%s\n",
packet->payload_len,
packet->payload_len > 0 ? packet->payload[0] : 0,
packet->isRouteDirect() ? "direct" : "flood");
// Voice-over-LoRa (dz0ny VE3 protocol): intercept voice packets and fetch requests
// before forwarding to BLE companion. In standalone mode (no BLE), this is the
// only way to handle them. In BLE mode, we still intercept so on-device voice works.
if (packet->payload_len > 1 && _voiceHandler) {
uint8_t magic = packet->payload[0];
if (magic == 0x56 || magic == 0x72) { // Voice data (V) or fetch request (r)
Serial.printf("onRawDataRecv: voice %s, payload_len=%d, first6=[%02X %02X %02X %02X %02X %02X]\n",
magic == 0x56 ? "PKT" : "FETCH", packet->payload_len,
packet->payload[0],
packet->payload_len > 1 ? packet->payload[1] : 0,
packet->payload_len > 2 ? packet->payload[2] : 0,
packet->payload_len > 3 ? packet->payload[3] : 0,
packet->payload_len > 4 ? packet->payload[4] : 0,
packet->payload_len > 5 ? packet->payload[5] : 0);
_voiceHandler(magic, packet->payload, packet->payload_len);
// Don't return — still forward to BLE companion if connected
}
}
int i = 0;
out_frame[i++] = PUSH_CODE_RAW_DATA;
out_frame[i++] = (int8_t)(_radio->getLastSNR() * 4);
@@ -1216,6 +1352,7 @@ void MyMesh::begin(bool has_display) {
if (_prefs.autoadd_max_hops > 64) _prefs.autoadd_max_hops = 0;
if (_prefs.dark_mode > 1) _prefs.dark_mode = 0;
if (_prefs.portrait_mode > 1) _prefs.portrait_mode = 0;
if (_prefs.hint_shown > 1) _prefs.hint_shown = 0;
#ifdef BLE_PIN_CODE // 123456 by default
if (_prefs.ble_pin == 0) {
@@ -1465,7 +1602,7 @@ void MyMesh::handleCmdFrame(size_t len) {
if (pkt) {
if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop)
unsigned long delay_millis = 0;
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
sendFlood(pkt, delay_millis, getPathHashSize());
} else {
sendZeroHop(pkt);
}
@@ -1588,6 +1725,13 @@ void MyMesh::handleCmdFrame(size_t len) {
uint8_t ch_idx = is_v3_ch ? out_frame[4] : out_frame[1];
_ui->markChannelReadFromBLE(ch_idx);
}
// Mark DM slot read when companion app syncs a contact (DM/room) message
bool is_v3_dm = (out_frame[0] == RESP_CODE_CONTACT_MSG_RECV_V3);
bool is_old_dm = (out_frame[0] == RESP_CODE_CONTACT_MSG_RECV);
if (is_v3_dm || is_old_dm) {
_ui->markChannelReadFromBLE(0xFF);
}
}
#endif
} else {
@@ -2223,6 +2367,10 @@ void MyMesh::checkCLIRescueCmd() {
Serial.printf(" > %d\n", _prefs.multi_acks);
} else if (strcmp(key, "int.thresh") == 0) {
Serial.printf(" > %d\n", _prefs.interference_threshold);
} else if (strcmp(key, "tx.fail.threshold") == 0) {
Serial.printf(" > %d\n", _prefs.tx_fail_reset_threshold);
} else if (strcmp(key, "rx.fail.threshold") == 0) {
Serial.printf(" > %d\n", _prefs.rx_fail_reboot_threshold);
} else if (strcmp(key, "gps.baud") == 0) {
uint32_t effective = _prefs.gps_baudrate ? _prefs.gps_baudrate : GPS_BAUDRATE;
Serial.printf(" > %lu (effective: %lu)\n",
@@ -2283,6 +2431,8 @@ void MyMesh::checkCLIRescueCmd() {
Serial.printf(" af: %.1f\n", _prefs.airtime_factor);
Serial.printf(" multi.acks: %d\n", _prefs.multi_acks);
Serial.printf(" int.thresh: %d\n", _prefs.interference_threshold);
Serial.printf(" tx.fail: %d\n", _prefs.tx_fail_reset_threshold);
Serial.printf(" rx.fail: %d\n", _prefs.rx_fail_reboot_threshold);
{
uint32_t eff_baud = _prefs.gps_baudrate ? _prefs.gps_baudrate : GPS_BAUDRATE;
Serial.printf(" gps.baud: %lu\n", (unsigned long)eff_baud);
@@ -2678,6 +2828,30 @@ void MyMesh::checkCLIRescueCmd() {
Serial.println(" Error: use 0 (disabled) or 14+ (typical: 14)");
}
} else if (memcmp(config, "tx.fail.threshold ", 18) == 0) {
int val = atoi(&config[18]);
if (val < 0) val = 0;
if (val > 10) val = 10;
_prefs.tx_fail_reset_threshold = (uint8_t)val;
savePrefs();
if (val == 0) {
Serial.println(" > tx fail reset disabled");
} else {
Serial.printf(" > tx fail reset after %d failures\n", val);
}
} else if (memcmp(config, "rx.fail.threshold ", 18) == 0) {
int val = atoi(&config[18]);
if (val < 0) val = 0;
if (val > 10) val = 10;
_prefs.rx_fail_reboot_threshold = (uint8_t)val;
savePrefs();
if (val == 0) {
Serial.println(" > rx fail reboot disabled");
} else {
Serial.printf(" > reboot after %d rx recovery failures\n", val);
}
} else if (memcmp(config, "gps.baud ", 9) == 0) {
uint32_t val = (uint32_t)atol(&config[9]);
if (val == 0 || val == 4800 || val == 9600 || val == 19200 ||
@@ -2775,6 +2949,8 @@ void MyMesh::checkCLIRescueCmd() {
Serial.println(" af <0-9> Airtime factor");
Serial.println(" multi.acks <0|1> Redundant ACKs (default: 1)");
Serial.println(" int.thresh <0|14+> Interference threshold dB (0=off, 14=typical)");
Serial.println(" tx.fail.threshold <0-10> TX fail radio reset (0=off, default 3)");
Serial.println(" rx.fail.threshold <0-10> RX stuck reboot (0=off, default 3)");
Serial.println(" gps.baud <rate> GPS baud (0=default, reboot to apply)");
Serial.println("");
Serial.println(" Clock:");
@@ -2999,14 +3175,17 @@ void MyMesh::loop() {
// is there are pending dirty contacts write needed?
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
if (!_store->isSaveInProgress()) {
if (_deferSaves) {
// Voice session receiving — push save forward to avoid SPI contention
dirty_contacts_expiry = futureMillis(2000);
} else if (!_store->isSaveInProgress()) {
_store->beginSaveContacts(this);
dirty_contacts_expiry = 0;
}
dirty_contacts_expiry = 0;
}
// Drive chunked contact save — write a batch each loop iteration
if (_store->isSaveInProgress()) {
if (_store->isSaveInProgress() && !_deferSaves) {
if (!_store->saveContactsChunk(20)) { // 20 contacts per chunk (~3KB, ~30ms)
_store->finishSaveContacts(); // Done or error — verify and commit
}
+38 -2
View File
@@ -8,11 +8,11 @@
#define FIRMWARE_VER_CODE 10
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "20 March 2026"
#define FIRMWARE_BUILD_DATE "31 March 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v1.2"
#define FIRMWARE_VERSION "Meck v1.6"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -70,6 +70,11 @@
#include <helpers/BaseChatMesh.h>
#include <helpers/TransportKeyStore.h>
// Custom path lock flag — bit 7 of ContactInfo.flags
// When set, onContactPathRecv skips auto-updating this contact's out_path.
// Bits 0-6 remain available (bit 0 = favourite, bits 1-3 = telemetry perms).
#define CONTACT_FLAG_CUSTOM_PATH 0x80
/* -------------------------------------------------------------------------------------- */
#define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS
@@ -133,16 +138,43 @@ public:
// Send a direct message from the UI (no BLE dependency)
bool uiSendDirectMessage(uint32_t contact_idx, const char* text);
// Send raw binary data to a contact (PAYLOAD_TYPE_RAW_CUSTOM, direct route only)
// Used for dz0ny VE3 voice protocol: voice packets (0x56) and fetch requests (0x72)
bool uiSendRawToContact(uint32_t contact_idx, const uint8_t* data, uint8_t len);
// Voice-over-LoRa: callback for incoming raw voice packets (dz0ny VE3 protocol)
// magic 0x56 = voice data packet, 0x72 = fetch request
typedef void (*VoiceRawHandler)(uint8_t magic, const uint8_t* payload, uint8_t len);
void setVoiceHandler(VoiceRawHandler h) { _voiceHandler = h; }
// Voice-over-LoRa: callback for incoming VE3 envelope in a DM
// Called with sender name and the VE3 text (e.g. "VE3:a:1:3:2")
typedef void (*VoiceEnvelopeHandler)(const char* senderName, const char* ve3Text);
void setVoiceEnvelopeHandler(VoiceEnvelopeHandler h) { _voiceEnvHandler = h; }
// Defer contact saves while voice packets are being received
// (SD writes block SPI bus shared with LoRa radio)
void setDeferSaves(bool defer) { _deferSaves = defer; }
bool isDeferSaves() const { return _deferSaves; }
// Repeater admin - UI-initiated operations
bool uiLoginToRepeater(uint32_t contact_idx, const char* password, uint32_t& est_timeout_ms);
bool uiSendCliCommand(uint32_t contact_idx, const char* command);
bool uiSendTelemetryRequest(uint32_t contact_idx);
int getAdminContactIdx() const { return _admin_contact_idx; }
// Custom path editor — set or clear a manually configured path for a contact
// When locked, automatic path discovery will not overwrite this contact's path.
bool setCustomPath(int contactIdx, const uint8_t* path, uint8_t pathLen, bool lock);
void clearCustomPath(int contactIdx);
protected:
float getAirtimeBudgetFactor() const override;
int getInterferenceThreshold() const override;
uint8_t getTxFailResetThreshold() const override;
uint8_t getRxFailRebootThreshold() const override;
void onRxUnrecoverable() override;
int calcRxDelay(float score, uint32_t air_time) const override;
uint32_t getRetransmitDelay(const mesh::Packet *packet) override;
uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override;
@@ -150,6 +182,7 @@ protected:
uint8_t getAutoAddMaxHops() const override;
bool filterRecvFloodPacket(mesh::Packet* packet) override;
uint8_t getPathHashSize() const override { return _prefs.path_hash_mode + 1; }
void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override;
@@ -226,6 +259,9 @@ private:
DataStore* _store;
NodePrefs _prefs;
VoiceRawHandler _voiceHandler = nullptr;
VoiceEnvelopeHandler _voiceEnvHandler = nullptr;
bool _deferSaves = false;
uint32_t pending_login;
uint32_t pending_status;
uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ
+38
View File
@@ -38,4 +38,42 @@ struct NodePrefs { // persisted to file
uint8_t dark_mode; // 0=off (white bg), 1=on (black bg)
uint8_t portrait_mode; // 0=landscape, 1=portrait — T5S3 only
uint8_t auto_lock_minutes; // 0=disabled, 2/5/10/15/30=auto-lock after idle
uint8_t hint_shown; // 0=show nav hint on boot, 1=already shown (dismiss permanently)
uint8_t large_font; // 0=tiny (built-in 6x8), 1=larger (FreeSans9pt) — T-Deck Pro only
uint8_t tx_fail_reset_threshold; // 0=disabled, 1-10, default 3
uint8_t rx_fail_reboot_threshold; // 0=disabled, 1-10, default 3
// --- Font helpers (inline, no overhead) ---
// Returns the DisplayDriver text-size index for "small/body" text.
// T-Deck Pro: 0 = built-in 6×8, 1 = FreeSans9pt.
// T5S3: both 0 and 1 are 12pt fonts (regular vs bold) with identical line
// height, so large_font has no layout effect there.
inline uint8_t smallTextSize() const {
return large_font ? 1 : 0;
}
// Returns the virtual-coordinate line height matching smallTextSize().
// T-Deck Pro size 0 → 9 (6×8 + 1px gap), size 1 → 11 (9pt ascent+descent).
// T5S3 size 0/1 → same 12pt height → always 9 in virtual coords.
inline int smallLineH() const {
#if defined(LilyGo_T5S3_EPaper_Pro)
return 9;
#else
return large_font ? 11 : 9;
#endif
}
// Returns the Y offset for selection highlight fillRect (T-Deck Pro only).
// Size 0 (built-in font): cursor positions at top-left, +5 offset in
// setCursor places text below → fillRect at y+5 aligns with text.
// Size 1 (FreeSans9pt): cursor positions at baseline, ascenders render
// upward → fillRect must start above baseline to cover ascenders.
// T5S3: always 0 (both sizes use baseline fonts with highlight at y).
inline int smallHighlightOff() const {
#if defined(LilyGo_T5S3_EPaper_Pro)
return 0;
#else
return large_font ? -2 : 5;
#endif
}
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -43,6 +43,8 @@
// JPEG decoder for cover art — JPEGDEC by bitbank2
#include <JPEGDEC.h>
#include "../NodePrefs.h"
// Forward declarations
class UITask;
@@ -151,6 +153,7 @@ public:
private:
UITask* _task;
NodePrefs* _prefs;
Audio* _audio;
Mode _mode;
bool _sdReady;
@@ -1193,10 +1196,10 @@ private:
}
// Switch to tiny font for file list (6x8 built-in)
display.setTextSize(0);
display.setTextSize(_prefs ? _prefs->smallTextSize() : 0);
// Calculate visible items — tiny font uses ~8 virtual units per line
int itemHeight = 8;
// Calculate visible items
int itemHeight = (_prefs ? _prefs->smallLineH() : 9) - 1;
int listTop = 13;
int listBottom = display.height() - 14; // Reserve footer space
int visibleItems = (listBottom - listTop) / itemHeight;
@@ -1208,7 +1211,7 @@ private:
_scrollOffset = _selectedFile - visibleItems + 1;
}
// Approx chars that fit in tiny font (~36 on 128 virtual width)
// Approx chars for suffix/type tag sizing (still needed for type tag assembly)
const int charsPerLine = 36;
// Draw file list
@@ -1218,9 +1221,7 @@ private:
if (fileIdx == _selectedFile) {
display.setColor(DisplayDriver::LIGHT);
// setCursor adds +5 to y internally, but fillRect does not.
// Offset fillRect by +5 to align highlight bar with text.
display.fillRect(0, y + 5, display.width(), itemHeight - 1);
display.fillRect(0, y + (_prefs ? _prefs->smallHighlightOff() : 5), display.width(), itemHeight - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
@@ -1231,29 +1232,15 @@ private:
char fullLine[96];
if (fe.isDir) {
// Directory entry: show as "/ FolderName" or just ".."
if (fe.name == "..") {
snprintf(fullLine, sizeof(fullLine), ".. (up)");
} else {
snprintf(fullLine, sizeof(fullLine), "/%s", fe.name.c_str());
// Truncate if needed
if ((int)strlen(fullLine) > charsPerLine - 1) {
fullLine[charsPerLine - 4] = '.';
fullLine[charsPerLine - 3] = '.';
fullLine[charsPerLine - 2] = '.';
fullLine[charsPerLine - 1] = '\0';
}
}
} else {
// Audio file: "Title - Author [TYPE]"
char lineBuf[80];
// Reserve space for type tag and bookmark indicator
int suffixLen = fe.fileType.length() + 3; // " [M4B]" or " [MP3]"
int bmkLen = fe.hasBookmark ? 2 : 0; // " >"
int availChars = charsPerLine - suffixLen - bmkLen;
if (availChars < 10) availChars = 10;
if (fe.displayAuthor.length() > 0) {
snprintf(lineBuf, sizeof(lineBuf), "%s - %s",
fe.displayTitle.c_str(), fe.displayAuthor.c_str());
@@ -1261,24 +1248,13 @@ private:
snprintf(lineBuf, sizeof(lineBuf), "%s", fe.displayTitle.c_str());
}
// Truncate with ellipsis if needed
if ((int)strlen(lineBuf) > availChars) {
if (availChars > 3) {
lineBuf[availChars - 3] = '.';
lineBuf[availChars - 2] = '.';
lineBuf[availChars - 1] = '.';
lineBuf[availChars] = '\0';
} else {
lineBuf[availChars] = '\0';
}
}
// Append file type tag
snprintf(fullLine, sizeof(fullLine), "%s [%s]", lineBuf, fe.fileType.c_str());
}
display.setCursor(2, y);
display.print(fullLine);
// Pixel-aware ellipsis — reserve space for bookmark indicator
int reserveRight = (!fe.isDir && fe.hasBookmark) ? 10 : 2;
display.drawTextEllipsized(2, y, display.width() - reserveRight, fullLine);
// Bookmark indicator (right-aligned, files only)
if (!fe.isDir && fe.hasBookmark) {
@@ -1464,8 +1440,8 @@ private:
}
public:
AudiobookPlayerScreen(UITask* task, Audio* audio)
: _task(task), _audio(audio), _mode(FILE_LIST),
AudiobookPlayerScreen(UITask* task, Audio* audio, NodePrefs* prefs = nullptr)
: _task(task), _prefs(prefs), _audio(audio), _mode(FILE_LIST),
_sdReady(false), _i2sInitialized(false), _dacPowered(false),
_displayRef(nullptr),
_selectedFile(0), _scrollOffset(0),
+594 -42
View File
@@ -59,11 +59,19 @@ public:
uint8_t path_len;
uint8_t channel_idx; // Which channel this message belongs to
int8_t snr; // Receive SNR × 4 (0 if locally sent or unknown)
uint32_t dm_peer_hash; // DM peer name hash (for conversation filtering)
uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes
char text[CHANNEL_MSG_TEXT_LEN];
bool valid;
};
// Simple hash for DM peer matching
static uint32_t peerHash(const char* s) {
uint32_t h = 5381;
while (*s) { h = ((h << 5) + h) ^ (uint8_t)*s++; }
return h;
}
private:
UITask* _task;
mesh::RTCClock* _rtc;
@@ -84,6 +92,31 @@ private:
int _replySelectPos; // Index into chronological channelMsgs[] (0=oldest)
int _replyChannelMsgCount; // Cached count from last render (for input bounds)
// DM tab (channel_idx == 0xFF) two-level view:
// Inbox mode: list of contacts you have DMs from
// Conversation mode: messages filtered to one contact
bool _dmInboxMode; // true = showing inbox list, false = conversation
int _dmInboxScroll; // Scroll position in inbox list
char _dmFilterName[32]; // Selected contact name for conversation view
int _dmContactIdx; // Contact index for conversation (-1 if unknown)
uint8_t _dmContactPerms; // Last login permissions for this contact (0=none/guest)
const uint8_t* _dmUnreadPtr; // Pointer to per-contact DM unread array (from UITask)
// Helper: does a message belong to the current view?
bool msgMatchesView(const ChannelMessage& msg) const {
if (!msg.valid) return false;
if (_viewChannelIdx != 0xFF) {
return msg.channel_idx == _viewChannelIdx;
}
// DM tab in conversation mode: filter by peer hash
if (!_dmInboxMode && _dmFilterName[0] != '\0') {
if (msg.channel_idx != 0xFF) return false;
return msg.dm_peer_hash == peerHash(_dmFilterName);
}
// Inbox mode or no filter — match all DMs
return msg.channel_idx == 0xFF;
}
// Per-channel unread message counts (standalone mode)
// Index 0..MAX_GROUP_CHANNELS-1 for channel messages
// Index MAX_GROUP_CHANNELS for DMs (channel_idx == 0xFF)
@@ -93,10 +126,13 @@ public:
ChannelScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0),
_msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false), _pathScrollPos(0), _pathHopsVisible(20),
_replySelectMode(false), _replySelectPos(-1), _replyChannelMsgCount(0) {
_replySelectMode(false), _replySelectPos(-1), _replyChannelMsgCount(0),
_dmInboxMode(true), _dmInboxScroll(0), _dmContactIdx(-1), _dmContactPerms(0), _dmUnreadPtr(nullptr) {
_dmFilterName[0] = '\0';
// Initialize all messages as invalid
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
_messages[i].valid = false;
_messages[i].dm_peer_hash = 0;
memset(_messages[i].path, 0, MSG_PATH_MAX);
}
// Initialize unread counts
@@ -106,8 +142,9 @@ public:
void setSDReady(bool ready) { _sdReady = ready; }
// Add a new message to the history
// peer_name: for DMs, the contact this message belongs to (sender for received, recipient for sent)
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text,
const uint8_t* path_bytes = nullptr, int8_t snr = 0) {
const uint8_t* path_bytes = nullptr, int8_t snr = 0, const char* peer_name = nullptr) {
// Move to next slot in circular buffer
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
@@ -118,6 +155,13 @@ public:
msg->snr = snr;
msg->valid = true;
// Set DM peer hash for conversation filtering
if (channel_idx == 0xFF) {
msg->dm_peer_hash = peerHash(peer_name ? peer_name : sender);
} else {
msg->dm_peer_hash = 0;
}
// Store path hop hashes
memset(msg->path, 0, MSG_PATH_MAX);
if (path_bytes && path_len > 0 && path_len != 0xFF) {
@@ -158,7 +202,7 @@ public:
int getMessageCountForChannel() const {
int count = 0;
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
if (_messages[i].valid && _messages[i].channel_idx == _viewChannelIdx) {
if (msgMatchesView(_messages[i])) {
count++;
}
}
@@ -173,11 +217,47 @@ public:
_scrollPos = 0;
_showPathOverlay = false;
_pathScrollPos = 0;
// Reset DM inbox state when entering DM tab
if (idx == 0xFF) {
_dmInboxMode = true;
_dmInboxScroll = 0;
_dmFilterName[0] = '\0';
_dmContactIdx = -1;
_dmContactPerms = 0;
}
markChannelRead(idx);
}
bool isDMTab() const { return _viewChannelIdx == 0xFF; }
bool isDMInboxMode() const { return _viewChannelIdx == 0xFF && _dmInboxMode; }
bool isDMConversation() const { return _viewChannelIdx == 0xFF && !_dmInboxMode; }
const char* getDMFilterName() const { return _dmFilterName; }
// Open a specific contact's DM conversation directly (skipping inbox)
void openConversation(const char* contactName, int contactIdx = -1, uint8_t perms = 0) {
strncpy(_dmFilterName, contactName, sizeof(_dmFilterName) - 1);
_dmFilterName[sizeof(_dmFilterName) - 1] = '\0';
_dmInboxMode = false;
_dmContactIdx = contactIdx;
_dmContactPerms = perms;
_scrollPos = 0;
}
int getDMContactIdx() const { return _dmContactIdx; }
uint8_t getDMContactPerms() const { return _dmContactPerms; }
void setDMContactPerms(uint8_t p) { _dmContactPerms = p; }
bool isShowingPathOverlay() const { return _showPathOverlay; }
void dismissPathOverlay() { _showPathOverlay = false; _pathScrollPos = 0; }
// Set pointer to per-contact DM unread array (called by UITask after allocation)
void setDMUnreadPtr(const uint8_t* ptr) { _dmUnreadPtr = ptr; }
// Subtract a specific amount from the DM unread slot (used by per-contact clearing)
void subtractDMUnread(int count) {
int slot = MAX_GROUP_CHANNELS; // DM slot
_unread[slot] -= count;
if (_unread[slot] < 0) _unread[slot] = 0;
}
// --- Reply select mode (R key → pick a message → Enter to @mention reply) ---
bool isReplySelectMode() const { return _replySelectMode; }
void exitReplySelect() { _replySelectMode = false; _replySelectPos = -1; }
@@ -206,7 +286,7 @@ public:
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) {
if (_messages[idx].valid && msgMatchesView(_messages[idx])) {
rsMsgs[count++] = idx;
}
}
@@ -230,7 +310,7 @@ public:
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) {
if (_messages[idx].valid && msgMatchesView(_messages[idx])) {
rsMsgs[count++] = idx;
}
}
@@ -277,7 +357,7 @@ public:
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
if (msgMatchesView(_messages[idx])
&& _messages[idx].path_len != 0) {
return &_messages[idx];
}
@@ -449,7 +529,15 @@ public:
// Get channel name
ChannelDetails channel;
if (the_mesh.getChannel(_viewChannelIdx, channel)) {
if (_viewChannelIdx == 0xFF) {
if (_dmInboxMode) {
display.print("Direct Messages");
} else {
char hdr[40];
snprintf(hdr, sizeof(hdr), "DM: %s", _dmFilterName);
display.print(hdr);
}
} else if (the_mesh.getChannel(_viewChannelIdx, channel)) {
display.print(channel.name);
} else {
sprintf(tmp, "Channel %d", _viewChannelIdx);
@@ -464,11 +552,201 @@ public:
// Divider line
display.drawRect(0, 11, display.width(), 1);
// === DM Inbox mode: show list of contacts with DMs ===
if (_viewChannelIdx == 0xFF && _dmInboxMode) {
#define DM_INBOX_MAX 20
struct DMInboxEntry {
uint32_t hash;
char name[32];
int msgCount;
int unreadCount;
uint32_t newestTs;
};
DMInboxEntry inbox[DM_INBOX_MAX];
int inboxCount = 0;
// Scan all DMs and group by peer hash
for (int i = 0; i < _msgCount && i < CHANNEL_MSG_HISTORY_SIZE; i++) {
int idx = _newestIdx - i;
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (!_messages[idx].valid || _messages[idx].channel_idx != 0xFF) continue;
if (_messages[idx].dm_peer_hash == 0) continue;
uint32_t h = _messages[idx].dm_peer_hash;
// Find existing entry by hash
int found = -1;
for (int j = 0; j < inboxCount; j++) {
if (inbox[j].hash == h) { found = j; break; }
}
if (found < 0 && inboxCount < DM_INBOX_MAX) {
found = inboxCount++;
inbox[found].hash = h;
inbox[found].name[0] = '\0';
inbox[found].msgCount = 0;
inbox[found].unreadCount = 0;
inbox[found].newestTs = 0;
// Look up name from contacts by matching peer hash
uint32_t numC = the_mesh.getNumContacts();
ContactInfo ci;
for (uint32_t c = 0; c < numC; c++) {
if (the_mesh.getContactByIdx(c, ci) && peerHash(ci.name) == h) {
strncpy(inbox[found].name, ci.name, 31);
inbox[found].name[31] = '\0';
break;
}
}
// Fallback: extract from text if contact not found
if (inbox[found].name[0] == '\0') {
extractSenderName(_messages[idx].text, inbox[found].name, sizeof(inbox[found].name));
}
}
if (found >= 0) {
inbox[found].msgCount++;
if (_messages[idx].timestamp > inbox[found].newestTs)
inbox[found].newestTs = _messages[idx].timestamp;
}
}
// Look up unread counts from per-contact array
if (_dmUnreadPtr) {
for (int e = 0; e < inboxCount; e++) {
uint32_t numC = the_mesh.getNumContacts();
ContactInfo ci;
for (uint32_t c = 0; c < numC; c++) {
if (the_mesh.getContactByIdx(c, ci) && peerHash(ci.name) == inbox[e].hash) {
inbox[e].unreadCount = _dmUnreadPtr[c];
break;
}
}
}
}
// Sort by newest timestamp descending (insertion sort)
for (int i = 1; i < inboxCount; i++) {
DMInboxEntry tmp2 = inbox[i];
int j = i - 1;
while (j >= 0 && inbox[j].newestTs < tmp2.newestTs) {
inbox[j + 1] = inbox[j];
j--;
}
inbox[j + 1] = tmp2;
}
// Render inbox list
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineH = the_mesh.getNodePrefs()->smallLineH();
int headerH = 14;
int footerH = 14;
int maxY = display.height() - footerH;
int y = headerH;
int maxVisible = (maxY - headerH) / lineH;
if (maxVisible < 3) maxVisible = 3;
// Clamp scroll
if (_dmInboxScroll >= inboxCount) _dmInboxScroll = inboxCount > 0 ? inboxCount - 1 : 0;
if (inboxCount == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, y);
display.print("No direct messages");
display.setCursor(0, y + lineH);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("DMs from contacts appear here");
#else
display.print("A/D: Switch channel");
#endif
} else {
int startIdx = max(0, min(_dmInboxScroll - maxVisible / 2,
inboxCount - maxVisible));
int endIdx = min(inboxCount, startIdx + maxVisible);
for (int i = startIdx; i < endIdx && y + lineH <= maxY; i++) {
bool selected = (i == _dmInboxScroll);
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineH);
#else
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
// Prefix: > for selected, unread indicator
char prefix[6];
if (inbox[i].unreadCount > 0) {
snprintf(prefix, sizeof(prefix), "%s*%d", selected ? ">" : " ", inbox[i].unreadCount);
} else {
snprintf(prefix, sizeof(prefix), "%s ", selected ? ">" : " ");
}
display.print(prefix);
// Name (truncated)
char filteredName[32];
display.translateUTF8ToBlocks(filteredName, inbox[i].name, sizeof(filteredName));
// Right side: message count + age
char ageStr[8];
uint32_t age = _rtc->getCurrentTime() - inbox[i].newestTs;
if (age < 60) snprintf(ageStr, sizeof(ageStr), "%ds", age);
else if (age < 3600) snprintf(ageStr, sizeof(ageStr), "%dm", age / 60);
else if (age < 86400) snprintf(ageStr, sizeof(ageStr), "%dh", age / 3600);
else snprintf(ageStr, sizeof(ageStr), "%dd", age / 86400);
char rightStr[16];
snprintf(rightStr, sizeof(rightStr), "(%d) %s", inbox[i].msgCount, ageStr);
int rightW = display.getTextWidth(rightStr) + 2;
int nameX = display.getTextWidth(prefix) + 2;
int nameMaxW = display.width() - nameX - rightW - 2;
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
display.setCursor(display.width() - rightW, y);
display.print(rightStr);
y += lineH;
}
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setCursor(0, footerY);
display.print("Swipe:Nav");
const char* rtInbox = "Hold:Open";
display.setCursor(display.width() - display.getTextWidth(rtInbox) - 2, footerY);
display.print(rtInbox);
#else
display.setCursor(0, footerY);
display.print("Q:Bck A/D:Ch");
const char* rtInbox = "Ent:Open";
display.setCursor(display.width() - display.getTextWidth(rtInbox) - 2, footerY);
display.print(rtInbox);
#endif
#ifdef USE_EINK
return 5000;
#else
return 1000;
#endif
}
// --- Path detail overlay ---
if (_showPathOverlay) {
display.setTextSize(0);
int lineH = 9;
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineH = the_mesh.getNodePrefs()->smallLineH();
int y = 14;
ChannelMessage* msg = getNewestReceivedMsg();
@@ -664,24 +942,160 @@ public:
}
if (channelMsgCount == 0) {
display.setTextSize(0); // Tiny font for body text
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // Tiny font for body text
display.setCursor(0, 20);
display.setColor(DisplayDriver::LIGHT);
display.print("No messages yet");
display.setCursor(0, 30);
if (_viewChannelIdx == 0xFF) {
char noMsg[48];
snprintf(noMsg, sizeof(noMsg), "No messages from %s", _dmFilterName);
display.print(noMsg);
display.setCursor(0, 30);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe: Switch channel");
display.setCursor(0, 40);
display.print("Long press: Compose");
display.print("Hold: Compose reply");
#else
display.print("A/D: Switch channel");
display.setCursor(0, 40);
display.print("C: Compose message");
display.print("Q: Back to inbox");
display.setCursor(0, 40);
display.print("Ent: Compose reply");
#endif
} else {
display.print("No messages yet");
display.setCursor(0, 30);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe: Switch channel");
display.setCursor(0, 40);
display.print("Long press: Compose");
#else
display.print("A/D: Switch channel");
display.setCursor(0, 40);
display.print("C: Compose message");
#endif
}
display.setTextSize(1); // Restore for footer
} else if (_viewChannelIdx == 0xFF && _dmInboxMode) {
// =================================================================
// DM Inbox: list of contacts/rooms you have DM history with
// =================================================================
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
int headerHeight = 14;
int footerHeight = 14;
int maxY = display.height() - footerHeight;
int y = headerHeight;
// Scan all DM messages and collect unique senders
#define DM_INBOX_MAX 16
struct InboxEntry {
char name[24];
int count;
uint32_t newest_ts;
};
static InboxEntry inbox[DM_INBOX_MAX];
int inboxCount = 0;
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 != 0xFF) continue;
char sender[24];
if (!extractSenderName(_messages[idx].text, sender, sizeof(sender))) continue;
// Find or add sender in inbox
bool found = false;
for (int j = 0; j < inboxCount; j++) {
if (strcmp(inbox[j].name, sender) == 0) {
inbox[j].count++;
if (_messages[idx].timestamp > inbox[j].newest_ts)
inbox[j].newest_ts = _messages[idx].timestamp;
found = true;
break;
}
}
if (!found && inboxCount < DM_INBOX_MAX) {
strncpy(inbox[inboxCount].name, sender, 23);
inbox[inboxCount].name[23] = '\0';
inbox[inboxCount].count = 1;
inbox[inboxCount].newest_ts = _messages[idx].timestamp;
inboxCount++;
}
}
// Sort by newest timestamp descending (most recent first)
for (int i = 1; i < inboxCount; i++) {
InboxEntry tmp2 = inbox[i];
int j = i - 1;
while (j >= 0 && inbox[j].newest_ts < tmp2.newest_ts) {
inbox[j + 1] = inbox[j];
j--;
}
inbox[j + 1] = tmp2;
}
if (inboxCount == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, y);
display.print("No conversations");
} else {
// Clamp scroll
if (_dmInboxScroll >= inboxCount) _dmInboxScroll = inboxCount - 1;
if (_dmInboxScroll < 0) _dmInboxScroll = 0;
int maxVisible = (maxY - headerHeight) / lineHeight;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_dmInboxScroll - maxVisible / 2,
inboxCount - maxVisible));
int endIdx = min(inboxCount, startIdx + maxVisible);
uint32_t now = _rtc->getCurrentTime();
for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) {
bool selected = (i == _dmInboxScroll);
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
display.print(selected ? ">" : " ");
// Name (ellipsized)
char filteredName[24];
display.translateUTF8ToBlocks(filteredName, inbox[i].name, sizeof(filteredName));
// Right side: message count + age
char ageStr[8];
uint32_t age = now - inbox[i].newest_ts;
if (age < 60) snprintf(ageStr, sizeof(ageStr), "%ds", age);
else if (age < 3600) snprintf(ageStr, sizeof(ageStr), "%dm", age / 60);
else if (age < 86400) snprintf(ageStr, sizeof(ageStr), "%dh", age / 3600);
else snprintf(ageStr, sizeof(ageStr), "%dd", age / 86400);
char rightStr[16];
snprintf(rightStr, sizeof(rightStr), "[%d] %s", inbox[i].count, ageStr);
int rightW = display.getTextWidth(rightStr) + 2;
int nameX = display.getTextWidth(">") + 2;
int nameMaxW = display.width() - nameX - rightW - 2;
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
display.setCursor(display.width() - rightW, y);
display.print(rightStr);
y += lineHeight;
}
}
display.setTextSize(1);
} else {
display.setTextSize(0); // Tiny font for message body
int lineHeight = 9; // 8px font + 1px spacing
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // Tiny font for message body
int lineHeight = the_mesh.getNodePrefs()->smallLineH(); // 8px font + 1px spacing
int headerHeight = 14;
int footerHeight = 14;
int scrollBarW = 4; // Width of scroll indicator on right edge
@@ -701,7 +1115,7 @@ public:
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
if (msgMatchesView(_messages[idx])) {
channelMsgs[numChannelMsgs++] = idx;
}
}
@@ -749,7 +1163,7 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, contentW, maxFillH);
#else
display.fillRect(0, y + 5, contentW, maxFillH);
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), contentW, maxFillH);
#endif
}
@@ -910,7 +1324,7 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, contentW, maxFillH - usedH);
#else
display.fillRect(0, y + 5, contentW, maxFillH - usedH);
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), contentW, maxFillH - usedH);
#endif
}
}
@@ -968,13 +1382,20 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setCursor(0, footerY);
display.print("Swipe:Ch/Scroll");
const char* midCh = "Tap:Path";
display.setCursor((display.width() - display.getTextWidth(midCh)) / 2, footerY);
display.print(midCh);
const char* rtCh = "Hold:Compose";
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
display.print(rtCh);
if (_viewChannelIdx == 0xFF) {
display.print("Swipe:Scroll");
const char* rtCh = "Hold:Reply";
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
display.print(rtCh);
} else {
display.print("Swipe:Ch/Scroll");
const char* midCh = "Tap:Path";
display.setCursor((display.width() - display.getTextWidth(midCh)) / 2, footerY);
display.print(midCh);
const char* rtCh = "Hold:Compose";
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
display.print(rtCh);
}
#else
// Left side: abbreviated controls
if (_replySelectMode) {
@@ -982,6 +1403,15 @@ public:
const char* rightText = "Ent:Reply";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
} else if (_viewChannelIdx == 0xFF) {
if (_dmContactPerms > 0) {
display.print("Q:Exit L:Admin");
} else {
display.print("Q:Exit");
}
const char* rightText = "Ent:Reply";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
} else {
display.print("Q:Bck A/D:Ch R:Rply");
const char* rightText = "Ent:New";
@@ -1080,10 +1510,92 @@ public:
return true; // Consume all other keys in reply select
}
// --- DM Inbox mode (two-level DM view) ---
if (_viewChannelIdx == 0xFF && _dmInboxMode) {
// W - scroll up in inbox
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_dmInboxScroll > 0) { _dmInboxScroll--; return true; }
return false;
}
// S - scroll down in inbox
if (c == 's' || c == 'S' || c == 0xF1) {
_dmInboxScroll++; // Clamped during render
return true;
}
// Enter - open conversation for selected entry
if (c == '\r' || c == 13) {
// Rebuild inbox by hash to find the selected entry
uint32_t seenHash[DM_INBOX_MAX];
int cur = 0;
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 != 0xFF) continue;
if (_messages[idx].dm_peer_hash == 0) continue;
uint32_t h = _messages[idx].dm_peer_hash;
bool dup = false;
for (int k = 0; k < cur; k++) {
if (seenHash[k] == h) { dup = true; break; }
}
if (dup) continue;
if (cur < DM_INBOX_MAX) seenHash[cur] = h;
if (cur == _dmInboxScroll) {
// Found the selected entry — look up name from contacts
_dmFilterName[0] = '\0';
_dmContactIdx = -1;
_dmContactPerms = 0;
uint32_t numC = the_mesh.getNumContacts();
ContactInfo ci;
for (uint32_t c2 = 0; c2 < numC; c2++) {
if (the_mesh.getContactByIdx(c2, ci) && peerHash(ci.name) == h) {
strncpy(_dmFilterName, ci.name, sizeof(_dmFilterName) - 1);
_dmFilterName[sizeof(_dmFilterName) - 1] = '\0';
_dmContactIdx = (int)c2;
break;
}
}
// Fallback to text extraction if contact not found
if (_dmFilterName[0] == '\0') {
extractSenderName(_messages[idx].text, _dmFilterName, sizeof(_dmFilterName));
}
_dmInboxMode = false;
_scrollPos = 0;
return true;
}
cur++;
}
return true;
}
// Q - let main.cpp handle (back to home)
if (c == 'q' || c == 'Q' || c == '\b') {
return false;
}
// A/D pass through to channel switching below
if (c == 'a' || c == 'A' || c == 'd' || c == 'D') {
// Fall through to channel switching
} else {
return true; // Consume other keys
}
}
// --- DM Conversation mode: Q goes back to inbox ---
if (_viewChannelIdx == 0xFF && !_dmInboxMode) {
if (c == 'q' || c == 'Q' || c == '\b') {
_dmInboxMode = true;
_dmFilterName[0] = '\0';
_scrollPos = 0;
return true;
}
}
int channelMsgCount = getMessageCountForChannel();
// R - enter reply select mode
// R - enter reply select mode (group channels only — DM tab uses Enter to reply)
if (c == 'r' || c == 'R') {
if (_viewChannelIdx == 0xFF) return false; // Not applicable on DM tab
if (channelMsgCount > 0) {
_replySelectMode = true;
// Start with newest message selected
@@ -1120,14 +1632,12 @@ public:
}
}
// A - previous channel
// A - previous channel (includes DM tab at 0xFF)
if (c == 'a' || c == 'A') {
_replySelectMode = false;
_replySelectPos = -1;
if (_viewChannelIdx > 0) {
_viewChannelIdx--;
} else {
// Wrap to last valid channel
if (_viewChannelIdx == 0xFF) {
// DM tab → go to last valid group channel
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
@@ -1135,22 +1645,64 @@ public:
break;
}
}
} else if (_viewChannelIdx > 0) {
// Skip backwards over any empty/gap slots
uint8_t prev = _viewChannelIdx - 1;
bool found = false;
while (true) {
ChannelDetails ch;
if (the_mesh.getChannel(prev, ch) && ch.name[0] != '\0') {
_viewChannelIdx = prev;
found = true;
break;
}
if (prev == 0) break;
prev--;
}
if (!found) {
// No valid channel below → wrap to DM tab
_viewChannelIdx = 0xFF;
_dmInboxMode = true;
_dmInboxScroll = 0;
_dmFilterName[0] = '\0';
}
} else {
// Channel 0 → wrap to DM tab
_viewChannelIdx = 0xFF;
_dmInboxMode = true;
_dmInboxScroll = 0;
_dmFilterName[0] = '\0';
}
_scrollPos = 0;
markChannelRead(_viewChannelIdx);
return true;
}
// D - next channel
// D - next channel (includes DM tab at 0xFF)
if (c == 'd' || c == 'D') {
_replySelectMode = false;
_replySelectPos = -1;
ChannelDetails ch;
uint8_t nextIdx = _viewChannelIdx + 1;
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
_viewChannelIdx = nextIdx;
} else {
if (_viewChannelIdx == 0xFF) {
// DM tab → wrap to channel 0
_viewChannelIdx = 0;
} else {
// Skip forward over any empty/gap slots
bool found = false;
for (uint8_t next = _viewChannelIdx + 1; next < MAX_GROUP_CHANNELS; next++) {
ChannelDetails ch;
if (the_mesh.getChannel(next, ch) && ch.name[0] != '\0') {
_viewChannelIdx = next;
found = true;
break;
}
}
if (!found) {
// Past last channel → go to DM tab
_viewChannelIdx = 0xFF;
_dmInboxMode = true;
_dmInboxScroll = 0;
_dmFilterName[0] = '\0';
}
}
_scrollPos = 0;
markChannelRead(_viewChannelIdx);
+160 -26
View File
@@ -40,6 +40,13 @@ private:
// How many rows fit on screen (computed during render)
int _rowsPerPage;
// Pointer to per-contact DM unread array (owned by UITask, set via setter)
const uint8_t* _dmUnread = nullptr;
// --- Select mode state ---
bool _selectMode;
uint8_t* _selectedBits; // Bitfield: 1 bit per MAX_CONTACTS raw index
// --- helpers ---
static const char* filterLabel(FilterMode f) {
@@ -130,21 +137,38 @@ private:
}
}
// --- Bitfield helpers ---
bool isSelectedRaw(int rawIdx) const {
if (rawIdx < 0 || rawIdx >= MAX_CONTACTS) return false;
return (_selectedBits[rawIdx / 8] & (1 << (rawIdx % 8))) != 0;
}
void setSelectedRaw(int rawIdx, bool sel) {
if (rawIdx < 0 || rawIdx >= MAX_CONTACTS) return;
if (sel) _selectedBits[rawIdx / 8] |= (1 << (rawIdx % 8));
else _selectedBits[rawIdx / 8] &= ~(1 << (rawIdx % 8));
}
public:
ContactsScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _scrollPos(0), _filter(FILTER_ALL),
_filteredCount(0), _cacheValid(false), _rowsPerPage(5) {
_filteredCount(0), _cacheValid(false), _rowsPerPage(5),
_selectMode(false) {
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
_filteredIdx = (uint16_t*)ps_calloc(MAX_CONTACTS, sizeof(uint16_t));
_filteredTs = (uint32_t*)ps_calloc(MAX_CONTACTS, sizeof(uint32_t));
_selectedBits = (uint8_t*)ps_calloc((MAX_CONTACTS + 7) / 8, 1);
#else
_filteredIdx = new uint16_t[MAX_CONTACTS]();
_filteredTs = new uint32_t[MAX_CONTACTS]();
_selectedBits = new uint8_t[(MAX_CONTACTS + 7) / 8]();
#endif
}
void invalidateCache() { _cacheValid = false; }
// Set pointer to per-contact DM unread array (called by UITask after allocation)
void setDMUnreadPtr(const uint8_t* ptr) { _dmUnread = ptr; }
void resetScroll() {
_scrollPos = 0;
_cacheValid = false;
@@ -152,15 +176,67 @@ public:
FilterMode getFilter() const { return _filter; }
// --- Select mode API ---
bool isInSelectMode() const { return _selectMode; }
void enterSelectMode() {
_selectMode = true;
memset(_selectedBits, 0, (MAX_CONTACTS + 7) / 8);
// Pre-select the currently highlighted contact
if (_filteredCount > 0 && _scrollPos < _filteredCount) {
setSelectedRaw(_filteredIdx[_scrollPos], true);
}
}
void exitSelectMode() {
_selectMode = false;
memset(_selectedBits, 0, (MAX_CONTACTS + 7) / 8);
}
void toggleSelected() {
if (_filteredCount == 0 || _scrollPos >= _filteredCount) return;
int rawIdx = _filteredIdx[_scrollPos];
setSelectedRaw(rawIdx, !isSelectedRaw(rawIdx));
}
void selectAll() {
for (int i = 0; i < _filteredCount; i++) {
setSelectedRaw(_filteredIdx[i], true);
}
}
void deselectAll() {
memset(_selectedBits, 0, (MAX_CONTACTS + 7) / 8);
}
int getSelectedCount() const {
int count = 0;
for (int i = 0; i < _filteredCount; i++) {
if (isSelectedRaw(_filteredIdx[i])) count++;
}
return count;
}
// Fill outBuf with raw contact table indices of selected contacts
int getSelectedRawIndices(uint16_t* outBuf, int maxOut) const {
int count = 0;
for (int i = 0; i < _filteredCount && count < maxOut; i++) {
if (isSelectedRaw(_filteredIdx[i])) {
outBuf[count++] = _filteredIdx[i];
}
}
return count;
}
// Tap-to-select: given virtual Y, select contact row.
// Returns: 0=miss, 1=moved, 2=tapped current row.
int selectRowAtVY(int vy) {
if (_filteredCount == 0) return 0;
const int headerH = 14, footerH = 14, lineH = 9;
const int headerH = 14, footerH = 14, lineH = the_mesh.getNodePrefs()->smallLineH();
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = headerH;
#else
const int bodyTop = headerH + 5;
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
@@ -213,7 +289,12 @@ public:
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter));
if (_selectMode) {
int selCount = getSelectedCount();
snprintf(tmp, sizeof(tmp), "%d Selected [%s]", selCount, filterLabel(_filter));
} else {
snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter));
}
display.print(tmp);
// Count on right: All → total/max, filtered → matched/total
@@ -229,8 +310,8 @@ public:
display.drawRect(0, 11, display.width(), 1);
// === Body - contact rows ===
display.setTextSize(0); // tiny font for compact rows
int lineHeight = 9; // 8px font + 1px gap
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // tiny font for compact rows
int lineHeight = the_mesh.getNodePrefs()->smallLineH(); // 8px font + 1px gap
int headerHeight = 14;
int footerHeight = 14;
int maxY = display.height() - footerHeight;
@@ -262,6 +343,7 @@ public:
if (!the_mesh.getContactByIdx(_filteredIdx[i], contact)) continue;
bool selected = (i == _scrollPos);
bool sel = _selectMode && isSelectedRaw(_filteredIdx[i]);
// Highlight: fill LIGHT rect first, then draw DARK text on top
if (selected) {
@@ -269,7 +351,7 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + 5, display.width(), lineHeight);
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -279,9 +361,13 @@ public:
// Set cursor AFTER fillRect so text draws on top of highlight
display.setCursor(0, y);
// Prefix: "> " for selected, type char + space for others
// Prefix: select mode uses * for selected, normal uses > for cursor
char prefix[4];
if (selected) {
if (_selectMode) {
snprintf(prefix, sizeof(prefix), "%c%c",
sel ? '*' : (selected ? '>' : ' '),
typeChar(contact.type));
} else if (selected) {
snprintf(prefix, sizeof(prefix), ">%c", typeChar(contact.type));
} else {
snprintf(prefix, sizeof(prefix), " %c", typeChar(contact.type));
@@ -294,18 +380,32 @@ public:
// Reserve space for hops + age on right side
char hopStr[6];
if (contact.out_path_len == 0xFF || contact.out_path_len == 0) {
strcpy(hopStr, "D"); // direct
if (contact.out_path_len == 0xFF) {
strcpy(hopStr, "?"); // unknown path
} else if (contact.out_path_len == 0) {
bool customDirect = (contact.flags & CONTACT_FLAG_CUSTOM_PATH) != 0;
strcpy(hopStr, customDirect ? "D*" : "D");
} else {
snprintf(hopStr, sizeof(hopStr), "%d", contact.out_path_len);
int hops = contact.out_path_len & 0x3F; // lower 6 bits = hop count
bool customPath = (contact.flags & CONTACT_FLAG_CUSTOM_PATH) != 0;
if (customPath) {
snprintf(hopStr, sizeof(hopStr), "%d*", hops); // asterisk = custom/locked path
} else {
snprintf(hopStr, sizeof(hopStr), "%d", hops);
}
}
char ageStr[6];
formatAge(ageStr, sizeof(ageStr), now, contact.last_advert_timestamp);
// Build right-side string: "hops age"
char rightStr[14];
snprintf(rightStr, sizeof(rightStr), "%sh %s", hopStr, ageStr);
// Build right-side string: "*N hops age" if unread, else "hops age"
int dmCount = (_dmUnread && _filteredIdx[i] < MAX_CONTACTS) ? _dmUnread[_filteredIdx[i]] : 0;
char rightStr[20];
if (dmCount > 0) {
snprintf(rightStr, sizeof(rightStr), "*%d %sh %s", dmCount, hopStr, ageStr);
} else {
snprintf(rightStr, sizeof(rightStr), "%sh %s", hopStr, ageStr);
}
int rightWidth = display.getTextWidth(rightStr) + 2;
// Name region: after prefix + small gap, before right info
@@ -332,19 +432,30 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setCursor(0, footerY);
display.print("Swipe:Filter");
const char* right = "Hold:DM/Admin";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
if (_selectMode) {
display.print("Swipe:All/Clr");
const char* right = "Tap:Tog Hold:Exit";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
} else {
display.print("Swipe:Filter");
const char* right = "Hold:DM/Admin";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
#else
// Left: Q:Bk
display.setCursor(0, footerY);
display.print("Q:Bk A/D:Filter");
// Right: Tap/Ent:Select
const char* right = "Tap/Ent:Select";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
if (_selectMode) {
display.print("A:All D:Clr");
const char* right = "X:Exp F:Fav Q:Done";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
} else {
display.print("Q:Bk A/D:Filter");
const char* right = "P:Path Ent:Sel";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
#endif
return 5000; // e-ink: next render after 5s
@@ -367,6 +478,29 @@ public:
}
}
// --- Select mode key handling ---
if (_selectMode) {
// Enter/tap: toggle selection on current contact
if (c == 13 || c == KEY_ENTER) {
toggleSelected();
return true;
}
// A: select all in current filter
if (c == 'a' || c == 'A') {
selectAll();
return true;
}
// D: deselect all
if (c == 'd' || c == 'D') {
deselectAll();
return true;
}
// Q, X, F, Backspace — handled by main.cpp (needs mesh/SD access)
return false;
}
// --- Normal mode key handling ---
// A - previous filter
if (c == 'a' || c == 'A') {
_filter = (FilterMode)(((int)_filter + FILTER_COUNT - 1) % FILTER_COUNT);
@@ -49,11 +49,11 @@ public:
int selectRowAtVY(int vy) {
int count = the_mesh.getDiscoveredCount();
if (count == 0) return 0;
const int headerH = 14, footerH = 14, lineH = 9;
const int headerH = 14, footerH = 14, lineH = the_mesh.getNodePrefs()->smallLineH();
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = headerH;
#else
const int bodyTop = headerH + 5;
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
@@ -91,8 +91,8 @@ public:
display.drawRect(0, 11, display.width(), 1);
// === Body — discovered node rows ===
display.setTextSize(0); // tiny font for compact rows
int lineHeight = 9;
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // tiny font for compact rows
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
int headerHeight = 14;
int footerHeight = 14;
int maxY = display.height() - footerHeight;
@@ -129,7 +129,7 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + 5, display.width(), lineHeight);
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -68,11 +68,11 @@ public:
// Returns: 0=miss, 1=moved, 2=tapped current row.
int selectRowAtVY(int vy) {
if (_count == 0) return 0;
const int headerH = 14, footerH = 14, lineH = 9;
const int headerH = 14, footerH = 14, lineH = the_mesh.getNodePrefs()->smallLineH();
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = headerH;
#else
const int bodyTop = headerH + 5;
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
@@ -117,8 +117,8 @@ public:
display.drawRect(0, 11, display.width(), 1);
// === Body — node rows ===
display.setTextSize(0);
int lineHeight = 9;
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
int headerHeight = 14;
int footerHeight = 14;
int maxY = display.height() - footerHeight;
@@ -147,7 +147,7 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + 5, display.width(), lineHeight);
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
#endif
display.setColor(DisplayDriver::DARK);
} else {
+41 -24
View File
@@ -5,6 +5,7 @@
#include <SD.h>
#include <vector>
#include "Utf8CP437.h"
#include "../NodePrefs.h"
// Forward declarations
class UITask;
@@ -52,9 +53,11 @@ public:
private:
UITask* _task;
NodePrefs* _prefs;
Mode _mode;
bool _sdReady;
bool _initialized;
uint8_t _lastFontPref;
DisplayDriver* _display;
// Display layout (calculated once from display metrics)
@@ -518,8 +521,8 @@ private:
display.drawRect(0, 11, display.width(), 1);
// File list with "+ New Note" at index 0
display.setTextSize(0);
int listLineH = 9; // Match contacts/discovery for consistent selection highlight
display.setTextSize(_prefs->smallTextSize());
int listLineH = _prefs->smallLineH();
int startY = 14;
int totalItems = 1 + (int)_fileList.size();
int maxVisible = (display.height() - startY - _footerHeight) / listLineH;
@@ -539,27 +542,21 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), listLineH);
#else
display.fillRect(0, y + 5, display.width(), listLineH);
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
if (i == 0) {
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
display.print(selected ? "> + New Note" : " + New Note");
display.drawTextEllipsized(0, y, display.width() - 4,
selected ? "> + New Note" : " + New Note");
} else {
String line = selected ? "> " : " ";
String name = _fileList[i - 1];
int maxLen = _charsPerLine - 4;
if ((int)name.length() > maxLen) {
name = name.substring(0, maxLen - 3) + "...";
}
line += name;
display.print(line.c_str());
line += _fileList[i - 1];
display.drawTextEllipsized(0, y, display.width() - 4, line.c_str());
}
y += listLineH;
}
@@ -605,7 +602,7 @@ private:
}
// Render current page using tiny font
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
int pageStart = _pageOffsets[_currentPage];
@@ -722,7 +719,7 @@ private:
int textAreaTop = 14;
int textAreaBottom = display.height() - 16;
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
// Find cursor line
int cursorLine = lineForPos(_cursorPos);
@@ -771,7 +768,7 @@ private:
// If buffer is empty, show cursor at top
if (_bufLen == 0) {
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, textAreaTop);
display.print("|");
@@ -829,7 +826,7 @@ private:
display.setCursor(0, 20);
display.setColor(DisplayDriver::LIGHT);
display.print("From: ");
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
String origDisplay = _renameOriginal;
if (origDisplay.length() > 30) origDisplay = origDisplay.substring(0, 27) + "...";
display.print(origDisplay.c_str());
@@ -840,7 +837,7 @@ private:
display.setColor(DisplayDriver::LIGHT);
display.print("To: ");
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::GREEN);
char displayName[NOTES_RENAME_MAX + 2];
snprintf(displayName, sizeof(displayName), "%s|", _renameBuf);
@@ -880,7 +877,7 @@ private:
display.setCursor(0, 25);
display.print("File:");
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setCursor(0, 38);
String nameDisplay = _deleteTarget;
if (nameDisplay.length() > 35) nameDisplay = nameDisplay.substring(0, 32) + "...";
@@ -1096,9 +1093,9 @@ private:
}
public:
NotesScreen(UITask* task)
: _task(task), _mode(FILE_LIST),
_sdReady(false), _initialized(false), _display(nullptr),
NotesScreen(UITask* task, NodePrefs* prefs = nullptr)
: _task(task), _prefs(prefs), _mode(FILE_LIST),
_sdReady(false), _initialized(false), _lastFontPref(0), _display(nullptr),
_charsPerLine(38), _linesPerPage(22), _lineHeight(5), _footerHeight(14),
_editCharsPerLine(20), _editLineHeight(12), _editMaxLines(8),
_selectedFile(0), _buf(nullptr), _bufLen(0), _cursorPos(0),
@@ -1133,15 +1130,31 @@ public:
// ---- Layout Init ----
void initLayout(DisplayDriver& display) {
// Re-init if font preference changed since last layout
uint8_t curFont = _prefs ? _prefs->large_font : 0;
if (_initialized && curFont != _lastFontPref) {
_initialized = false;
Serial.println("Notes: font changed, recalculating layout");
}
if (_initialized) return;
_lastFontPref = curFont;
_display = &display;
// Tiny font metrics (for read mode)
display.setTextSize(0);
// Font metrics (for read mode)
display.setTextSize(_prefs->smallTextSize());
uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM");
if (tenCharsW > 0) {
_charsPerLine = (display.width() * 10) / tenCharsW;
}
// Proportional font: use average-width measurement instead of M-width
if (_prefs && _prefs->large_font) {
const char* sample = "the quick brown fox jumps over lazy dog";
uint16_t sampleW = display.getTextWidth(sample);
int sampleLen = strlen(sample);
if (sampleW > 0 && sampleLen > 0) {
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
}
}
if (_charsPerLine < 15) _charsPerLine = 15;
if (_charsPerLine > 60) _charsPerLine = 60;
@@ -1151,6 +1164,10 @@ public:
} else {
_lineHeight = 5;
}
// Large font: formula above assumes built-in 6x8 ratio — too small for 9pt
if (_prefs && _prefs->large_font) {
_lineHeight = _prefs->smallLineH();
}
_footerHeight = 14;
int textAreaHeight = display.height() - _footerHeight;
@@ -0,0 +1,805 @@
#pragma once
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <MeshCore.h>
#include <Packet.h>
// Forward declarations
class UITask;
class MyMesh;
extern MyMesh the_mesh;
class PathEditorScreen : public UIScreen {
public:
enum EditorState {
STATE_MAIN,
STATE_PICK_HOP
};
// Main-state menu items (dynamic, built each render)
enum MenuItem {
MENU_MODE = 0, // "Mode: 1B/hop" or "Mode: 2B/hop"
// After mode: hop lines (MENU_HOP_BASE + i)
// Then: action items
MENU_HOP_BASE = 1,
// Dynamic items after hops:
MENU_ADD_HOP = 100,
MENU_SET_DIRECT,
MENU_REMOVE_LAST,
MENU_CLEAR_PATH,
MENU_SAVE_EXIT
};
private:
UITask* _task;
mesh::RTCClock* _rtc;
int _contactIdx; // Index into contact table
char _contactName[32]; // Contact name for header
EditorState _state;
int _menuSel; // Selected menu item index (0-based in visible list)
int _menuCount; // Total visible menu items
// Path being edited (working copy)
uint8_t _pathBuf[MAX_PATH_SIZE];
uint8_t _pathLen; // Encoded: bits[7:6]=mode, bits[5:0]=hops
int _hopCount; // Decoded hop count
int _bytesPerHop; // 1 or 2
// Repeater picker state
static const int MAX_REPEATERS = 200;
uint16_t* _repIdx; // Indices into contact table (PSRAM)
int _repCount; // Number of repeaters found
int _repSel; // Selected repeater in picker
int _repScroll; // Scroll offset in picker
bool _dirty; // Path has been modified
bool _wantExit; // Set by Save & Exit — caller should navigate back
bool _directLocked; // True = path is explicitly set to direct (0 hops, locked)
// --- helpers ---
void decodePath() {
_hopCount = _pathLen & 0x3F;
uint8_t mode = (_pathLen >> 6) & 0x03;
_bytesPerHop = mode + 1;
}
uint8_t encodePath() const {
uint8_t mode = (_bytesPerHop - 1) & 0x03;
return (mode << 6) | (_hopCount & 0x3F);
}
void buildRepeaterList() {
_repCount = 0;
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo c;
for (uint32_t i = 0; i < numContacts && _repCount < MAX_REPEATERS; i++) {
if (the_mesh.getContactByIdx(i, c)) {
if (c.type == ADV_TYPE_REPEATER) {
_repIdx[_repCount++] = (uint16_t)i;
}
}
}
}
// Look up a contact name by matching pub_key prefix bytes
bool findNameForHop(int hopIndex, char* name, size_t nameLen) const {
if (hopIndex < 0 || hopIndex >= _hopCount) return false;
int offset = hopIndex * _bytesPerHop;
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo c;
for (uint32_t i = 0; i < numContacts; i++) {
if (the_mesh.getContactByIdx(i, c)) {
bool match = true;
for (int b = 0; b < _bytesPerHop; b++) {
if (c.id.pub_key[b] != _pathBuf[offset + b]) {
match = false;
break;
}
}
if (match) {
strncpy(name, c.name, nameLen);
name[nameLen - 1] = '\0';
return true;
}
}
}
return false;
}
// Build the visible menu items list and return count
// Menu layout:
// 0: Mode selector
// 1..hopCount: each hop
// hopCount+1: Add hop
// hopCount+2: Remove last (only if hops > 0)
// hopCount+2 or +3: Clear path (only if custom path flag set or hops > 0)
// last: Save & Exit
int buildMenuCount() const {
int count = 1; // Mode selector
count += _hopCount; // One per hop
if (_hopCount < 8) count++; // Add hop (max 8 hops)
count++; // Set Direct (always visible)
if (_hopCount > 0) count++; // Remove last
if (_hopCount > 0 || _directLocked || isCustomPathSet()) count++; // Clear path
count++; // Save & Exit
return count;
}
// Map a menu index to a MenuItem enum
MenuItem menuItemAt(int idx) const {
if (idx == 0) return MENU_MODE;
int pos = 1;
// Hop lines
for (int h = 0; h < _hopCount; h++) {
if (idx == pos) return (MenuItem)(MENU_HOP_BASE + h);
pos++;
}
// Add hop
if (_hopCount < 8) {
if (idx == pos) return MENU_ADD_HOP;
pos++;
}
// Set Direct
if (idx == pos) return MENU_SET_DIRECT;
pos++;
// Remove last
if (_hopCount > 0) {
if (idx == pos) return MENU_REMOVE_LAST;
pos++;
}
// Clear path
if (_hopCount > 0 || _directLocked || isCustomPathSet()) {
if (idx == pos) return MENU_CLEAR_PATH;
pos++;
}
// Save & Exit
return MENU_SAVE_EXIT;
}
bool isCustomPathSet() const {
ContactInfo c;
if (!the_mesh.getContactByIdx(_contactIdx, c)) return false;
return (c.flags & CONTACT_FLAG_CUSTOM_PATH) != 0;
}
public:
PathEditorScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _contactIdx(-1), _state(STATE_MAIN),
_menuSel(0), _menuCount(1), _pathLen(0), _hopCount(0),
_bytesPerHop(1), _repCount(0), _repSel(0), _repScroll(0),
_dirty(false), _wantExit(false), _directLocked(false) {
memset(_contactName, 0, sizeof(_contactName));
memset(_pathBuf, 0, sizeof(_pathBuf));
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
_repIdx = (uint16_t*)ps_calloc(MAX_REPEATERS, sizeof(uint16_t));
#else
_repIdx = new uint16_t[MAX_REPEATERS]();
#endif
}
void openForContact(int contactIdx) {
_contactIdx = contactIdx;
_state = STATE_MAIN;
_menuSel = 0;
_repSel = 0;
_repScroll = 0;
_dirty = false;
_wantExit = false;
_directLocked = false;
// Load contact info
ContactInfo c;
if (the_mesh.getContactByIdx(contactIdx, c)) {
strncpy(_contactName, c.name, sizeof(_contactName) - 1);
_contactName[sizeof(_contactName) - 1] = '\0';
// Copy current path
if (c.out_path_len != OUT_PATH_UNKNOWN) {
_pathLen = c.out_path_len;
decodePath();
int byteLen = _hopCount * _bytesPerHop;
if (byteLen > MAX_PATH_SIZE) byteLen = MAX_PATH_SIZE;
memcpy(_pathBuf, c.out_path, byteLen);
// Detect existing direct-locked path
if (_hopCount == 0 && (c.flags & CONTACT_FLAG_CUSTOM_PATH)) {
_directLocked = true;
}
} else {
_pathLen = 0;
_hopCount = 0;
_bytesPerHop = 1;
memset(_pathBuf, 0, sizeof(_pathBuf));
}
} else {
strcpy(_contactName, "Unknown");
_pathLen = 0;
_hopCount = 0;
_bytesPerHop = 1;
}
_menuCount = buildMenuCount();
}
int render(DisplayDriver& display) override {
if (_state == STATE_PICK_HOP) {
return renderPicker(display);
}
return renderMain(display);
}
int renderMain(DisplayDriver& display) {
char tmp[64];
// === Header ===
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
snprintf(tmp, sizeof(tmp), "Path: %s", _contactName);
// Truncate if too long
if (display.getTextWidth(tmp) > display.width() - 4) {
snprintf(tmp, sizeof(tmp), "Path: %.12s..", _contactName);
}
display.print(tmp);
// Show lock icon or dirty indicator on right
if (_dirty) {
const char* mod = "[*]";
display.setCursor(display.width() - display.getTextWidth(mod) - 2, 0);
display.print(mod);
} else if (isCustomPathSet()) {
const char* lock = "[L]";
display.setCursor(display.width() - display.getTextWidth(lock) - 2, 0);
display.print(lock);
}
display.drawRect(0, 11, display.width(), 1);
// === Body ===
display.setTextSize(0);
int lineH = 9;
int headerH = 14;
int footerH = 14;
int maxY = display.height() - footerH;
int y = headerH;
_menuCount = buildMenuCount();
// Center visible window around selected item
int maxVisible = (maxY - headerH) / lineH;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_menuSel - maxVisible / 2, _menuCount - maxVisible));
if (startIdx < 0) startIdx = 0;
int endIdx = min(_menuCount, startIdx + maxVisible);
for (int i = startIdx; i < endIdx && y + lineH <= maxY; i++) {
bool selected = (i == _menuSel);
MenuItem item = menuItemAt(i);
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineH);
#else
display.fillRect(0, y + 5, display.width(), lineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(2, y);
char prefix = selected ? '>' : ' ';
switch (item) {
case MENU_MODE:
if (_directLocked) {
snprintf(tmp, sizeof(tmp), "%c Mode: DIRECT", prefix);
} else {
snprintf(tmp, sizeof(tmp), "%c Mode: %dB/hop", prefix, _bytesPerHop);
}
display.print(tmp);
// Show hint on right
if (!_directLocked) {
const char* hint = "(A/D)";
display.setCursor(display.width() - display.getTextWidth(hint) - 4, y);
display.print(hint);
}
break;
case MENU_ADD_HOP:
snprintf(tmp, sizeof(tmp), "%c + Add hop...", prefix);
display.print(tmp);
break;
case MENU_SET_DIRECT:
if (_directLocked) {
snprintf(tmp, sizeof(tmp), "%c * Direct (set)", prefix);
} else {
snprintf(tmp, sizeof(tmp), "%c * Set Direct", prefix);
}
display.print(tmp);
break;
case MENU_REMOVE_LAST:
snprintf(tmp, sizeof(tmp), "%c - Remove last hop", prefix);
display.print(tmp);
break;
case MENU_CLEAR_PATH:
snprintf(tmp, sizeof(tmp), "%c Clear custom path", prefix);
display.print(tmp);
break;
case MENU_SAVE_EXIT:
snprintf(tmp, sizeof(tmp), "%c Save & Exit", prefix);
display.print(tmp);
break;
default:
// Hop line: MENU_HOP_BASE + hopIndex
if (item >= MENU_HOP_BASE && item < MENU_HOP_BASE + 64) {
int hopIdx = item - MENU_HOP_BASE;
char hopName[24];
int offset = hopIdx * _bytesPerHop;
if (findNameForHop(hopIdx, hopName, sizeof(hopName))) {
if (_bytesPerHop == 1) {
snprintf(tmp, sizeof(tmp), "%c %d: %s (%02X)", prefix, hopIdx + 1,
hopName, _pathBuf[offset]);
} else {
snprintf(tmp, sizeof(tmp), "%c %d: %s (%02X%02X)", prefix, hopIdx + 1,
hopName, _pathBuf[offset], _pathBuf[offset + 1]);
}
} else {
if (_bytesPerHop == 1) {
snprintf(tmp, sizeof(tmp), "%c %d: ??? (%02X)", prefix, hopIdx + 1,
_pathBuf[offset]);
} else {
snprintf(tmp, sizeof(tmp), "%c %d: ??? (%02X%02X)", prefix, hopIdx + 1,
_pathBuf[offset], _pathBuf[offset + 1]);
}
}
display.drawTextEllipsized(2, y, display.width() - 4, tmp);
}
break;
}
y += lineH;
}
// === Footer ===
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setCursor(0, footerY);
display.print("Swipe:Nav");
const char* right = "Hold:Select";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#else
display.setCursor(0, footerY);
display.print("Q:Bk W/S:Nav");
const char* right = "Enter:Sel";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#endif
return 5000;
}
int renderPicker(DisplayDriver& display) {
char tmp[64];
// === Header ===
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
snprintf(tmp, sizeof(tmp), "Select Repeater (%d)", _repCount);
display.print(tmp);
display.drawRect(0, 11, display.width(), 1);
// === Body ===
display.setTextSize(0);
int lineH = 9;
int headerH = 14;
int footerH = 14;
int maxY = display.height() - footerH;
int y = headerH;
if (_repCount == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, y);
display.print("No repeaters in contacts");
display.setCursor(0, y + lineH);
display.print("Add repeaters first");
} else {
int maxVisible = (maxY - headerH) / lineH;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_repSel - maxVisible / 2, _repCount - maxVisible));
if (startIdx < 0) startIdx = 0;
int endIdx = min(_repCount, startIdx + maxVisible);
for (int i = startIdx; i < endIdx && y + lineH <= maxY; i++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(_repIdx[i], c)) continue;
bool selected = (i == _repSel);
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineH);
#else
display.fillRect(0, y + 5, display.width(), lineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(2, y);
char prefix = selected ? '>' : ' ';
if (_bytesPerHop == 1) {
snprintf(tmp, sizeof(tmp), "%c %s (%02X)", prefix, c.name, c.id.pub_key[0]);
} else {
snprintf(tmp, sizeof(tmp), "%c %s (%02X%02X)", prefix, c.name,
c.id.pub_key[0], c.id.pub_key[1]);
}
display.drawTextEllipsized(2, y, display.width() - 4, tmp);
y += lineH;
}
}
// === Footer ===
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setCursor(0, footerY);
display.print("Swipe:Scroll");
const char* right = "Hold:Add Back:Cancel";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#else
display.setCursor(0, footerY);
display.print("Q:Cancel W/S:Scroll");
const char* right = "Enter:Add";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#endif
return 5000;
}
bool handleInput(char c) override {
if (_state == STATE_PICK_HOP) {
return handlePickerInput(c);
}
return handleMainInput(c);
}
bool handleMainInput(char c) {
// W - scroll up
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_menuSel > 0) {
_menuSel--;
return true;
}
return false;
}
// S - scroll down
if (c == 's' || c == 'S' || c == 0xF1) {
if (_menuSel < _menuCount - 1) {
_menuSel++;
return true;
}
return false;
}
// A/D — toggle mode (only when Mode item is selected and not direct-locked)
if (c == 'a' || c == 'A' || c == 'd' || c == 'D') {
MenuItem item = menuItemAt(_menuSel);
if (item == MENU_MODE && !_directLocked) {
// Toggle between 1-byte and 2-byte
if (_bytesPerHop == 1) {
switchMode(2);
} else {
switchMode(1);
}
_dirty = true;
_menuCount = buildMenuCount();
return true;
}
return false;
}
// Enter - select
if (c == 13 || c == KEY_ENTER || c == '\r') {
MenuItem item = menuItemAt(_menuSel);
switch (item) {
case MENU_MODE:
// Toggle mode on Enter too (no-op if direct locked)
if (!_directLocked) {
if (_bytesPerHop == 1) {
switchMode(2);
} else {
switchMode(1);
}
_dirty = true;
_menuCount = buildMenuCount();
}
return true;
case MENU_ADD_HOP:
// Enter picker mode — adding a hop clears direct lock
_directLocked = false;
buildRepeaterList();
_repSel = 0;
_repScroll = 0;
_state = STATE_PICK_HOP;
return true;
case MENU_SET_DIRECT:
// Set path to direct (0 hops, locked)
_hopCount = 0;
_pathLen = 0;
memset(_pathBuf, 0, sizeof(_pathBuf));
_directLocked = true;
_dirty = true;
_menuCount = buildMenuCount();
return true;
case MENU_REMOVE_LAST:
if (_hopCount > 0) {
_hopCount--;
_pathLen = encodePath();
_dirty = true;
_menuCount = buildMenuCount();
// Clamp selection
if (_menuSel >= _menuCount) _menuSel = _menuCount - 1;
}
return true;
case MENU_CLEAR_PATH:
_hopCount = 0;
_pathLen = 0;
_directLocked = false;
memset(_pathBuf, 0, sizeof(_pathBuf));
_dirty = true;
_menuCount = buildMenuCount();
_menuSel = 0;
return true;
case MENU_SAVE_EXIT:
savePath();
_wantExit = true; // Signal to main.cpp to navigate back to contacts
return true;
default:
// Hop line — no action (could add remove-specific-hop later)
break;
}
return true;
}
// Q - back (discard changes or prompt?)
// For simplicity, just go back without saving
if (c == 'q' || c == 'Q') {
// Return to contacts screen without saving
// The UITask will handle this via the key falling through
return false; // Let UITask handle Q as back
}
return false;
}
bool handlePickerInput(char c) {
// W - scroll up
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_repSel > 0) {
_repSel--;
return true;
}
return false;
}
// S - scroll down
if (c == 's' || c == 'S' || c == 0xF1) {
if (_repSel < _repCount - 1) {
_repSel++;
return true;
}
return false;
}
// Enter - add selected repeater as hop
if (c == 13 || c == KEY_ENTER || c == '\r') {
if (_repCount > 0 && _repSel >= 0 && _repSel < _repCount) {
addHopFromContact(_repIdx[_repSel]);
}
_state = STATE_MAIN;
_menuCount = buildMenuCount();
return true;
}
// Q - cancel picker, return to main
if (c == 'q' || c == 'Q') {
_state = STATE_MAIN;
return true;
}
return false;
}
// Tap-to-select for T5S3 touch
int selectRowAtVY(int vy) {
if (_state == STATE_PICK_HOP) {
return selectPickerRowAtVY(vy);
}
return selectMainRowAtVY(vy);
}
int selectMainRowAtVY(int vy) {
if (_menuCount == 0) return 0;
const int headerH = 14, footerH = 14, lineH = 9;
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = headerH;
#else
const int bodyTop = headerH + 5;
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
int maxVisible = (128 - headerH - footerH) / lineH;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_menuSel - maxVisible / 2, _menuCount - maxVisible));
if (startIdx < 0) startIdx = 0;
int tappedRow = startIdx + (vy - bodyTop) / lineH;
if (tappedRow < 0 || tappedRow >= _menuCount) return 0;
if (tappedRow == _menuSel) return 2;
_menuSel = tappedRow;
return 1;
}
int selectPickerRowAtVY(int vy) {
if (_repCount == 0) return 0;
const int headerH = 14, footerH = 14, lineH = 9;
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = headerH;
#else
const int bodyTop = headerH + 5;
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
int maxVisible = (128 - headerH - footerH) / lineH;
if (maxVisible < 3) maxVisible = 3;
int startIdx = max(0, min(_repSel - maxVisible / 2, _repCount - maxVisible));
if (startIdx < 0) startIdx = 0;
int tappedRow = startIdx + (vy - bodyTop) / lineH;
if (tappedRow < 0 || tappedRow >= _repCount) return 0;
if (tappedRow == _repSel) return 2;
_repSel = tappedRow;
return 1;
}
EditorState getState() const { return _state; }
bool isDirty() const { return _dirty; }
bool wantsExit() const { return _wantExit; }
private:
void switchMode(int newBytesPerHop) {
if (newBytesPerHop == _bytesPerHop) return;
if (_hopCount > 0) {
// Rebuild path buffer for new mode
// We need the full pub_keys to re-extract the right prefix bytes
uint8_t newBuf[MAX_PATH_SIZE];
memset(newBuf, 0, sizeof(newBuf));
int newHopCount = 0;
for (int h = 0; h < _hopCount && newHopCount < 8; h++) {
int oldOffset = h * _bytesPerHop;
// Try to find the contact that matches this hop
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo c;
bool found = false;
for (uint32_t i = 0; i < numContacts; i++) {
if (the_mesh.getContactByIdx(i, c)) {
bool match = true;
for (int b = 0; b < _bytesPerHop; b++) {
if (c.id.pub_key[b] != _pathBuf[oldOffset + b]) {
match = false;
break;
}
}
if (match) {
// Found the contact — copy new prefix size
int newOffset = newHopCount * newBytesPerHop;
for (int b = 0; b < newBytesPerHop; b++) {
newBuf[newOffset + b] = c.id.pub_key[b];
}
newHopCount++;
found = true;
break;
}
}
}
if (!found) {
// Contact not found — copy what we can
int newOffset = newHopCount * newBytesPerHop;
int oldOff = h * _bytesPerHop;
for (int b = 0; b < newBytesPerHop; b++) {
if (b < _bytesPerHop) {
newBuf[newOffset + b] = _pathBuf[oldOff + b];
} else {
newBuf[newOffset + b] = 0x00; // pad with zero
}
}
newHopCount++;
}
}
_hopCount = newHopCount;
memcpy(_pathBuf, newBuf, sizeof(newBuf));
}
_bytesPerHop = newBytesPerHop;
_pathLen = encodePath();
}
void addHopFromContact(uint16_t contactTableIdx) {
if (_hopCount >= 8) return;
ContactInfo c;
if (!the_mesh.getContactByIdx(contactTableIdx, c)) return;
int offset = _hopCount * _bytesPerHop;
if (offset + _bytesPerHop > MAX_PATH_SIZE) return;
for (int b = 0; b < _bytesPerHop; b++) {
_pathBuf[offset + b] = c.id.pub_key[b];
}
_hopCount++;
_pathLen = encodePath();
_dirty = true;
}
void savePath() {
if (_contactIdx < 0) return;
if (_directLocked) {
// Set as direct (0 hops) with lock — prevents flood routing
the_mesh.setCustomPath(_contactIdx, _pathBuf, 0, true);
Serial.printf("PathEditor: set DIRECT path for contact %d (%s)\n",
_contactIdx, _contactName);
} else if (_hopCount > 0) {
// Set custom path with lock
the_mesh.setCustomPath(_contactIdx, _pathBuf, encodePath(), true);
Serial.printf("PathEditor: saved %d-hop %dB/hop path for contact %d (%s)\n",
_hopCount, _bytesPerHop, _contactIdx, _contactName);
} else {
// Clear custom path — revert to auto-discovery
the_mesh.clearCustomPath(_contactIdx);
Serial.printf("PathEditor: cleared custom path for contact %d (%s)\n",
_contactIdx, _contactName);
}
// Trigger contact save to SD
the_mesh.saveContacts();
_dirty = false;
}
};
@@ -475,6 +475,7 @@ public:
int getContactIdx() const { return _contactIdx; }
AdminState getState() const { return _state; }
uint8_t getPermissions() const { return _permissions; }
void onLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
_waitingForLogin = false;
@@ -561,7 +562,9 @@ public:
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
snprintf(tmp, sizeof(tmp), "Admin: %.16s", _repeaterName);
const char* hdrPrefix = (_state == STATE_PASSWORD_ENTRY || _state == STATE_LOGGING_IN)
? "Login" : "Admin";
snprintf(tmp, sizeof(tmp), "%s: %.16s", hdrPrefix, _repeaterName);
display.print(tmp);
if (_state >= STATE_CATEGORY_MENU && _state <= STATE_RESPONSE_VIEW) {
@@ -774,8 +777,8 @@ private:
// =====================================================================
void renderCategoryMenu(DisplayDriver& display, int y, int bodyHeight) {
display.setTextSize(0);
int lineHeight = 9;
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
// Clock drift info line
if (_serverTime > 0) {
@@ -859,8 +862,8 @@ private:
// =====================================================================
void renderCommandMenu(DisplayDriver& display, int y, int bodyHeight) {
display.setTextSize(0);
int lineHeight = 9;
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
const AdminCategoryDef& cat = CATEGORIES[_catSel];
// Category title
@@ -1022,7 +1025,7 @@ private:
if (_pendingCmd) display.print(_pendingCmd->label);
y += 14;
display.setTextSize(0);
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
display.setCursor(0, y);
// Show the param value if one was collected
@@ -1030,7 +1033,7 @@ private:
char preview[80];
snprintf(preview, sizeof(preview), "Value: %s", _paramBuf);
display.print(preview);
y += 10;
y += the_mesh.getNodePrefs()->smallLineH() + 1;
display.setCursor(0, y);
}
@@ -1068,8 +1071,8 @@ private:
// =====================================================================
void renderResponse(DisplayDriver& display, int y, int bodyHeight) {
display.setTextSize(0);
int lineHeight = 9;
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
display.setColor((_state == STATE_ERROR) ? DisplayDriver::YELLOW : DisplayDriver::LIGHT);
@@ -1163,7 +1166,7 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + 5, display.width(), lineHeight);
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
#endif
display.setColor(DisplayDriver::DARK);
} else if (warn) {
+24 -21
View File
@@ -36,6 +36,7 @@
#include "ModemManager.h"
#include "SMSStore.h"
#include "SMSContacts.h"
#include "../NodePrefs.h"
// Limits
#define SMS_INBOX_PAGE_SIZE 4
@@ -51,6 +52,7 @@ public:
private:
UITask* _task;
NodePrefs* _prefs;
SubView _view;
// App menu state
@@ -117,8 +119,8 @@ private:
}
public:
SMSScreen(UITask* task)
: _task(task), _view(APP_MENU)
SMSScreen(UITask* task, NodePrefs* prefs = nullptr)
: _task(task), _prefs(prefs), _view(APP_MENU)
, _menuCursor(0)
, _convCount(0), _inboxCursor(0), _inboxScrollTop(0)
, _msgCount(0), _msgScrollPos(0)
@@ -276,7 +278,7 @@ public:
// Show modem state text if not ready
if (ms != ModemState::READY && ms != ModemState::SENDING_SMS) {
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::YELLOW);
const char* label = ModemManager::stateToString(ms);
uint16_t labelW = display.getTextWidth(label);
@@ -356,7 +358,7 @@ public:
// Modem status indicator
ModemState ms = modemManager.getState();
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setCursor(4, y + lineHeight + 8);
if (ms == ModemState::OFF || ms == ModemState::POWERING_ON ||
ms == ModemState::INITIALIZING) {
@@ -483,7 +485,7 @@ public:
bool isAction = (row == 4); // Bottom row has action buttons
if (isAction) {
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
if (col == 2 && _phoneInputPos > 0) {
display.setColor(DisplayDriver::GREEN); // CALL
} else if (col == 1) {
@@ -544,7 +546,7 @@ public:
display.drawRect(0, 11, display.width(), 1);
if (_convCount == 0) {
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 20);
display.print("No conversations");
@@ -560,8 +562,8 @@ public:
}
display.setTextSize(1);
} else {
display.setTextSize(0);
int lineHeight = 10;
display.setTextSize(_prefs->smallTextSize());
int lineHeight = _prefs->smallLineH() + 1;
int y = 14;
int visibleCount = (display.height() - 14 - 14) / (lineHeight * 2 + 2);
@@ -643,14 +645,14 @@ public:
display.drawRect(0, 11, display.width(), 1);
if (_msgCount == 0) {
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 25);
display.print("No messages");
display.setTextSize(1);
} else {
display.setTextSize(0);
int lineHeight = 10;
display.setTextSize(_prefs->smallTextSize());
int lineHeight = _prefs->smallLineH() + 1;
int headerHeight = 14;
int footerHeight = 14;
@@ -764,12 +766,13 @@ public:
// Message body
display.setCursor(0, 14);
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
uint16_t testWidth = display.getTextWidth("MMMMMMMMMM");
int charsPerLine = (testWidth > 0) ? (display.width() * 10) / testWidth : 20;
if (charsPerLine < 12) charsPerLine = 12;
int composeLH = _prefs->smallLineH() + 1;
int y = 14;
int x = 0;
char cs[2] = {0, 0};
@@ -780,7 +783,7 @@ public:
x++;
if (x >= charsPerLine) {
x = 0;
y += 10;
y += composeLH;
}
}
@@ -827,7 +830,7 @@ public:
int cnt = smsContacts.count();
if (cnt == 0) {
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 25);
display.print("No contacts saved");
@@ -837,8 +840,8 @@ public:
display.print("and press A to add");
display.setTextSize(1);
} else {
display.setTextSize(0);
int lineHeight = 10;
display.setTextSize(_prefs->smallTextSize());
int lineHeight = _prefs->smallLineH() + 1;
int y = 14;
int visibleCount = (display.height() - 14 - 14) / (lineHeight * 2 + 2);
@@ -900,7 +903,7 @@ public:
display.drawRect(0, 11, display.width(), 1);
// Phone number (read-only)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 16);
display.print("Phone: ");
@@ -956,7 +959,7 @@ public:
display.print(dispName);
// Phone number below name (smaller, dimmer)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(4, 36);
display.print(_callPhone);
@@ -1011,7 +1014,7 @@ public:
display.print(dispName);
// Phone number below name (smaller, dimmer)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(4, 36);
display.print(_callPhone);
@@ -1070,7 +1073,7 @@ public:
display.print(dispName);
// Phone number below name (smaller, dimmer)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(4, 36);
display.print(_callPhone);
@@ -1090,7 +1093,7 @@ public:
display.print(timeBuf);
// Volume (left-aligned)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
char volLabel[12];
snprintf(volLabel, sizeof(volLabel), "Vol: %d/5", _callVolume);
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@
#include <vector>
#include "Utf8CP437.h"
#include "EpubProcessor.h"
#include "../NodePrefs.h"
// Forward declarations
class UITask;
@@ -15,7 +16,7 @@ class UITask;
// ============================================================================
#define BOOKS_FOLDER "/books"
#define INDEX_FOLDER "/.indexes"
#define INDEX_VERSION 9 // v9: indexer buffer matches page buffer (fixes chunk boundary gaps)
#define INDEX_VERSION 12 // v12: indexer breaks page BEFORE overflowing line (matches renderer pre-check)
#define PREINDEX_PAGES 100
#define READER_MAX_FILES 50
#define READER_BUF_SIZE 4096
@@ -238,17 +239,25 @@ inline WrapResult findLineBreakPixel(const char* buffer, int bufLen, int lineSta
// ============================================================================
// Page Indexer (word-wrap aware, matches display rendering)
// When textAreaHeight and lineHeight are provided (both > 0), uses height-based
// pagination that accounts for blank lines getting 40% height (matching renderer).
// Otherwise falls back to simple line counting.
// ============================================================================
inline int indexPagesWordWrap(File& file, long startPos,
std::vector<long>& pagePositions,
int linesPerPage, int charsPerLine,
int maxPages) {
int maxPages,
int textAreaHeight = 0, int lineHeight = 0) {
const int BUF_SIZE = READER_BUF_SIZE; // Match page buffer to avoid chunk boundary wrap mismatches
char buffer[BUF_SIZE];
bool heightAware = (textAreaHeight > 0 && lineHeight > 0);
int blankLineH = heightAware ? max(2, lineHeight * 2 / 5) : 0;
file.seek(startPos);
int pagesAdded = 0;
int lineCount = 0;
int accHeight = 0;
int leftover = 0;
long chunkFileStart = startPos;
@@ -259,17 +268,42 @@ inline int indexPagesWordWrap(File& file, long startPos,
int pos = 0;
while (pos < bufLen) {
int lineStart = pos;
WrapResult wrap = findLineBreak(buffer, bufLen, pos, charsPerLine);
if (wrap.nextStart <= pos && wrap.lineEnd >= bufLen) break;
lineCount++;
// Blank line = newline at line start (no printable content before it)
bool isBlankLine = (wrap.lineEnd == lineStart);
bool pageBreak = false;
if (heightAware) {
int thisH = isBlankLine ? blankLineH : lineHeight;
// Check BEFORE adding: does this line fit on the current page?
// The renderer checks y <= maxY before rendering each line,
// so we must break the page BEFORE a line that won't fit.
if (accHeight > 0 && accHeight + thisH > textAreaHeight) {
// This line doesn't fit — start new page at this line's position
long pageFilePos = chunkFileStart + lineStart;
pagePositions.push_back(pageFilePos);
pagesAdded++;
accHeight = 0;
if (maxPages > 0 && pagesAdded >= maxPages) break;
}
accHeight += thisH;
} else {
lineCount++;
if (lineCount >= linesPerPage) {
pageBreak = true;
lineCount = 0;
}
}
pos = wrap.nextStart;
if (lineCount >= linesPerPage) {
if (pageBreak) {
long pageFilePos = chunkFileStart + pos;
pagePositions.push_back(pageFilePos);
pagesAdded++;
lineCount = 0;
if (maxPages > 0 && pagesAdded >= maxPages) break;
}
if (pos >= bufLen) break;
@@ -294,12 +328,13 @@ inline int indexPagesWordWrap(File& file, long startPos,
inline int indexPagesWordWrapPixel(File& file, long startPos,
std::vector<long>& pagePositions,
int linesPerPage, int maxChars,
DisplayDriver* display, int maxPages) {
DisplayDriver* display, int maxPages,
NodePrefs* prefs = nullptr) {
const int BUF_SIZE = READER_BUF_SIZE; // Match page buffer to avoid chunk boundary wrap mismatches
char buffer[BUF_SIZE];
// Ensure body font is active for pixel measurement
display->setTextSize(0);
display->setTextSize(prefs ? prefs->smallTextSize() : 0);
file.seek(startPos);
int pagesAdded = 0;
@@ -363,9 +398,11 @@ public:
private:
UITask* _task;
NodePrefs* _prefs;
Mode _mode;
bool _sdReady;
bool _initialized; // Layout metrics calculated
uint8_t _lastFontPref; // Font preference at last layout init (detect changes)
bool _bootIndexed; // Boot-time pre-indexing done
DisplayDriver* _display; // Stored reference for splash screens
@@ -373,6 +410,7 @@ private:
int _charsPerLine;
int _linesPerPage;
int _lineHeight; // virtual coord units per text line
int _textAreaHeight; // usable height for text (excluding header/footer)
int _headerHeight;
int _footerHeight;
@@ -900,22 +938,14 @@ private:
if (_pagePositions.empty()) {
// Cache had no pages (e.g. dummy entry) — full index from scratch
_pagePositions.push_back(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
indexPagesWordWrapPixel(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, _display, 0);
#else
indexPagesWordWrap(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, 0);
#endif
_linesPerPage, _charsPerLine, 0,
_textAreaHeight, _lineHeight);
} else {
long lastPos = cache->pagePositions.back();
#if defined(LilyGo_T5S3_EPaper_Pro)
indexPagesWordWrapPixel(_file, lastPos, _pagePositions,
_linesPerPage, _charsPerLine, _display, 0);
#else
indexPagesWordWrap(_file, lastPos, _pagePositions,
_linesPerPage, _charsPerLine, 0);
#endif
_linesPerPage, _charsPerLine, 0,
_textAreaHeight, _lineHeight);
}
} else {
// No cache — full index from scratch
@@ -933,13 +963,9 @@ private:
drawSplash("Indexing...", "Please wait", shortName);
_pagePositions.push_back(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
indexPagesWordWrapPixel(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, _display, 0);
#else
indexPagesWordWrap(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, 0);
#endif
_linesPerPage, _charsPerLine, 0,
_textAreaHeight, _lineHeight);
}
// Save complete index
@@ -1062,8 +1088,8 @@ private:
display.setCursor(0, 42);
display.print("/books/ on SD card");
} else {
display.setTextSize(0); // Tiny font for file list
int listLineH = 8; // Approximate tiny font line height in virtual coords
display.setTextSize(_prefs->smallTextSize()); // Tiny font for file list
int listLineH = _prefs->smallLineH();
int startY = 14;
int maxVisible = (display.height() - startY - _footerHeight) / listLineH;
if (maxVisible < 3) maxVisible = 3;
@@ -1084,7 +1110,7 @@ private:
#else
// setCursor adds +5 to y internally, but fillRect does not.
// Offset fillRect by +5 to align highlight bar with text.
display.fillRect(0, y + 5, display.width(), listLineH);
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -1092,8 +1118,6 @@ private:
}
// Set cursor AFTER fillRect so text draws on top of highlight
display.setCursor(0, y);
int type = itemTypeAt(i);
String line = selected ? "> " : " ";
@@ -1103,10 +1127,6 @@ private:
} 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);
@@ -1119,16 +1139,11 @@ private:
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());
// Pixel-aware ellipsis — small margin prevents GxEPD edge wrapping
display.drawTextEllipsized(0, y, display.width() - 4, line.c_str());
y += listLineH;
}
display.setTextSize(1); // Restore
@@ -1141,7 +1156,7 @@ private:
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.drawTextCentered(display.width() / 2, footerY, "Swipe: Scroll Tap: Open Boot: home");
#else
display.setCursor(0, footerY);
@@ -1155,7 +1170,7 @@ private:
void renderPage(DisplayDriver& display) {
// Use tiny font for maximum text density
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
int y = 0;
@@ -1166,13 +1181,9 @@ private:
// Render all lines in the page buffer using word wrap.
// The buffer contains exactly the bytes for this page (from indexed positions),
// so we render everything in it.
while (pos < _pageBufLen && lineCount < _linesPerPage && y <= maxY) {
while (pos < _pageBufLen && y <= maxY) {
int oldPos = pos;
#if defined(LilyGo_T5S3_EPaper_Pro)
WrapResult wrap = findLineBreakPixel(_pageBuf, _pageBufLen, pos, &display, _charsPerLine);
#else
WrapResult wrap = findLineBreak(_pageBuf, _pageBufLen, pos, _charsPerLine);
#endif
// Safety: stop if findLineBreak made no progress (stuck at end of buffer)
if (wrap.nextStart <= oldPos && wrap.lineEnd >= _pageBufLen) break;
@@ -1252,7 +1263,7 @@ private:
}
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setCursor(0, footerY);
display.print(status);
const char* right = "Swipe:Page Tap:GoTo Hold:Close";
@@ -1269,11 +1280,11 @@ private:
}
public:
TextReaderScreen(UITask* task)
: _task(task), _mode(FILE_LIST), _sdReady(false), _initialized(false),
TextReaderScreen(UITask* task, NodePrefs* prefs = nullptr)
: _task(task), _prefs(prefs), _mode(FILE_LIST), _sdReady(false), _initialized(false), _lastFontPref(0),
_bootIndexed(false), _display(nullptr),
_charsPerLine(38), _linesPerPage(22), _lineHeight(5),
_headerHeight(14), _footerHeight(14),
_textAreaHeight(100), _headerHeight(14), _footerHeight(14),
_selectedFile(0), _currentPath(BOOKS_FOLDER),
_fileOpen(false), _currentPage(0), _totalPages(0),
_pageBufLen(0), _contentDirty(true) {
@@ -1295,25 +1306,53 @@ public:
// Call once after display is available to calculate layout metrics
void initLayout(DisplayDriver& display) {
// Re-init if font preference changed since last layout
uint8_t curFont = _prefs ? _prefs->large_font : 0;
if (_initialized && curFont != _lastFontPref) {
_initialized = false;
Serial.println("TextReader: font changed, recalculating layout");
}
if (_initialized) return;
_lastFontPref = curFont;
// Store display reference for splash screens during openBook
_display = &display;
// Measure tiny font metrics using the display driver
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
// Measure character width: use 10 M's to get accurate average
// Measure character width: use 10 M's for monospace (T-Deck Pro tiny font).
// Proportional fonts (T5S3 and T-Deck Pro large_font) override below with
// average-width measurement since M is the widest glyph (~40% wider than average).
uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM");
if (tenCharsW > 0) {
_charsPerLine = (display.width() * 10) / tenCharsW;
}
#if defined(LilyGo_T5S3_EPaper_Pro)
// T5S3 uses pixel-based line breaking (findLineBreakPixel) which measures
// actual text width via getTextWidth(). _charsPerLine serves only as a
// safety upper bound for lines without word breaks (URLs, etc.).
_charsPerLine = 120;
// T5S3 uses proportional font (FreeSans12pt) — measure average character
// width from a representative English sample. M-based measurement is far
// too conservative (M is the widest glyph), leaving half the line empty.
{
const char* sample = "the quick brown fox jumps over lazy dog";
uint16_t sampleW = display.getTextWidth(sample);
int sampleLen = strlen(sample);
if (sampleW > 0 && sampleLen > 0) {
// 95% factor as small safety margin for slightly-wider-than-average text
_charsPerLine = (display.width() * sampleLen * 95) / ((int)sampleW * 100);
}
}
if (_charsPerLine < 15) _charsPerLine = 15;
if (_charsPerLine > 80) _charsPerLine = 80;
#else
// T-Deck Pro: large_font uses FreeSans9pt (proportional) — same fix
if (_prefs && _prefs->large_font) {
const char* sample = "the quick brown fox jumps over lazy dog";
uint16_t sampleW = display.getTextWidth(sample);
int sampleLen = strlen(sample);
if (sampleW > 0 && sampleLen > 0) {
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
}
}
if (_charsPerLine < 15) _charsPerLine = 15;
if (_charsPerLine > 60) _charsPerLine = 60;
#endif
@@ -1333,33 +1372,138 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
// T5S3 uses FreeSans12pt/FreeSerif12pt for size 0 (yAdvance=29px).
// Line height in virtual coords depends on orientation:
// Landscape: 29px / scale_y(4.22) ≈ 7 + 1 spacing = 8
// Portrait: 29px / scale_y(7.50) ≈ 4 + 1 spacing = 5
{
extern DISPLAY_CLASS display;
_lineHeight = display.isPortraitMode() ? 5 : 8;
}
#else
// T-Deck Pro large_font uses FreeSans9pt (yAdvance=22px at scale 1.5625×).
// The 6x8 formula above gives ~5-7 which is way too small — lines overlap.
// Use smallLineH() which is already tuned for this font.
if (_prefs && _prefs->large_font) {
_lineHeight = _prefs->smallLineH();
}
#endif
_headerHeight = 0; // No header in reading mode (maximize text area)
_footerHeight = 14;
int textAreaHeight = display.height() - _headerHeight - _footerHeight;
_linesPerPage = textAreaHeight / _lineHeight;
_textAreaHeight = display.height() - _headerHeight - _footerHeight;
_linesPerPage = _textAreaHeight / _lineHeight;
if (_linesPerPage < 5) _linesPerPage = 5;
if (_linesPerPage > 40) _linesPerPage = 40;
display.setTextSize(1); // Restore
_initialized = true;
Serial.printf("TextReader layout: %d chars/line, %d lines/page, lineH=%d (display %dx%d)\n",
_charsPerLine, _linesPerPage, _lineHeight, display.width(), display.height());
Serial.printf("TextReader layout: %d chars/line, %d lines/page, lineH=%d, textH=%d (display %dx%d)\n",
_charsPerLine, _linesPerPage, _lineHeight, _textAreaHeight, display.width(), display.height());
}
// ---- Boot-time Indexing ----
// Called from setup() after SD card init. Scans files, pre-indexes first
// 100 pages of each, and shows progress on the e-ink display.
// Pre-index files inside one level of subdirectories so navigating
// into them later is instant (idx files already on SD).
void bootIndexSubfolders() {
// Work from the root-level _dirList that scanFiles() already populated.
// Copy it -- scanFiles() will overwrite _dirList when we scan each subfolder.
std::vector<String> subDirs = _dirList;
if (subDirs.empty()) return;
Serial.printf("TextReader: Pre-indexing %d subfolders\n", (int)subDirs.size());
int totalSubFiles = 0;
int cachedSubFiles = 0;
int indexedSubFiles = 0;
for (int d = 0; d < (int)subDirs.size(); d++) {
String subPath = String(BOOKS_FOLDER) + "/" + subDirs[d];
_currentPath = subPath;
scanFiles(); // populates _fileList for this subfolder
// Also pick up previously converted EPUB cache files for this subfolder
String epubCachePath = subPath + "/.epub_cache";
if (SD.exists(epubCachePath.c_str())) {
File cacheDir = SD.open(epubCachePath.c_str());
if (cacheDir && cacheDir.isDirectory()) {
File cf = cacheDir.openNextFile();
while (cf && _fileList.size() < READER_MAX_FILES) {
if (!cf.isDirectory()) {
String cname = String(cf.name());
int cslash = cname.lastIndexOf('/');
if (cslash >= 0) cname = cname.substring(cslash + 1);
if (cname.endsWith(".txt") || cname.endsWith(".TXT")) {
bool dup = false;
for (int k = 0; k < (int)_fileList.size(); k++) {
if (_fileList[k] == cname) { dup = true; break; }
}
if (!dup) _fileList.push_back(cname);
}
}
cf = cacheDir.openNextFile();
}
cacheDir.close();
}
}
for (int i = 0; i < (int)_fileList.size(); i++) {
totalSubFiles++;
// Try loading existing .idx cache -- if hit, skip
FileCache tempCache;
if (loadIndex(_fileList[i], tempCache)) {
cachedSubFiles++;
continue;
}
// Skip .epub files (converted on first open)
if (_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB")) continue;
// Index this .txt file
String fullPath = _currentPath + "/" + _fileList[i];
File file = SD.open(fullPath.c_str(), FILE_READ);
if (!file) {
// Try epub cache fallback
String cacheFallback = epubCachePath + "/" + _fileList[i];
file = SD.open(cacheFallback.c_str(), FILE_READ);
}
if (!file) continue;
indexedSubFiles++;
String displayName = subDirs[d] + "/" + _fileList[i];
drawBootSplash(indexedSubFiles, 0, displayName);
FileCache cache;
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,
_textAreaHeight, _lineHeight);
cache.fullyIndexed = !file.available();
file.close();
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
cache.fullyIndexed, 0);
Serial.printf("TextReader: %s/%s - indexed %d pages%s\n",
subDirs[d].c_str(), _fileList[i].c_str(),
(int)cache.pagePositions.size(),
cache.fullyIndexed ? " (complete)" : "");
yield(); // Feed WDT between files
}
}
Serial.printf("TextReader: Subfolder pre-index: %d files (%d cached, %d newly indexed)\n",
totalSubFiles, cachedSubFiles, indexedSubFiles);
}
void bootIndex(DisplayDriver& display) {
if (!_sdReady) return;
@@ -1401,20 +1545,24 @@ public:
}
}
if (_fileList.size() == 0) {
Serial.println("TextReader: No files to index");
if (_fileList.size() == 0 && _dirList.size() == 0) {
Serial.println("TextReader: No files or folders to index");
_bootIndexed = true;
return;
}
int cachedCount = 0;
int needsIndexCount = 0;
// --- Pass 1 & 2: Index root-level files ---
if (_fileList.size() > 0) {
// --- Pass 1: Fast cache load (no per-file splash screens) ---
// Try to load existing .idx files from SD for every file.
// This is just SD reads — no indexing, no e-ink refreshes.
_fileCache.clear();
_fileCache.resize(_fileList.size()); // Pre-allocate slots to maintain alignment with _fileList
int cachedCount = 0;
int needsIndexCount = 0;
for (int i = 0; i < (int)_fileList.size(); i++) {
if (loadIndex(_fileList[i], _fileCache[i])) {
@@ -1464,15 +1612,10 @@ public:
cache.pagePositions.clear();
cache.pagePositions.push_back(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
int added = indexPagesWordWrapPixel(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
_display, PREINDEX_PAGES - 1);
#else
int added = indexPagesWordWrap(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
PREINDEX_PAGES - 1);
#endif
PREINDEX_PAGES - 1,
_textAreaHeight, _lineHeight);
cache.fullyIndexed = !file.available();
file.close();
@@ -1485,6 +1628,26 @@ public:
}
}
} // end if (_fileList.size() > 0)
// --- Pass 3: Pre-index files inside subfolders (one level deep) ---
// Save root state -- bootIndexSubfolders() will overwrite _fileList/_dirList
// via scanFiles() as it iterates each subdirectory.
if (_dirList.size() > 0) {
std::vector<String> savedFileList = _fileList;
std::vector<String> savedDirList = _dirList;
std::vector<FileCache> savedFileCache = _fileCache;
bootIndexSubfolders();
// Restore root state
_currentPath = String(BOOKS_FOLDER);
_fileList = savedFileList;
_dirList = savedDirList;
_fileCache = savedFileCache;
}
// Deselect SD to free SPI bus
digitalWrite(SDCARD_CS, HIGH);
@@ -1515,13 +1678,9 @@ public:
// Layout was invalidated (orientation change) — reindex the open book
Serial.println("TextReader: Reindexing after layout change");
_pagePositions.push_back(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
indexPagesWordWrapPixel(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, _display, 0);
#else
indexPagesWordWrap(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, 0);
#endif
_linesPerPage, _charsPerLine, 0,
_textAreaHeight, _lineHeight);
_totalPages = _pagePositions.size();
if (_currentPage >= _totalPages) _currentPage = 0;
_mode = READING;
@@ -1554,11 +1713,12 @@ public:
// Returns: 0=miss, 1=moved, 2=tapped current row.
int selectRowAtVY(int vy) {
if (_mode != FILE_LIST) return 0;
const int startY = 14, footerH = 14, listLineH = 8;
const int startY = 14, footerH = 14;
const int listLineH = _prefs ? _prefs->smallLineH() : 9;
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = startY;
#else
const int bodyTop = startY + 5; // GxEPD baseline offset
const int bodyTop = startY + (_prefs ? _prefs->smallHighlightOff() : 5);
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
@@ -1689,15 +1849,10 @@ public:
cache.lastReadPage = 0;
cache.pagePositions.clear();
cache.pagePositions.push_back(0);
#if defined(LilyGo_T5S3_EPaper_Pro)
indexPagesWordWrapPixel(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
_display, PREINDEX_PAGES - 1);
#else
indexPagesWordWrap(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
PREINDEX_PAGES - 1);
#endif
PREINDEX_PAGES - 1,
_textAreaHeight, _lineHeight);
cache.fullyIndexed = !file.available();
file.close();
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
File diff suppressed because it is too large Load Diff
+72
View File
@@ -30,6 +30,10 @@
#include "WebReaderScreen.h"
#endif
#ifdef MECK_AUDIO_VARIANT
#include "AlarmScreen.h"
#endif
#if defined(LilyGo_T5S3_EPaper_Pro)
#include "VirtualKeyboard.h"
#endif
@@ -56,6 +60,9 @@ class UITask : public AbstractUITask {
NodePrefs* _node_prefs;
char _alert[80];
unsigned long _alert_expiry;
bool _hintActive = false; // Boot navigation hint overlay
unsigned long _hintExpiry = 0; // Auto-dismiss time for hint
bool _pendingBootHint = false; // Deferred hint — show after splash screen
int _msgcount;
unsigned long ui_started_at, next_batt_chck;
uint8_t _low_batt_count = 0; // Consecutive low-voltage readings for debounce
@@ -79,10 +86,15 @@ class UITask : public AbstractUITask {
UIScreen* notes_screen; // Notes editor screen
UIScreen* settings_screen; // Settings/onboarding screen
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
#ifdef MECK_AUDIO_VARIANT
UIScreen* alarm_screen; // Alarm clock screen (audio variant only)
UIScreen* voice_screen; // Voice message screen (audio variant only)
#endif
#ifdef HAS_4G_MODEM
UIScreen* sms_screen; // SMS messaging screen (4G variant only)
#endif
UIScreen* repeater_admin; // Repeater admin screen
UIScreen* path_editor; // Custom path editor screen (lazy-init)
UIScreen* discovery_screen; // Node discovery scan screen
UIScreen* last_heard_screen; // Last heard passive advert list
#ifdef MECK_WEB_READER
@@ -103,6 +115,13 @@ class UITask : public AbstractUITask {
bool _vkbActive = false;
UIScreen* _screenBeforeVKB = nullptr;
unsigned long _vkbOpenedAt = 0;
// Powersaving: light sleep when locked + idle (standalone only — no BLE/WiFi)
// Wakes on LoRa packet (DIO1), boot button (GPIO0), or 30-min timer
#if !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION)
unsigned long _psLastActive = 0; // millis() at last wake or lock entry
unsigned long _psNextSleepSecs = 60; // Seconds before first sleep (60s), then 5s cycles
#endif
#ifdef MECK_CARDKB
bool _cardkbDetected = false;
#endif
@@ -114,6 +133,26 @@ class UITask : public AbstractUITask {
unsigned long _lastLockRefresh = 0; // Periodic lock screen clock update
#endif
// --- Message dedup ring buffer (suppress retry spam at UI level) ---
#define MSG_DEDUP_SIZE 8
#define MSG_DEDUP_WINDOW_MS 60000 // 60 seconds
struct MsgDedup {
uint32_t name_hash;
uint32_t text_hash;
unsigned long millis;
};
MsgDedup _dedup[MSG_DEDUP_SIZE];
int _dedupIdx = 0;
// --- Per-contact DM unread tracking ---
uint8_t* _dmUnread = nullptr; // PSRAM-allocated, MAX_CONTACTS entries
static uint32_t simpleHash(const char* s) {
uint32_t h = 5381;
while (*s) { h = ((h << 5) + h) ^ (uint8_t)*s++; }
return h;
}
void userLedHandler();
// Button action handlers
@@ -141,13 +180,21 @@ public:
void gotoHomeScreen();
void gotoChannelScreen(); // Navigate to channel message screen
void gotoDMTab(); // Navigate directly to DM tab on channel screen
void gotoDMConversation(const char* contactName, int contactIdx = -1, uint8_t perms = 0);
void gotoContactsScreen(); // Navigate to contacts list
void gotoTextReader(); // *** NEW: Navigate to text reader ***
void gotoNotesScreen(); // Navigate to notes editor
void gotoSettingsScreen(); // Navigate to settings
void gotoOnboarding(); // Navigate to settings in onboarding mode
void gotoAudiobookPlayer(); // Navigate to audiobook player
#ifdef MECK_AUDIO_VARIANT
void gotoAlarmScreen(); // Navigate to alarm clock
void gotoVoiceScreen(); // Navigate to voice message recorder
#endif
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
void gotoRepeaterAdminDirect(int contactIdx); // Auto-login admin (L key from conversation)
void gotoPathEditor(int contactIdx); // Navigate to custom path editor
void gotoDiscoveryScreen(); // Navigate to node discovery scan
void gotoLastHeardScreen(); // Navigate to last heard passive list
#if HAS_GPS
@@ -163,6 +210,9 @@ public:
#endif
void showAlert(const char* text, int duration_millis) override;
void forceRefresh() override { _next_refresh = 100; }
void showBootHint(bool immediate = false); // Show navigation hint overlay on first boot
void dismissBootHint(); // Dismiss hint and save preference
bool isHintActive() const { return _hintActive; }
// Wake display and extend auto-off timer. Call this when handling keys
// outside of injectKey() to prevent display auto-off during direct input.
void keepAlive() {
@@ -171,6 +221,14 @@ public:
}
int getMsgCount() const { return _msgcount; }
int getUnreadMsgCount() const; // Per-channel unread tracking (standalone)
// Per-contact DM unread tracking
bool hasDMUnread(int contactIdx) const;
int getDMUnreadCount(int contactIdx) const;
void clearDMUnread(int contactIdx);
// Flag: suppress room→conversation redirect on next login (L key admin access)
bool _skipRoomRedirect = false;
bool hasDisplay() const { return _display != NULL; }
bool isButtonPressed() const;
bool isOnChannelScreen() const { return curr == channel_screen; }
@@ -184,7 +242,12 @@ public:
bool isOnNotesScreen() const { return curr == notes_screen; }
bool isOnSettingsScreen() const { return curr == settings_screen; }
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
#ifdef MECK_AUDIO_VARIANT
bool isOnAlarmScreen() const { return curr == alarm_screen; }
bool isOnVoiceScreen() const { return curr == voice_screen; }
#endif
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
bool isOnPathEditor() const { return curr == path_editor; }
bool isOnDiscoveryScreen() const { return curr == discovery_screen; }
bool isOnLastHeardScreen() const { return curr == last_heard_screen; }
bool isOnMapScreen() const { return curr == map_screen; }
@@ -231,6 +294,7 @@ public:
// Add a sent message to the channel screen history
void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) override;
void addSentDM(const char* recipientName, const char* sender, const char* text);
// Mark channel as read when BLE companion app syncs messages
void markChannelReadFromBLE(uint8_t channel_idx) override;
@@ -248,9 +312,17 @@ public:
UIScreen* getContactsScreen() const { return contacts_screen; }
UIScreen* getChannelScreen() const { return channel_screen; }
UIScreen* getSettingsScreen() const { return settings_screen; }
NodePrefs* getNodePrefs() const { return _node_prefs; }
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
#ifdef MECK_AUDIO_VARIANT
UIScreen* getAlarmScreen() const { return alarm_screen; }
void setAlarmScreen(UIScreen* s) { alarm_screen = s; }
UIScreen* getVoiceScreen() const { return voice_screen; }
void setVoiceScreen(UIScreen* s) { voice_screen = s; }
#endif
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
UIScreen* getPathEditorScreen() const { return path_editor; }
UIScreen* getDiscoveryScreen() const { return discovery_screen; }
UIScreen* getLastHeardScreen() const { return last_heard_screen; }
UIScreen* getMapScreen() const { return map_screen; }
File diff suppressed because it is too large Load Diff
@@ -39,6 +39,7 @@
#include "ModemManager.h"
#endif
#include "Utf8CP437.h"
#include "../NodePrefs.h"
// Forward declarations
class UITask;
@@ -1030,8 +1031,10 @@ public:
private:
UITask* _task;
NodePrefs* _prefs;
Mode _mode;
bool _initialized;
uint8_t _lastFontPref;
DisplayDriver* _display;
// Display layout (calculated once)
@@ -1424,7 +1427,7 @@ private:
_display->print("WiFi Setup");
_display->drawRect(0, 11, _display->width(), 1);
_display->setColor(DisplayDriver::LIGHT);
_display->setTextSize(0);
_display->setTextSize(_prefs->smallTextSize());
_display->setCursor(0, 18);
_display->print("Scanning for networks...");
_display->endFrame();
@@ -1524,7 +1527,7 @@ private:
_display->print("Web Reader");
_display->drawRect(0, 11, _display->width(), 1);
_display->setTextSize(0);
_display->setTextSize(_prefs->smallTextSize());
_display->setCursor(0, 18);
_display->print("Connected!");
_display->setCursor(0, 30);
@@ -2306,7 +2309,7 @@ private:
_display->print("Web Reader");
_display->drawRect(0, 11, _display->width(), 1);
_display->setColor(DisplayDriver::YELLOW);
_display->setTextSize(0);
_display->setTextSize(_prefs->smallTextSize());
_display->setCursor(0, 18);
_display->print("Fetch failed:");
_display->setColor(DisplayDriver::LIGHT);
@@ -2442,7 +2445,7 @@ private:
_display->setTextSize(2);
_display->setCursor(10, 20);
_display->print("Logging in...");
_display->setTextSize(0);
_display->setTextSize(_prefs->smallTextSize());
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(10, 45);
_display->print("Refreshing session...");
@@ -2656,14 +2659,14 @@ private:
display.drawRect(0, 11, display.width(), 1);
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
if (_wifiState == WIFI_SCANNING) {
display.setCursor(0, 18);
display.print("Scanning for networks...");
} else if (_wifiState == WIFI_SCAN_DONE) {
int y = 14;
int listLineH = 8;
int listLineH = _prefs ? _prefs->smallLineH() : 9;
for (int i = 0; i < _ssidCount && y < display.height() - 24; i++) {
bool selected = (i == _selectedSSID);
if (selected) {
@@ -2671,7 +2674,7 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), listLineH);
#else
display.fillRect(0, y + 5, display.width(), listLineH);
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -2695,7 +2698,7 @@ private:
y += 12;
display.setCursor(0, y);
display.print("Password:");
y += 10;
y += _prefs->smallLineH() + 1;
display.setCursor(0, y);
// Show masked password with brief reveal of last char
char passBuf[WEB_WIFI_PASS_LEN + 2];
@@ -2771,7 +2774,7 @@ private:
if (isNetworkAvailable()) {
display.print("Web Reader");
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::GREEN);
if (isWiFiConnected()) {
IPAddress ip = WiFi.localIP();
@@ -2797,7 +2800,7 @@ private:
const int footerY = display.height() - 12;
const int viewportH = display.height() - headerY - footerH;
const int scrollbarW = 4;
const int listLineH = 8;
const int listLineH = _prefs ? _prefs->smallLineH() : 9;
const int sepH = 8; // Separator between IRC and web sections
const int sectionH = listLineH; // Section header height
int maxChars = _charsPerLine - 2; // Account for "> " prefix
@@ -2875,7 +2878,7 @@ private:
if (totalContentH <= viewportH) _homeScrollY = 0;
// ---- Render pass (with scroll offset) ----
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
int y = headerY - _homeScrollY; // Start Y in screen coords
itemIdx = 0;
bool needsScroll = (totalContentH > viewportH);
@@ -2895,7 +2898,7 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#else
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -2934,7 +2937,7 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#else
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -2971,7 +2974,7 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#else
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -3024,7 +3027,7 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, contentW, itemH);
#else
display.fillRect(0, y + 5, contentW, itemH);
display.fillRect(0, y + _prefs->smallHighlightOff(), contentW, itemH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -3076,7 +3079,7 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, contentW, itemH);
#else
display.fillRect(0, y + 5, contentW, itemH);
display.fillRect(0, y + _prefs->smallHighlightOff(), contentW, itemH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
@@ -3198,7 +3201,7 @@ private:
display.setCursor(10, 20);
display.print("Loading...");
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
// Word-wrap the URL across multiple lines
@@ -3243,7 +3246,7 @@ private:
display.print("Download Complete");
display.drawRect(0, 11, display.width(), 1);
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 16);
display.print("Saved to /books/:");
@@ -3277,7 +3280,7 @@ private:
display.print("Download Failed");
display.drawRect(0, 11, display.width(), 1);
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 18);
display.print(_fetchError.c_str());
@@ -3314,7 +3317,7 @@ private:
return;
}
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
// Determine page bounds
@@ -3476,9 +3479,16 @@ private:
// ---- Layout Initialization ----
void initLayout(DisplayDriver& display) {
// Re-init if font preference changed since last layout
uint8_t curFont = _prefs ? _prefs->large_font : 0;
if (_initialized && curFont != _lastFontPref) {
_initialized = false;
Serial.println("WebReader: font changed, recalculating layout");
}
if (_initialized) return;
_lastFontPref = curFont;
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
uint16_t mWidth = display.getTextWidth("M");
if (mWidth > 0) {
_charsPerLine = display.width() / mWidth;
@@ -3487,6 +3497,19 @@ private:
_charsPerLine = 40;
_lineHeight = 5;
}
// Proportional font: use average-width measurement instead of M-width
if (_prefs && _prefs->large_font && mWidth > 0) {
const char* sample = "the quick brown fox jumps over lazy dog";
uint16_t sampleW = display.getTextWidth(sample);
int sampleLen = strlen(sample);
if (sampleW > 0 && sampleLen > 0) {
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
}
}
// Large font: formula above assumes built-in 6x8 ratio — too small for 9pt
if (_prefs && _prefs->large_font) {
_lineHeight = _prefs->smallLineH();
}
_footerHeight = 14;
int textAreaHeight = display.height() - _footerHeight;
@@ -3931,7 +3954,7 @@ private:
if (_activeForm < 0 || _activeForm >= _formCount) return;
WebForm& form = _forms[_activeForm];
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
// Header
display.setColor(DisplayDriver::GREEN);
@@ -3954,7 +3977,7 @@ private:
display.drawRect(0, 9, display.width(), 1);
int y = 12;
int lineH = 10; // Taller lines for form fields
int lineH = _prefs->smallLineH() + 1; // Taller lines for form fields
int visCount = getVisibleFieldCount(form);
// Render each visible field
@@ -4662,9 +4685,9 @@ private:
display.print("IRC Setup");
display.drawRect(0, 11, display.width(), 1);
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
int y = 16;
int lineH = 10;
int lineH = _prefs->smallLineH() + 1;
const char* labels[] = {"Server:", "Port:", "Nick:", "Channel:", "[ Connect ]"};
const char* chanDisp = (_ircChannel[0] != '\0') ? _ircChannel : "(none)";
@@ -4822,7 +4845,7 @@ private:
display.print(header);
// Connection indicator on right
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
if (!_ircConnected) {
display.setColor(DisplayDriver::YELLOW);
display.setCursor(display.width() - 42, -3);
@@ -4848,7 +4871,7 @@ private:
if (_ircComposing) {
// Compose text just above separator (tiny font to match messages)
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, footerY - 12);
char compDisp[IRC_COMPOSE_MAX + 4];
@@ -4878,10 +4901,10 @@ private:
}
// Message area
display.setTextSize(0);
display.setTextSize(_prefs->smallTextSize());
int msgAreaTop = 14;
int msgAreaBottom = _ircComposing ? footerY - 16 : footerY - 4;
int lineH = 8;
int lineH = _prefs->smallLineH() - 1;
int scrollBarW = 4;
int lineW = _charsPerLine - 1; // Reserve space for scroll bar
_ircLinesPerPage = (msgAreaBottom - msgAreaTop) / lineH;
@@ -5065,8 +5088,8 @@ private:
}
public:
WebReaderScreen(UITask* task)
: _task(task), _mode(HOME), _initialized(false), _display(nullptr),
WebReaderScreen(UITask* task, NodePrefs* prefs = nullptr)
: _task(task), _prefs(prefs), _mode(HOME), _initialized(false), _lastFontPref(0), _display(nullptr),
_charsPerLine(40), _linesPerPage(15), _lineHeight(5), _footerHeight(14),
_wifiState(WIFI_IDLE), _ssidCount(0), _selectedSSID(0), _wifiPassLen(0),
_urlLen(0), _urlCursor(0),
@@ -5150,7 +5173,7 @@ public:
_display->print("Web Reader");
_display->drawRect(0, 11, _display->width(), 1);
_display->setColor(DisplayDriver::LIGHT);
_display->setTextSize(0);
_display->setTextSize(_prefs->smallTextSize());
_display->setCursor(0, 18);
_display->print("Connecting to WiFi...");
_display->endFrame();
@@ -46,4 +46,18 @@ static const uint8_t icon_notepad[] PROGMEM = {
static const uint8_t icon_search[] PROGMEM = {
0x3C,0x00, 0x42,0x00, 0x81,0x00, 0x81,0x00, 0x81,0x00, 0x42,0x00,
0x3C,0x00, 0x03,0x00, 0x01,0x80, 0x00,0xC0, 0x00,0x40, 0x00,0x00,
};
// ⏰ Alarm Clock (AlarmScreen) — 12x12 home tile icon
static const uint8_t icon_alarm[] PROGMEM = {
0x40,0x40, 0x9E,0x20, 0x20,0x80, 0x44,0x40, 0x44,0x40, 0x46,0x40,
0x40,0x40, 0x20,0x80, 0x1F,0x00, 0x00,0x00, 0x20,0x40, 0x40,0x20,
};
// 🔔 Bell — 7x8 status bar indicator (alarm enabled)
// MSB-first, 1 byte per row
#define BELL_ICON_W 7
#define BELL_ICON_H 8
static const uint8_t icon_bell_small[] PROGMEM = {
0x10, 0x38, 0x7C, 0x7C, 0x7C, 0xFE, 0x00, 0x10,
};
+372
View File
@@ -0,0 +1,372 @@
#pragma once
// =============================================================================
// ApnDatabase.h - Embedded APN Lookup Table
//
// Maps MCC/MNC (Mobile Country Code / Mobile Network Code) to default APN
// settings for common carriers worldwide. Compiled directly into flash (~3KB)
// so users never need to manually install a lookup file.
//
// The modem queries IMSI via AT+CIMI to extract MCC (3 digits) + MNC (2-3
// digits), then looks up the APN here. If not found, falls back to the
// modem's existing PDP context (AT+CGDCONT?) or user-configured APN.
//
// To add a carrier: append to APN_DATABASE[] with the MCC+MNC as a single
// integer. MNC can be 2 or 3 digits:
// MCC=310, MNC=260 → mccmnc = 310260
// MCC=505, MNC=01 → mccmnc = 50501
//
// Guard: HAS_4G_MODEM
// =============================================================================
#ifdef HAS_4G_MODEM
#ifndef APN_DATABASE_H
#define APN_DATABASE_H
struct ApnEntry {
uint32_t mccmnc; // MCC+MNC as integer (e.g. 310260 for T-Mobile US)
const char* apn; // APN string
const char* carrier; // Human-readable carrier name (for debug/display)
};
// ---------------------------------------------------------------------------
// APN Database — sorted by MCC for binary search potential (not required)
//
// Sources: carrier documentation, GSMA databases, community wikis.
// This covers ~120 major carriers across key regions. Users with less
// common carriers can set APN manually in Settings.
// ---------------------------------------------------------------------------
static const ApnEntry APN_DATABASE[] = {
// =========================================================================
// Australia (MCC 505)
// =========================================================================
{ 50501, "telstra.internet", "Telstra" },
{ 50502, "yesinternet", "Optus" },
{ 50503, "vfinternet.au", "Vodafone AU" },
{ 50506, "3netaccess", "Three AU" },
{ 50507, "telstra.internet", "Vodafone AU (MVNO)" }, // Many MVNOs on Telstra
{ 50510, "telstra.internet", "Norfolk Tel" },
{ 50512, "3netaccess", "Amaysim" }, // Optus MVNO
{ 50514, "yesinternet", "Aussie Broadband" }, // Optus MVNO
{ 50590, "yesinternet", "Optus MVNO" },
// =========================================================================
// New Zealand (MCC 530)
// =========================================================================
{ 53001, "internet", "Vodafone NZ" },
{ 53005, "internet", "Spark NZ" },
{ 53024, "internet", "2degrees" },
// =========================================================================
// United States (MCC 310, 311, 312, 313, 316)
// =========================================================================
{ 310012, "fast.t-mobile.com", "Verizon (old)" },
{ 310026, "fast.t-mobile.com", "T-Mobile US" },
{ 310030, "fast.t-mobile.com", "T-Mobile US" },
{ 310032, "fast.t-mobile.com", "T-Mobile US" },
{ 310060, "fast.t-mobile.com", "T-Mobile US" },
{ 310160, "fast.t-mobile.com", "T-Mobile US" },
{ 310200, "fast.t-mobile.com", "T-Mobile US" },
{ 310210, "fast.t-mobile.com", "T-Mobile US" },
{ 310220, "fast.t-mobile.com", "T-Mobile US" },
{ 310230, "fast.t-mobile.com", "T-Mobile US" },
{ 310240, "fast.t-mobile.com", "T-Mobile US" },
{ 310250, "fast.t-mobile.com", "T-Mobile US" },
{ 310260, "fast.t-mobile.com", "T-Mobile US" },
{ 310270, "fast.t-mobile.com", "T-Mobile US" },
{ 310310, "fast.t-mobile.com", "T-Mobile US" },
{ 310490, "fast.t-mobile.com", "T-Mobile US" },
{ 310530, "fast.t-mobile.com", "T-Mobile US" },
{ 310580, "fast.t-mobile.com", "T-Mobile US" },
{ 310660, "fast.t-mobile.com", "T-Mobile US" },
{ 310800, "fast.t-mobile.com", "T-Mobile US" },
{ 311480, "vzwinternet", "Verizon" },
{ 311481, "vzwinternet", "Verizon" },
{ 311482, "vzwinternet", "Verizon" },
{ 311483, "vzwinternet", "Verizon" },
{ 311484, "vzwinternet", "Verizon" },
{ 311489, "vzwinternet", "Verizon" },
{ 310410, "fast.t-mobile.com", "AT&T (migrated)" },
{ 310120, "att.mvno", "AT&T (Sprint)" },
{ 312530, "iot.1nce.net", "1NCE IoT" },
{ 310120, "tfdata", "Tracfone" },
// =========================================================================
// Canada (MCC 302)
// =========================================================================
{ 30220, "internet.com", "Rogers" },
{ 30221, "internet.com", "Rogers" },
{ 30237, "internet.com", "Rogers" },
{ 30272, "internet.com", "Rogers" },
{ 30234, "sp.telus.com", "Telus" },
{ 30286, "sp.telus.com", "Telus" },
{ 30236, "sp.telus.com", "Telus" },
{ 30261, "sp.bell.ca", "Bell" },
{ 30263, "sp.bell.ca", "Bell" },
{ 30267, "sp.bell.ca", "Bell" },
{ 30268, "fido-core-appl1.apn", "Fido" },
{ 30278, "internet.com", "SaskTel" },
{ 30266, "sp.mb.com", "MTS" },
// =========================================================================
// United Kingdom (MCC 234, 235)
// =========================================================================
{ 23410, "o2-internet", "O2 UK" },
{ 23415, "three.co.uk", "Vodafone UK" },
{ 23420, "three.co.uk", "Three UK" },
{ 23430, "everywhere", "EE" },
{ 23431, "everywhere", "EE" },
{ 23432, "everywhere", "EE" },
{ 23433, "everywhere", "EE" },
{ 23450, "data.lycamobile.co.uk","Lycamobile UK" },
{ 23486, "three.co.uk", "Three UK" },
// =========================================================================
// Germany (MCC 262)
// =========================================================================
{ 26201, "internet.t-mobile", "Telekom DE" },
{ 26202, "web.vodafone.de", "Vodafone DE" },
{ 26203, "internet", "O2 DE" },
{ 26207, "internet", "O2 DE" },
// =========================================================================
// France (MCC 208)
// =========================================================================
{ 20801, "orange", "Orange FR" },
{ 20810, "sl2sfr", "SFR" },
{ 20815, "free", "Free Mobile" },
{ 20820, "ofnew.fr", "Bouygues" },
// =========================================================================
// Italy (MCC 222)
// =========================================================================
{ 22201, "mobile.vodafone.it", "TIM" },
{ 22210, "mobile.vodafone.it", "Vodafone IT" },
{ 22250, "internet.it", "Iliad IT" },
{ 22288, "internet.wind", "WindTre" },
{ 22299, "internet.wind", "WindTre" },
// =========================================================================
// Spain (MCC 214)
// =========================================================================
{ 21401, "internet", "Vodafone ES" },
{ 21403, "internet", "Orange ES" },
{ 21404, "internet", "Yoigo" },
{ 21407, "internet", "Movistar" },
// =========================================================================
// Netherlands (MCC 204)
// =========================================================================
{ 20404, "internet", "Vodafone NL" },
{ 20408, "internet", "KPN" },
{ 20412, "internet", "Telfort" },
{ 20416, "internet", "T-Mobile NL" },
{ 20420, "internet", "T-Mobile NL" },
// =========================================================================
// Sweden (MCC 240)
// =========================================================================
{ 24001, "internet.telia.se", "Telia SE" },
{ 24002, "tre.se", "Three SE" },
{ 24007, "internet.telenor.se", "Telenor SE" },
// =========================================================================
// Norway (MCC 242)
// =========================================================================
{ 24201, "internet.telenor.no", "Telenor NO" },
{ 24202, "internet.netcom.no", "Telia NO" },
// =========================================================================
// Denmark (MCC 238)
// =========================================================================
{ 23801, "internet", "TDC" },
{ 23802, "internet", "Telenor DK" },
{ 23806, "internet", "Three DK" },
{ 23820, "internet", "Telia DK" },
// =========================================================================
// Switzerland (MCC 228)
// =========================================================================
{ 22801, "gprs.swisscom.ch", "Swisscom" },
{ 22802, "internet", "Sunrise" },
{ 22803, "internet", "Salt" },
// =========================================================================
// Austria (MCC 232)
// =========================================================================
{ 23201, "a1.net", "A1" },
{ 23203, "web.one.at", "Three AT" },
{ 23205, "web", "T-Mobile AT" },
// =========================================================================
// Japan (MCC 440, 441)
// =========================================================================
{ 44010, "spmode.ne.jp", "NTT Docomo" },
{ 44020, "plus.4g", "SoftBank" },
{ 44051, "au.au-net.ne.jp", "KDDI au" },
// =========================================================================
// South Korea (MCC 450)
// =========================================================================
{ 45005, "lte.sktelecom.com", "SK Telecom" },
{ 45006, "lte.ktfwing.com", "KT" },
{ 45008, "lte.lguplus.co.kr", "LG U+" },
// =========================================================================
// India (MCC 404, 405)
// =========================================================================
{ 40445, "airtelgprs.com", "Airtel" },
{ 40410, "airtelgprs.com", "Airtel" },
{ 40411, "www", "Vodafone IN (Vi)" },
{ 40413, "www", "Vodafone IN (Vi)" },
{ 40486, "www", "Vodafone IN (Vi)" },
{ 40553, "jionet", "Jio" },
{ 40554, "jionet", "Jio" },
{ 40512, "bsnlnet", "BSNL" },
// =========================================================================
// Singapore (MCC 525)
// =========================================================================
{ 52501, "internet", "Singtel" },
{ 52503, "internet", "M1" },
{ 52505, "internet", "StarHub" },
// =========================================================================
// Hong Kong (MCC 454)
// =========================================================================
{ 45400, "internet", "CSL" },
{ 45406, "internet", "SmarTone" },
{ 45412, "internet", "CMHK" },
// =========================================================================
// Brazil (MCC 724)
// =========================================================================
{ 72405, "claro.com.br", "Claro BR" },
{ 72406, "wap.oi.com.br", "Vivo" },
{ 72410, "wap.oi.com.br", "Vivo" },
{ 72411, "wap.oi.com.br", "Vivo" },
{ 72415, "internet.tim.br", "TIM BR" },
{ 72431, "gprs.oi.com.br", "Oi" },
// =========================================================================
// Mexico (MCC 334)
// =========================================================================
{ 33402, "internet.itelcel.com","Telcel" },
{ 33403, "internet.movistar.mx","Movistar MX" },
{ 33404, "internet.att.net.mx", "AT&T MX" },
// =========================================================================
// South Africa (MCC 655)
// =========================================================================
{ 65501, "internet", "Vodacom" },
{ 65502, "internet", "Telkom ZA" },
{ 65507, "internet", "Cell C" },
{ 65510, "internet", "MTN ZA" },
// =========================================================================
// Philippines (MCC 515)
// =========================================================================
{ 51502, "internet.globe.com.ph","Globe" },
{ 51503, "internet", "Smart" },
{ 51505, "internet", "Sun Cellular" },
// =========================================================================
// Thailand (MCC 520)
// =========================================================================
{ 52001, "internet", "AIS" },
{ 52004, "internet", "TrueMove" },
{ 52005, "internet", "dtac" },
// =========================================================================
// Indonesia (MCC 510)
// =========================================================================
{ 51001, "internet", "Telkomsel" },
{ 51010, "internet", "Telkomsel" },
{ 51011, "3gprs", "XL Axiata" },
{ 51028, "3gprs", "XL Axiata (Axis)" },
// =========================================================================
// Malaysia (MCC 502)
// =========================================================================
{ 50212, "celcom3g", "Celcom" },
{ 50213, "celcom3g", "Celcom" },
{ 50216, "internet", "Digi" },
{ 50219, "celcom3g", "Celcom" },
// =========================================================================
// Czech Republic (MCC 230)
// =========================================================================
{ 23001, "internet.t-mobile.cz","T-Mobile CZ" },
{ 23002, "internet", "O2 CZ" },
{ 23003, "internet.vodafone.cz","Vodafone CZ" },
// =========================================================================
// Poland (MCC 260)
// =========================================================================
{ 26001, "internet", "Plus PL" },
{ 26002, "internet", "T-Mobile PL" },
{ 26003, "internet", "Orange PL" },
{ 26006, "internet", "Play" },
// =========================================================================
// Portugal (MCC 268)
// =========================================================================
{ 26801, "internet", "Vodafone PT" },
{ 26803, "internet", "NOS" },
{ 26806, "internet", "MEO" },
// =========================================================================
// Ireland (MCC 272)
// =========================================================================
{ 27201, "internet", "Vodafone IE" },
{ 27202, "open.internet", "Three IE" },
{ 27205, "three.ie", "Three IE" },
// =========================================================================
// IoT / Global SIMs
// =========================================================================
{ 901028, "iot.1nce.net", "1NCE (IoT)" },
{ 90143, "hologram", "Hologram" },
};
#define APN_DATABASE_SIZE (sizeof(APN_DATABASE) / sizeof(APN_DATABASE[0]))
// ---------------------------------------------------------------------------
// Lookup function — returns nullptr if not found
// ---------------------------------------------------------------------------
inline const ApnEntry* apnLookup(uint32_t mccmnc) {
for (int i = 0; i < (int)APN_DATABASE_SIZE; i++) {
if (APN_DATABASE[i].mccmnc == mccmnc) {
return &APN_DATABASE[i];
}
}
return nullptr;
}
// Parse IMSI string into MCC+MNC. Tries 3-digit MNC first (6-digit mccmnc),
// falls back to 2-digit MNC (5-digit mccmnc) if not found.
inline const ApnEntry* apnLookupFromIMSI(const char* imsi) {
if (!imsi || strlen(imsi) < 5) return nullptr;
// Extract MCC (always 3 digits)
uint32_t mcc = (imsi[0] - '0') * 100 + (imsi[1] - '0') * 10 + (imsi[2] - '0');
// Try 3-digit MNC first (more specific)
if (strlen(imsi) >= 6) {
uint32_t mnc3 = (imsi[3] - '0') * 100 + (imsi[4] - '0') * 10 + (imsi[5] - '0');
uint32_t mccmnc6 = mcc * 1000 + mnc3;
const ApnEntry* entry = apnLookup(mccmnc6);
if (entry) return entry;
}
// Fall back to 2-digit MNC
uint32_t mnc2 = (imsi[3] - '0') * 10 + (imsi[4] - '0');
uint32_t mccmnc5 = mcc * 100 + mnc2;
return apnLookup(mccmnc5);
}
#endif // APN_DATABASE_H
#endif // HAS_4G_MODEM
+228
View File
@@ -0,0 +1,228 @@
#pragma once
// =============================================================================
// CellularMQTT — A7682E Modem + MQTT via native AT commands
// =============================================================================
#ifdef HAS_4G_MODEM
#ifndef CELLULAR_MQTT_H
#define CELLULAR_MQTT_H
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
#include <freertos/semphr.h>
#include "variant.h"
#include "ApnDatabase.h"
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
#define MQTT_TOPIC_MAX 80
#define MQTT_PAYLOAD_MAX 512
#define MQTT_CLIENT_ID_MAX 32
#define CMD_QUEUE_SIZE 4
#define RSP_QUEUE_SIZE 4
#define TELEMETRY_INTERVAL 60000
#define CELL_TASK_PRIORITY 1
#define CELL_TASK_STACK_SIZE 8192
#define CELL_TASK_CORE 0
#define MQTT_RECONNECT_MIN 5000
#define MQTT_RECONNECT_MAX 300000
#define MQTT_PUB_FAIL_MAX 5
#define OTA_CHUNK_SIZE 1024
// ---------------------------------------------------------------------------
// State machine
// ---------------------------------------------------------------------------
enum class CellState : uint8_t {
OFF,
POWERING_ON,
INITIALIZING,
REGISTERING,
DATA_ACTIVATING,
MQTT_STARTING,
MQTT_CONNECTING,
CONNECTED,
RECONNECTING,
OTA_IN_PROGRESS,
ERROR
};
// ---------------------------------------------------------------------------
// Queue message types
// ---------------------------------------------------------------------------
struct MQTTCommand {
char cmd[MQTT_PAYLOAD_MAX];
};
struct MQTTResponse {
char topic[MQTT_TOPIC_MAX];
char payload[MQTT_PAYLOAD_MAX];
};
// ---------------------------------------------------------------------------
// MQTT config (loaded from SD: /remote/mqtt.cfg)
// ---------------------------------------------------------------------------
struct MQTTConfig {
char broker[80];
uint16_t port;
char username[40];
char password[40];
char deviceId[MQTT_CLIENT_ID_MAX];
};
// ---------------------------------------------------------------------------
// Telemetry snapshot
// ---------------------------------------------------------------------------
struct TelemetryData {
uint32_t uptime_secs;
uint16_t battery_mv;
uint8_t battery_pct;
int16_t temperature;
int csq;
uint8_t neighbor_count;
float freq;
float bw;
uint8_t sf;
uint8_t cr;
uint8_t tx_power;
char node_name[32];
char apn[40];
char oper[24];
bool mqtt_connected;
};
// ---------------------------------------------------------------------------
// CellularMQTT class
// ---------------------------------------------------------------------------
class CellularMQTT {
public:
void begin();
void stop();
// --- Queue API (called from main loop) ---
bool recvCommand(MQTTCommand& out);
bool sendResponse(const char* topic, const char* payload);
// --- Telemetry ---
void updateTelemetry(const TelemetryData& data);
// --- OTA ---
void requestOTA(const char* url);
bool isOTAInProgress() const { return _state == CellState::OTA_IN_PROGRESS; }
// --- State queries ---
CellState getState() const { return _state; }
bool isConnected() const { return _state == CellState::CONNECTED; }
int getCSQ() const { return _csq; }
int getSignalBars() const;
const char* getOperator() const { return _operator; }
const char* getIPAddress() const { return _ipAddr; }
const char* getBroker() const { return _config.broker; }
const char* getAPN() const { return _apn; }
const char* getRspTopic() const { return _topicRsp; }
const char* stateString() const;
uint32_t getLastCmdTime() const { return _lastCmdTime; }
static bool loadConfig(MQTTConfig& cfg);
private:
volatile CellState _state = CellState::OFF;
volatile int _csq = 99;
volatile uint32_t _lastCmdTime = 0;
char _operator[24] = {0};
char _ipAddr[20] = {0};
char _imei[20] = {0};
char _imsi[20] = {0};
char _apn[64] = {0};
MQTTConfig _config = {};
TelemetryData _telemetry = {};
SemaphoreHandle_t _telemetryMutex = nullptr;
char _topicCmd[MQTT_TOPIC_MAX] = {0};
char _topicRsp[MQTT_TOPIC_MAX] = {0};
char _topicTelem[MQTT_TOPIC_MAX] = {0};
char _topicOta[MQTT_TOPIC_MAX] = {0};
TaskHandle_t _taskHandle = nullptr;
QueueHandle_t _cmdQueue = nullptr;
QueueHandle_t _rspQueue = nullptr;
SemaphoreHandle_t _uartMutex = nullptr;
uint8_t _pubFailCount = 0;
static const int AT_BUF_SIZE = 512;
char _atBuf[AT_BUF_SIZE];
static const int URC_BUF_SIZE = 600;
char _urcBuf[URC_BUF_SIZE];
int _urcPos = 0;
enum MqttRxState { RX_IDLE, RX_WAIT_TOPIC, RX_WAIT_PAYLOAD };
MqttRxState _rxState = RX_IDLE;
int _rxTopicLen = 0;
int _rxPayloadLen = 0;
char _rxTopic[MQTT_TOPIC_MAX];
char _rxPayload[MQTT_PAYLOAD_MAX];
uint32_t _reconnectDelay = MQTT_RECONNECT_MIN;
// OTA state
volatile bool _otaPending = false;
char _otaUrl[256] = {0};
// --- Modem UART helpers ---
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);
bool waitPrompt(uint32_t timeout_ms = 5000);
void drainURCs();
void processURCLine(const char* line);
// --- Data connection ---
void resolveAPN();
bool activateData();
// --- MQTT operations ---
bool mqttStart();
bool mqttConnect();
bool mqttSubscribe(const char* topic);
bool mqttPublish(const char* topic, const char* payload);
void mqttDisconnect();
// --- URC handlers ---
void handleMqttRxStart(const char* line);
void handleMqttRxTopic(const char* data, int len);
void handleMqttRxPayload(const char* data, int len);
void handleMqttRxEnd();
void handleMqttConnLost(const char* line);
// --- OTA operations (modem task only) ---
void performOTA();
int httpGet(const char* url);
bool httpReadChunk(int offset, int len, uint8_t* dest, int* bytesRead);
void httpTerm();
void otaPublish(const char* msg);
int readRawBytes(uint8_t* dest, int count, uint32_t timeout_ms);
// --- Task ---
static void taskEntry(void* param);
void taskLoop();
};
extern CellularMQTT cellularMQTT;
#endif // CELLULAR_MQTT_H
#endif // HAS_4G_MODEM
File diff suppressed because it is too large Load Diff
+72 -25
View File
@@ -2,7 +2,14 @@
#include <Arduino.h>
#include <helpers/CommonCLI.h>
#define AUTO_OFF_MILLIS 20000 // 20 seconds
#ifdef HAS_4G_MODEM
#include "CellularMQTT.h"
#define AUTO_OFF_DISABLED true
#else
#define AUTO_OFF_DISABLED false
#endif
#define AUTO_OFF_MILLIS 20000 // 20 seconds (ignored when AUTO_OFF_DISABLED)
#define BOOT_SCREEN_MILLIS 4000 // 4 seconds
// 'meshcore', 128x13px
@@ -28,55 +35,97 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi
_node_prefs = node_prefs;
_display->turnOn();
// strip off dash and commit hash by changing dash to null terminator
// e.g: v1.2.3-abcdef -> v1.2.3
char *version = strdup(firmware_version);
char *dash = strchr(version, '-');
if(dash){
*dash = 0;
}
if (dash) *dash = 0;
// v1.2.3 (1 Jan 2025)
sprintf(_version_info, "%s (%s)", version, build_date);
snprintf(_version_info, sizeof(_version_info), "%s (%s)", version, build_date);
free(version);
}
void UITask::renderCurrScreen() {
char tmp[80];
if (millis() < BOOT_SCREEN_MILLIS) { // boot screen
// meshcore logo
if (millis() < BOOT_SCREEN_MILLIS) {
// Boot screen — logo + version
_display->setColor(DisplayDriver::BLUE);
int logoWidth = 128;
_display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
// version info
_display->setColor(DisplayDriver::LIGHT);
_display->setTextSize(1);
uint16_t versionWidth = _display->getTextWidth(_version_info);
_display->setCursor((_display->width() - versionWidth) / 2, 22);
_display->print(_version_info);
// node type
#ifdef HAS_4G_MODEM
const char* node_type = "< Remote Repeater >";
#else
const char* node_type = "< Repeater >";
#endif
uint16_t typeWidth = _display->getTextWidth(node_type);
_display->setCursor((_display->width() - typeWidth) / 2, 35);
_display->print(node_type);
} else { // home screen
// node name
} else {
// Home screen — node info + cellular status
_display->setCursor(0, 0);
_display->setTextSize(1);
_display->setColor(DisplayDriver::GREEN);
_display->print(_node_prefs->node_name);
// freq / sf
_display->setCursor(0, 20);
_display->setColor(DisplayDriver::YELLOW);
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
_display->print(tmp);
// bw / cr
_display->setCursor(0, 30);
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
_display->print(tmp);
#ifdef HAS_4G_MODEM
int y = 44;
_display->setCursor(0, y);
_display->setColor(DisplayDriver::LIGHT);
sprintf(tmp, "4G: %s", cellularMQTT.stateString());
_display->print(tmp);
y += 10;
_display->setCursor(0, y);
sprintf(tmp, "CSQ: %d (%d bars)", cellularMQTT.getCSQ(), cellularMQTT.getSignalBars());
_display->print(tmp);
y += 10;
const char* oper = cellularMQTT.getOperator();
if (oper[0]) {
_display->setCursor(0, y);
sprintf(tmp, "Op: %.16s", oper);
_display->print(tmp);
y += 10;
}
_display->setCursor(0, y);
_display->setColor(cellularMQTT.isConnected() ? DisplayDriver::GREEN : DisplayDriver::YELLOW);
sprintf(tmp, "MQTT: %s", cellularMQTT.isConnected() ? "Connected" : "---");
_display->print(tmp);
y += 10;
const char* ip = cellularMQTT.getIPAddress();
if (ip[0]) {
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(0, y);
sprintf(tmp, "IP: %s", ip);
_display->print(tmp);
y += 10;
}
uint32_t upSec = millis() / 1000;
uint32_t upH = upSec / 3600;
uint32_t upM = (upSec % 3600) / 60;
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(0, y);
sprintf(tmp, "Up: %luh %lum Heap:%dk", upH, upM, ESP.getFreeHeap() / 1024);
_display->print(tmp);
#endif
}
}
@@ -85,17 +134,15 @@ void UITask::loop() {
if (millis() >= _next_read) {
int btnState = digitalRead(PIN_USER_BTN);
if (btnState != _prevBtnState) {
if (btnState == LOW) { // pressed?
if (_display->isOn()) {
// TODO: any action ?
} else {
if (btnState == LOW) {
if (!_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
_auto_off = millis() + AUTO_OFF_MILLIS;
}
_prevBtnState = btnState;
}
_next_read = millis() + 200; // 5 reads per second
_next_read = millis() + 200;
}
#endif
@@ -105,10 +152,10 @@ void UITask::loop() {
renderCurrScreen();
_display->endFrame();
_next_refresh = millis() + 1000; // refresh every second
_next_refresh = millis() + 10000;
}
if (millis() > _auto_off) {
if (!AUTO_OFF_DISABLED && millis() > _auto_off) {
_display->turnOff();
}
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ class UITask {
unsigned long _next_read, _next_refresh, _auto_off;
int _prevBtnState;
NodePrefs* _node_prefs;
char _version_info[32];
char _version_info[48];
void renderCurrScreen();
public:
File diff suppressed because it is too large Load Diff
+110 -8
View File
@@ -1,8 +1,13 @@
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#include <time.h>
#include "MyMesh.h"
#ifdef HAS_4G_MODEM
#include <SD.h>
#include "CellularMQTT.h"
#endif
#ifdef DISPLAY_CLASS
#include "UITask.h"
static UITask ui_task(display);
@@ -23,6 +28,10 @@ static char command[160];
unsigned long lastActive = 0; // mark last active time
unsigned long nextSleepinSecs = 120; // next sleep in seconds. The first sleep (if enabled) is after 2 minutes from boot
#ifdef HAS_4G_MODEM
static bool sdCardReady = false;
#endif
void setup() {
Serial.begin(115200);
delay(1000);
@@ -83,6 +92,48 @@ void setup() {
the_mesh.begin(fs);
// ---------------------------------------------------------------------------
// SD card init — needed for CellularMQTT config (/remote/mqtt.cfg)
// SD, LoRa, and e-ink share the same SPI bus on T-Deck Pro.
// ---------------------------------------------------------------------------
#ifdef HAS_4G_MODEM
{
// Deselect all SPI devices before SD init to prevent bus contention
#ifdef SDCARD_CS
pinMode(SDCARD_CS, OUTPUT);
digitalWrite(SDCARD_CS, HIGH);
#endif
#ifdef PIN_DISPLAY_CS
pinMode(PIN_DISPLAY_CS, OUTPUT);
digitalWrite(PIN_DISPLAY_CS, HIGH);
#endif
#ifdef P_LORA_NSS
pinMode(P_LORA_NSS, OUTPUT);
digitalWrite(P_LORA_NSS, HIGH);
#endif
delay(100);
for (int i = 0; i < 3; i++) {
#ifdef SDCARD_CS
extern SPIClass displaySpi;
if (SD.begin(SDCARD_CS, displaySpi)) { sdCardReady = true; break; }
#else
if (SD.begin(SPI_CS)) { sdCardReady = true; break; }
#endif
delay(200);
}
Serial.printf("SD card: %s\n", sdCardReady ? "ready" : "FAILED");
}
// Start cellular MQTT
if (sdCardReady) {
cellularMQTT.begin();
Serial.println("Cellular MQTT starting...");
} else {
Serial.println("Cellular MQTT skipped — no SD card for config");
}
#endif
#ifdef DISPLAY_CLASS
ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION);
#endif
@@ -118,6 +169,55 @@ void loop() {
command[0] = 0; // reset command buffer
}
// ---------------------------------------------------------------------------
// MQTT → CLI bridge: process incoming commands from MQTT
// ---------------------------------------------------------------------------
#ifdef HAS_4G_MODEM
{
MQTTCommand mqttCmd;
while (cellularMQTT.recvCommand(mqttCmd)) {
// CLI command — process through the same handler as serial/LoRa admin
Serial.printf("[MQTT] CLI: %s\n", mqttCmd.cmd);
char reply[512];
reply[0] = '\0';
the_mesh.handleCommand((uint32_t)time(nullptr), mqttCmd.cmd, reply);
if (reply[0] == '\0') strcpy(reply, "OK");
cellularMQTT.sendResponse(cellularMQTT.getRspTopic(), reply);
Serial.printf("[MQTT] Reply: %.80s\n", reply);
}
}
// Periodic telemetry snapshot for MQTT publishing
{
static unsigned long lastTelemUpdate = 0;
if (millis() - lastTelemUpdate > 10000) {
NodePrefs* p = the_mesh.getNodePrefs();
TelemetryData td;
memset(&td, 0, sizeof(td));
td.uptime_secs = millis() / 1000;
td.battery_mv = board.getBattMilliVolts();
td.battery_pct = board.getBatteryPercent();
td.temperature = board.getBattTemperature();
td.csq = cellularMQTT.getCSQ();
td.freq = p->freq;
td.bw = p->bw;
td.sf = p->sf;
td.cr = p->cr;
td.tx_power = p->tx_power_dbm;
strncpy(td.node_name, p->node_name, sizeof(td.node_name) - 1);
strncpy(td.apn, cellularMQTT.getAPN(), sizeof(td.apn) - 1);
strncpy(td.oper, cellularMQTT.getOperator(), sizeof(td.oper) - 1);
td.mqtt_connected = cellularMQTT.isConnected();
td.neighbor_count = 0; // TODO: expose from MyMesh
cellularMQTT.updateTelemetry(td);
lastTelemUpdate = millis();
}
}
#endif
the_mesh.loop();
sensors.loop();
#ifdef DISPLAY_CLASS
@@ -125,14 +225,16 @@ void loop() {
#endif
rtc_clock.tick();
if (the_mesh.getNodePrefs()->powersaving_enabled && // To check if power saving is enabled
the_mesh.millisHasNowPassed(lastActive + nextSleepinSecs * 1000)) { // To check if it is time to sleep
if (!the_mesh.hasPendingWork()) { // No pending work. Safe to sleep
board.sleep(1800); // To sleep. Wake up after 30 minutes or when receiving a LoRa packet
#ifndef HAS_4G_MODEM
if (the_mesh.getNodePrefs()->powersaving_enabled &&
the_mesh.millisHasNowPassed(lastActive + nextSleepinSecs * 1000)) {
if (!the_mesh.hasPendingWork()) {
board.sleep(1800);
lastActive = millis();
nextSleepinSecs = 5; // Default: To work for 5s and sleep again
nextSleepinSecs = 5;
} else {
nextSleepinSecs += 5; // When there is pending work, to work another 5s
nextSleepinSecs += 5;
}
}
}
#endif
}
+96 -2
View File
@@ -1,7 +1,10 @@
"""
PlatformIO post-build script: merge bootloader + partitions + firmware
PlatformIO post-build script: merge bootloader + partitions + firmware + SPIFFS
into a single flashable binary.
Includes a pre-formatted empty SPIFFS image so first-boot doesn't need to
format the partition (which takes 1-2 minutes on 16MB flash).
Output: .pio/build/<env>/firmware_merged.bin
Flash: esptool.py --chip esp32s3 write_flash 0x0 firmware_merged.bin
@@ -12,6 +15,87 @@ Add to each environment (or the base section):
Import("env")
def find_spiffs_partition(partitions_bin):
"""Parse compiled partitions.bin to find SPIFFS partition offset and size.
ESP32 partition entry format (32 bytes each):
0xAA50 magic, type, subtype, offset(u32le), size(u32le), label(16), flags(u32le)
SPIFFS: type=0x01(data), subtype=0x82(spiffs)
"""
import struct
with open(partitions_bin, "rb") as f:
data = f.read()
for i in range(0, len(data) - 32, 32):
magic = struct.unpack_from("<H", data, i)[0]
if magic != 0xAA50:
continue
ptype = data[i + 2]
subtype = data[i + 3]
offset = struct.unpack_from("<I", data, i + 4)[0]
size = struct.unpack_from("<I", data, i + 8)[0]
label = data[i + 12:i + 28].split(b'\x00')[0].decode("ascii", errors="ignore")
if ptype == 0x01 and subtype == 0x82: # data/spiffs
return offset, size, label
return None, None, None
def build_spiffs_image(env, size):
"""Generate an empty formatted SPIFFS image using mkspiffs."""
import subprocess, os, tempfile, glob
build_dir = env.subst("$BUILD_DIR")
spiffs_bin = os.path.join(build_dir, "spiffs_empty.bin")
# If already generated for this build, reuse it
if os.path.isfile(spiffs_bin) and os.path.getsize(spiffs_bin) == size:
return spiffs_bin
# Find mkspiffs in PlatformIO packages
pio_home = os.path.expanduser("~/.platformio")
mkspiffs_paths = glob.glob(os.path.join(pio_home, "packages", "tool-mkspiffs*", "mkspiffs*"))
if not mkspiffs_paths:
# Also check platform-specific tool paths
mkspiffs_paths = glob.glob(os.path.join(pio_home, "packages", "tool-mklittlefs*", "mkspiffs*"))
mkspiffs = None
for p in mkspiffs_paths:
if os.path.isfile(p) and os.access(p, os.X_OK):
mkspiffs = p
break
if not mkspiffs:
print("[merge] WARNING: mkspiffs not found, skipping SPIFFS image")
return None
# Create empty data directory for mkspiffs
data_dir = os.path.join(build_dir, "_empty_spiffs_data")
os.makedirs(data_dir, exist_ok=True)
# SPIFFS block/page sizes — ESP32 Arduino defaults
block_size = 4096
page_size = 256
cmd = [
mkspiffs,
"-c", data_dir,
"-b", str(block_size),
"-p", str(page_size),
"-s", str(size),
spiffs_bin,
]
print(f"[merge] Generating empty SPIFFS image ({size // 1024} KB)...")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0 and os.path.isfile(spiffs_bin):
print(f"[merge] SPIFFS image OK: {spiffs_bin}")
return spiffs_bin
else:
print(f"[merge] mkspiffs failed: {result.stderr}")
return None
def merge_bin(source, target, env):
import subprocess, os
@@ -52,8 +136,18 @@ def merge_bin(source, target, env):
"0x10000", firmware,
]
# Try to include a pre-formatted SPIFFS image (eliminates 1-2 min first-boot format)
spiffs_offset, spiffs_size, spiffs_label = find_spiffs_partition(partitions)
if spiffs_offset and spiffs_size:
spiffs_bin = build_spiffs_image(env, spiffs_size)
if spiffs_bin:
cmd.extend([f"0x{spiffs_offset:x}", spiffs_bin])
print(f"[merge] Including SPIFFS image at 0x{spiffs_offset:x} ({spiffs_size // 1024} KB)")
else:
print("[merge] No SPIFFS partition found in partition table, skipping SPIFFS image")
print(f"\n[merge] Creating merged firmware for {env_name}...")
print(f"[merge] {' '.join(cmd[-6:])}")
print(f"[merge] {' '.join(cmd[-8:])}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
+39 -4
View File
@@ -36,7 +36,7 @@ uint32_t Dispatcher::getCADFailRetryDelay() const {
return 200;
}
uint32_t Dispatcher::getCADFailMaxDuration() const {
return 4000; // 4 seconds
return 6000; // 6 seconds
}
void Dispatcher::loop() {
@@ -52,10 +52,28 @@ void Dispatcher::loop() {
prev_isrecv_mode = is_recv;
if (!is_recv) {
radio_nonrx_start = _ms->getMillis();
} else {
rx_stuck_count = 0; // radio recovered — reset counter
}
}
if (!is_recv && _ms->getMillis() - radio_nonrx_start > 8000) { // radio has not been in Rx mode for 8 seconds!
_err_flags |= ERR_EVENT_STARTRX_TIMEOUT;
rx_stuck_count++;
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): RX stuck (attempt %d), calling onRxStuck()", getLogDateTime(), rx_stuck_count);
onRxStuck();
uint8_t reboot_threshold = getRxFailRebootThreshold();
if (reboot_threshold > 0 && rx_stuck_count >= reboot_threshold) {
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): RX unrecoverable after %d attempts", getLogDateTime(), rx_stuck_count);
onRxUnrecoverable();
}
// Reset state to give recovery the full 8s window before re-triggering
radio_nonrx_start = _ms->getMillis();
prev_isrecv_mode = true;
cad_busy_start = 0;
next_agc_reset_time = futureMillis(getAGCResetInterval());
}
if (outbound) { // waiting for outbound send to be completed
@@ -273,14 +291,31 @@ void Dispatcher::checkSend() {
outbound_start = _ms->getMillis();
bool success = _radio->startSendRaw(raw, len);
if (!success) {
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): ERROR: send start failed!", getLogDateTime());
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): ERROR: send start failed!", getLogDateTime());
logTxFail(outbound, outbound->getRawLength());
releasePacket(outbound); // return to pool
// re-queue packet for retry instead of dropping it
int retry_delay = getCADFailRetryDelay();
unsigned long retry_time = futureMillis(retry_delay);
_mgr->queueOutbound(outbound, 0, retry_time);
outbound = NULL;
next_tx_time = retry_time;
// count consecutive failures and reset radio if stuck
uint8_t threshold = getTxFailResetThreshold();
if (threshold > 0) {
tx_fail_count++;
if (tx_fail_count >= threshold) {
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): TX stuck (%d failures), resetting radio", getLogDateTime(), tx_fail_count);
onTxStuck();
tx_fail_count = 0;
next_tx_time = futureMillis(2000);
}
}
return;
}
tx_fail_count = 0; // clear counter on successful TX start
outbound_expiry = futureMillis(max_airtime);
#if MESH_PACKET_LOGGING
+10 -1
View File
@@ -122,6 +122,8 @@ class Dispatcher {
bool prev_isrecv_mode;
uint32_t n_sent_flood, n_sent_direct;
uint32_t n_recv_flood, n_recv_direct;
uint8_t tx_fail_count;
uint8_t rx_stuck_count;
void processRecvPacket(Packet* pkt);
@@ -142,6 +144,8 @@ protected:
_err_flags = 0;
radio_nonrx_start = 0;
prev_isrecv_mode = true;
tx_fail_count = 0;
rx_stuck_count = 0;
}
virtual DispatcherAction onRecvPacket(Packet* pkt) = 0;
@@ -159,6 +163,11 @@ protected:
virtual uint32_t getCADFailMaxDuration() const;
virtual int getInterferenceThreshold() const { return 0; } // disabled by default
virtual int getAGCResetInterval() const { return 0; } // disabled by default
virtual uint8_t getTxFailResetThreshold() const { return 3; } // reset radio after N consecutive TX failures; 0=disabled
virtual void onTxStuck() { _radio->resetAGC(); } // override to use doFullRadioReset() when available
virtual uint8_t getRxFailRebootThreshold() const { return 3; } // reboot after N failed RX recovery attempts; 0=disabled
virtual void onRxStuck() { _radio->resetAGC(); } // called each time RX stuck for 8s; override for deeper reset
virtual void onRxUnrecoverable() { } // called when reboot threshold exceeded; override to call _board->reboot()
public:
void begin();
@@ -188,4 +197,4 @@ private:
void checkSend();
};
}
}
+2 -2
View File
@@ -10,10 +10,10 @@
#endif
void BaseChatMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
sendFlood(pkt, delay_millis);
sendFlood(pkt, delay_millis, getPathHashSize());
}
void BaseChatMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
sendFlood(pkt, delay_millis);
sendFlood(pkt, delay_millis, getPathHashSize());
}
mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name) {
+1
View File
@@ -130,6 +130,7 @@ protected:
virtual void onContactResponse(const ContactInfo& contact, const uint8_t* data, uint8_t len) = 0;
virtual void handleReturnPathRetry(const ContactInfo& contact, const uint8_t* path, uint8_t path_len);
virtual uint8_t getPathHashSize() const = 0;
virtual void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0);
virtual void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0);
+12 -4
View File
@@ -10,6 +10,7 @@
#include <Wire.h>
#include "esp_wifi.h"
#include "driver/rtc_io.h"
#include "driver/gpio.h"
class ESP32Board : public mesh::MainBoard {
protected:
@@ -60,13 +61,20 @@ public:
#if defined(CONFIG_IDF_TARGET_ESP32S3) && defined(P_LORA_DIO_1) // Supported ESP32 variants
if (rtc_gpio_is_valid_gpio((gpio_num_t)P_LORA_DIO_1)) { // Only enter sleep mode if P_LORA_DIO_1 is RTC pin
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // To wake up when receiving a LoRa packet
esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // Wake on LoRa packet
// T5S3: Also wake on boot button press (GPIO0, active LOW).
// gpio_wakeup uses level trigger — works for light sleep only.
#if defined(LilyGo_T5S3_EPaper_Pro) && defined(PIN_USER_BTN)
gpio_wakeup_enable((gpio_num_t)PIN_USER_BTN, GPIO_INTR_LOW_LEVEL);
esp_sleep_enable_gpio_wakeup();
#endif
if (secs > 0) {
esp_sleep_enable_timer_wakeup(secs * 1000000); // To wake up every hour to do periodically jobs
esp_sleep_enable_timer_wakeup(secs * 1000000ULL); // Timer wake (microseconds)
}
esp_light_sleep_start(); // CPU enters light sleep
esp_light_sleep_start(); // CPU halts here, resumes on wake
}
#endif
}
@@ -154,4 +162,4 @@ public:
}
};
#endif
#endif
+1 -1
View File
@@ -185,7 +185,7 @@ size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) {
return 0;
}
#define BLE_WRITE_MIN_INTERVAL 60
#define BLE_WRITE_MIN_INTERVAL 30
bool SerialBLEInterface::isWriteBusy() const {
return millis() < _last_write + BLE_WRITE_MIN_INTERVAL; // still too soon to start another write?
+1 -1
View File
@@ -23,7 +23,7 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE
uint8_t buf[MAX_FRAME_SIZE];
};
#define FRAME_QUEUE_SIZE 4
#define FRAME_QUEUE_SIZE 8
int recv_queue_len;
Frame recv_queue[FRAME_QUEUE_SIZE];
int send_queue_len;
@@ -24,7 +24,7 @@
#endif
#ifndef CPU_FREQ_LOW_POWER
#define CPU_FREQ_LOW_POWER 40 // MHz — lock screen / idle standby
#define CPU_FREQ_LOW_POWER 80 // MHz — lock screen / idle standby (40 MHz breaks I2C)
#endif
#ifndef CPU_BOOST_TIMEOUT_MS
+174 -18
View File
@@ -188,9 +188,15 @@ int16_t T5S3Board::getBattTemperature() {
}
// ---- BQ27220 Design Capacity configuration ----
// Identical procedure to TDeckBoard — sets 1500 mAh for T5S3's larger cell.
// The BQ27220 ships with 3000 mAh default. This writes once on first boot
// and persists in battery-backed RAM.
// The BQ27220 ships with a 3000 mAh default. T5S3 uses a 1500 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).
//
// When DC and DE are already correct but FCC is stuck (common after initial
// flash), the root cause is Qmax Cell 0 (0x9106) and stored FCC (0x929D)
// retaining factory 3000 mAh defaults. This function detects and fixes all
// three layers: DC/DE, Qmax, and stored FCC.
bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
#if HAS_BQ27220
@@ -198,23 +204,169 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
Serial.printf("BQ27220: Design Capacity = %d mAh (target %d)\n", currentDC, designCapacity_mAh);
if (currentDC == designCapacity_mAh) {
// Design Capacity correct, but check if Full Charge Capacity is sane.
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
Serial.printf("BQ27220: Design Capacity correct, FCC=%d mAh\n", fcc);
if (fcc < designCapacity_mAh * 3 / 2) {
return true; // FCC is sane, nothing to do
Serial.printf("BQ27220: Design Capacity already correct, FCC=%d mAh\n", fcc);
if (fcc >= designCapacity_mAh * 3 / 2) {
// FCC is >=150% of design — stale from factory defaults (typically 3000 mAh).
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
Serial.printf("BQ27220: FCC %d >> DC %d, checking Design Energy (target %d mWh)\n",
fcc, designCapacity_mAh, designEnergy);
// Unseal to read data memory and issue RESET
bq27220_writeControl(0x0414); delay(2);
bq27220_writeControl(0x3672); delay(2);
// Full Access
bq27220_writeControl(0xFFFF); delay(2);
bq27220_writeControl(0xFFFF); delay(2);
// Enter CFG_UPDATE to access data memory
bq27220_writeControl(0x0090);
bool ready = false;
for (int i = 0; i < 50; i++) {
delay(20);
uint16_t opSt = bq27220_read16(BQ27220_REG_OP_STATUS);
if (opSt & 0x0400) { ready = true; break; }
}
if (ready) {
// Read Design Energy at data memory address 0x92A1
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
Wire.endTransmission();
delay(10);
uint8_t oldMSB = bq27220_read8(0x40);
uint8_t oldLSB = bq27220_read8(0x41);
uint16_t currentDE = (oldMSB << 8) | oldLSB;
if (currentDE != designEnergy) {
// Design Energy actually needs updating — write it
uint8_t oldChk = bq27220_read8(0x60);
uint8_t dLen = bq27220_read8(0x61);
uint8_t newMSB = (designEnergy >> 8) & 0xFF;
uint8_t newLSB = designEnergy & 0xFF;
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
Serial.printf("BQ27220: DE old=%d new=%d mWh, writing\n", currentDE, designEnergy);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
Wire.write(newMSB); Wire.write(newLSB);
Wire.endTransmission();
delay(5);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x60); Wire.write(newChk); Wire.write(dLen);
Wire.endTransmission();
delay(10);
// Exit with reinit since we actually changed data
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
delay(200);
Serial.println("BQ27220: Design Energy written, exited CFG_UPDATE");
} else {
// DC and DE are both correct, but FCC is stuck.
// Root cause: Qmax Cell 0 (0x9106) and stored FCC (0x929D) retain
// factory 3000 mAh defaults. Overwrite both with designCapacity_mAh.
Serial.printf("BQ27220: DE correct (%d mWh) — fixing Qmax + stored FCC\n", currentDE);
// --- Helper lambda for MAC data memory 2-byte write ---
// Reads old value + checksum, computes differential checksum, writes new value.
auto writeDM16 = [](uint16_t addr, uint16_t newVal) -> bool {
// Select address
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E);
Wire.write(addr & 0xFF);
Wire.write((addr >> 8) & 0xFF);
Wire.endTransmission();
delay(10);
uint8_t oldMSB = bq27220_read8(0x40);
uint8_t oldLSB = bq27220_read8(0x41);
uint8_t oldChk = bq27220_read8(0x60);
uint8_t dLen = bq27220_read8(0x61);
uint16_t oldVal = (oldMSB << 8) | oldLSB;
if (oldVal == newVal) {
Serial.printf("BQ27220: [0x%04X] already %d, skip\n", addr, newVal);
return true; // already correct
}
uint8_t newMSB = (newVal >> 8) & 0xFF;
uint8_t newLSB = newVal & 0xFF;
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
Serial.printf("BQ27220: [0x%04X] %d -> %d\n", addr, oldVal, newVal);
// Write new value
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E);
Wire.write(addr & 0xFF);
Wire.write((addr >> 8) & 0xFF);
Wire.write(newMSB);
Wire.write(newLSB);
Wire.endTransmission();
delay(5);
// Write checksum
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x60);
Wire.write(newChk);
Wire.write(dLen);
Wire.endTransmission();
delay(10);
return true;
};
// Overwrite Qmax Cell 0 (IT Cfg) — this is what FCC is derived from
writeDM16(0x9106, designCapacity_mAh);
// Overwrite stored FCC reference (Gas Gauging, 2 bytes before DC)
writeDM16(0x929D, designCapacity_mAh);
// Exit with reinit to apply the new values
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
delay(200);
Serial.println("BQ27220: Qmax + stored FCC updated, exited CFG_UPDATE");
}
} else {
Serial.println("BQ27220: Failed to enter CFG_UPDATE for DE check");
}
// Seal first, then issue RESET.
// RESET forces the gauge to fully reinitialize its Impedance Track
// algorithm and recalculate FCC from the current DC/DE values.
bq27220_writeControl(0x0030); // SEAL
delay(5);
Serial.println("BQ27220: Issuing RESET to force FCC recalculation...");
bq27220_writeControl(0x0041); // RESET
delay(2000); // Full reset needs generous settle time
fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
Serial.printf("BQ27220: FCC after RESET: %d mAh (target <= %d)\n", fcc, designCapacity_mAh);
if (fcc > designCapacity_mAh * 3 / 2) {
// RESET didn't fix FCC — the gauge IT algorithm is stubbornly
// retaining its learned value. This typically resolves after one
// full charge/discharge cycle. Software clamp in
// getFullChargeCapacity() ensures correct display regardless.
Serial.printf("BQ27220: FCC still stale at %d — software clamp active\n", fcc);
}
}
// FCC is stale from factory — fall through to reconfigure
Serial.printf("BQ27220: FCC %d >> DC %d, reconfiguring\n", fcc, designCapacity_mAh);
return true;
}
// Unseal
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);
// Full Access
// Step 2: Full Access
bq27220_writeControl(0xFFFF); delay(2);
bq27220_writeControl(0xFFFF); delay(2);
// Enter CFG_UPDATE
// Step 3: Enter CFG_UPDATE
bq27220_writeControl(0x0090);
bool cfgReady = false;
for (int i = 0; i < 50; i++) {
@@ -229,7 +381,7 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
return false;
}
// Write Design Capacity at 0x929F
// Step 4: Write Design Capacity at 0x929F
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0x9F); Wire.write(0x92);
Wire.endTransmission();
@@ -255,7 +407,7 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
Wire.endTransmission();
delay(10);
// Write Design Energy at 0x92A1
// Step 4a: Write Design Energy at 0x92A1
{
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
Wire.beginTransmission(BQ27220_I2C_ADDR);
@@ -271,6 +423,9 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
uint8_t deTemp = (255 - deOldChk - deOldMSB - deOldLSB);
uint8_t deNewChk = 255 - ((deTemp + deNewMSB + deNewLSB) & 0xFF);
Serial.printf("BQ27220: Design Energy: old=%d new=%d mWh\n",
(deOldMSB << 8) | deOldLSB, designEnergy);
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
Wire.write(deNewMSB); Wire.write(deNewLSB);
@@ -282,16 +437,17 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
delay(10);
}
// Exit CFG_UPDATE with reinit
// Step 5: Exit CFG_UPDATE with reinit
bq27220_writeControl(0x0091);
Serial.println("BQ27220: Sent EXIT_CFG_UPDATE_REINIT, waiting...");
delay(200);
// Seal
// Step 6: Seal
bq27220_writeControl(0x0030);
delay(5);
// Force RESET to reinitialize FCC
bq27220_writeControl(0x0041);
// Step 7: Force RESET to reinitialize FCC from new DC/DE
bq27220_writeControl(0x0041); // RESET
delay(1000);
uint16_t verifyDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
@@ -302,4 +458,4 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
#else
return false;
#endif
}
}
@@ -63,10 +63,14 @@ build_src_filter = ${esp32_base.build_src_filter}
+<../variants/LilyGo_T5S3_EPaper_Pro>
lib_deps =
${esp32_base.lib_deps}
WebServer
DNSServer
Update
; ---------------------------------------------------------------------------
; T5S3 standalone — touch UI (stub), verify display rendering
; Uses FastEPD for parallel e-ink, Adafruit GFX for drawing
; OTA enabled: WiFi AP activates only during user-initiated firmware updates.
; ---------------------------------------------------------------------------
[env:meck_t5s3_standalone]
extends = LilyGo_T5S3_EPaper_Pro
@@ -80,6 +84,7 @@ build_flags =
-D DISPLAY_CLASS=FastEPDDisplay
-D USE_EINK
-D MECK_CARDKB
-D MECK_OTA_UPDATE=1
; -D MECK_SERIF_FONT ; FreeSerif (Times New Roman-like)
; ; Default (no flag): FreeSans (Arial-like)
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
@@ -98,6 +103,7 @@ lib_deps =
; ---------------------------------------------------------------------------
; T5S3 BLE companion — touch UI, BLE phone bridging
; Connect via MeshCore iOS/Android app over Bluetooth
; OTA enabled: WiFi AP activates only during user-initiated firmware updates.
; Flash: pio run -e meck_t5s3_ble -t upload
; ---------------------------------------------------------------------------
[env:meck_t5s3_ble]
@@ -105,13 +111,14 @@ extends = LilyGo_T5S3_EPaper_Pro
build_flags =
${LilyGo_T5S3_EPaper_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=500
-D MAX_CONTACTS=510
-D MAX_GROUP_CHANNELS=20
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
-D DISPLAY_CLASS=FastEPDDisplay
-D USE_EINK
-D MECK_CARDKB
-D MECK_OTA_UPDATE=1
; -D MECK_SERIF_FONT
build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
@@ -141,6 +148,7 @@ build_flags =
-D MAX_GROUP_CHANNELS=20
-D MECK_WIFI_COMPANION=1
-D MECK_WEB_READER=1
-D MECK_OTA_UPDATE=1
-D TCP_PORT=5000
-D OFFLINE_QUEUE_SIZE=256
-D DISPLAY_CLASS=FastEPDDisplay
-35
View File
@@ -1,35 +0,0 @@
#include <Arduino.h>
#include "TDeckBoard.h"
uint32_t deviceOnline = 0x00;
void TDeckBoard::begin() {
ESP32Board::begin();
// Enable peripheral power
pinMode(PIN_PERF_POWERON, OUTPUT);
digitalWrite(PIN_PERF_POWERON, HIGH);
// Configure user button
pinMode(PIN_USER_BTN, INPUT);
// Configure LoRa Pins
pinMode(P_LORA_MISO, INPUT_PULLUP);
// pinMode(P_LORA_DIO_1, INPUT_PULLUP);
#ifdef P_LORA_TX_LED
digitalWrite(P_LORA_TX_LED, HIGH); // inverted pin for SX1276 - HIGH for off
#endif
esp_reset_reason_t reason = esp_reset_reason();
if (reason == ESP_RST_DEEPSLEEP) {
long wakeup_source = esp_sleep_get_ext1_wakeup_status();
if (wakeup_source & (1 << P_LORA_DIO_1)) {
startup_reason = BD_STARTUP_RX_PACKET; // received a LoRa packet (while in deep sleep)
}
rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS);
rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1);
}
}
-68
View File
@@ -1,68 +0,0 @@
#pragma once
#include <Wire.h>
#include <Arduino.h>
#include "helpers/ESP32Board.h"
#include <driver/rtc_io.h>
#define PIN_VBAT_READ 4
#define BATTERY_SAMPLES 8
#define ADC_MULTIPLIER (2.0f * 3.3f * 1000)
class TDeckBoard : public ESP32Board {
public:
void begin();
#ifdef P_LORA_TX_LED
void onBeforeTransmit() override{
digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED on - invert pin for SX1276
}
void onAfterTransmit() override{
digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED off - invert pin for SX1276
}
#endif
void enterDeepSleep(uint32_t secs, int pin_wake_btn) {
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
// Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep
rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY);
rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1);
rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS);
if (pin_wake_btn < 0) {
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet
} else {
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn
}
if (secs > 0) {
esp_sleep_enable_timer_wakeup(secs * 1000000);
}
// Finally set ESP32 into sleep
esp_deep_sleep_start(); // CPU halts here and never returns!
}
uint16_t getBattMilliVolts() {
#if defined(PIN_VBAT_READ) && defined(ADC_MULTIPLIER)
analogReadResolution(12);
uint32_t raw = 0;
for (int i = 0; i < BATTERY_SAMPLES; i++) {
raw += analogRead(PIN_VBAT_READ);
}
raw = raw / BATTERY_SAMPLES;
return (ADC_MULTIPLIER * raw) / 4096;
#else
return 0;
#endif
}
const char* getManufacturerName() const{
return "LilyGo T-Deck";
}
};
-115
View File
@@ -1,115 +0,0 @@
[LilyGo_TDeck]
extends = esp32_base
board = t-deck
build_flags =
${esp32_base.build_flags}
${sensor_base.build_flags}
-I variants/lilygo_tdeck
-D LILYGO_TDECK
-D BOARD_HAS_PSRAM=1
-D CORE_DEBUG_LEVEL=1
-D ARDUINO_USB_CDC_ON_BOOT=1
-D PIN_USER_BTN=0 ; Trackball button
-D PIN_PERF_POWERON=10 ; Peripheral power pin
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
-D LORA_TX_POWER=22
-D SX126X_DIO2_AS_RF_SWITCH=false
-D SX126X_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1
-D SX126X_DIO3_TCXO_VOLTAGE=1.8f
-D P_LORA_DIO_1=45 ; LORA IRQ pin
-D ENV_INCLUDE_GPS=1
-D ENV_INCLUDE_AHTX0=0
-D ENV_INCLUDE_BME280=0
-D ENV_INCLUDE_BMP280=0
-D ENV_INCLUDE_SHTC3=0
-D ENV_INCLUDE_SHT4X=0
-D ENV_INCLUDE_LPS22HB=0
-D ENV_INCLUDE_INA3221=0
-D ENV_INCLUDE_INA219=0
-D ENV_INCLUDE_INA226=0
-D ENV_INCLUDE_INA260=0
-D ENV_INCLUDE_MLX90614=0
-D ENV_INCLUDE_VL53L0X=0
-D ENV_INCLUDE_BME680=0
-D ENV_INCLUDE_BMP085=0
-D P_LORA_NSS=9 ; LORA SS pin
-D P_LORA_RESET=17 ; LORA RST pin
-D P_LORA_BUSY=13 ; LORA Busy pin
-D P_LORA_SCLK=40 ; LORA SCLK pin
-D P_LORA_MISO=38 ; LORA MISO pin
-D P_LORA_MOSI=41 ; LORA MOSI pin
-D DISPLAY_CLASS=ST7789LCDDisplay
-D DISPLAY_SCALE_X=2.5
-D DISPLAY_SCALE_Y=3.75
-D PIN_TFT_RST=-1
-D PIN_TFT_VDD_CTL=-1
-D PIN_TFT_LEDA_CTL=42
-D PIN_TFT_CS=12
-D PIN_TFT_DC=11
-D PIN_TFT_SCL=40
-D PIN_TFT_SDA=41
-D PIN_GPS_RX=43
-D PIN_GPS_TX=44
-D GPS_BAUD_RATE=38400
build_src_filter = ${esp32_base.build_src_filter}
+<../variants/lilygo_tdeck>
+<helpers/sensors/*.cpp>
lib_deps =
${esp32_base.lib_deps}
${sensor_base.lib_deps}
adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0
[env:LilyGo_TDeck_companion_radio_usb]
extends = LilyGo_TDeck
build_flags =
${LilyGo_TDeck.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.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/ST7789LCDDisplay.cpp>
lib_deps =
${LilyGo_TDeck.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:LilyGo_TDeck_companion_radio_ble]
extends = LilyGo_TDeck
build_flags =
${LilyGo_TDeck.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
build_src_filter = ${LilyGo_TDeck.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/ST7789LCDDisplay.cpp>
lib_deps =
${LilyGo_TDeck.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:LilyGo_TDeck_repeater]
extends = LilyGo_TDeck
build_flags =
${LilyGo_TDeck.build_flags}
-D ADVERT_NAME='"TDeck Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
build_src_filter = ${LilyGo_TDeck.build_src_filter}
+<../examples/simple_repeater>
+<helpers/ui/ST7789LCDDisplay.cpp>
lib_deps =
${LilyGo_TDeck.lib_deps}
${esp32_ota.lib_deps}
-55
View File
@@ -1,55 +0,0 @@
#include <Arduino.h>
#include "target.h"
TDeckBoard board;
#if defined(P_LORA_SCLK)
static SPIClass spi;
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi);
#else
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY);
#endif
WRAPPER_CLASS radio_driver(radio, board);
ESP32RTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
MicroNMEALocationProvider gps(Serial1, &rtc_clock);
EnvironmentSensorManager sensors(gps);
#ifdef DISPLAY_CLASS
DISPLAY_CLASS display;
MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
#endif
bool radio_init() {
fallback_clock.begin();
rtc_clock.begin(Wire);
Wire.begin(18, 8);
#if defined(P_LORA_SCLK)
return radio.std_init(&spi);
#else
return radio.std_init();
#endif
}
uint32_t radio_get_rng_seed() {
return radio.random(0x7FFFFFFF);
}
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
radio.setFrequency(freq);
radio.setSpreadingFactor(sf);
radio.setBandwidth(bw);
radio.setCodingRate(cr);
}
void radio_set_tx_power(uint8_t dbm) {
radio.setOutputPower(dbm);
}
mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
-31
View File
@@ -1,31 +0,0 @@
#pragma once
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <TDeckBoard.h>
#include <helpers/AutoDiscoverRTCClock.h>
#include <helpers/SensorManager.h>
#ifdef DISPLAY_CLASS
#include <helpers/ui/ST7789LCDDisplay.h>
#include <helpers/ui/MomentaryButton.h>
#endif
#include "helpers/sensors/EnvironmentSensorManager.h"
#include "helpers/sensors/MicroNMEALocationProvider.h"
extern TDeckBoard board;
extern WRAPPER_CLASS radio_driver;
extern AutoDiscoverRTCClock rtc_clock;
extern EnvironmentSensorManager sensors;
#ifdef DISPLAY_CLASS
extern DISPLAY_CLASS display;
extern MomentaryButton user_btn;
#endif
bool radio_init();
uint32_t radio_get_rng_seed();
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr);
void radio_set_tx_power(uint8_t dbm);
mesh::LocalIdentity radio_new_identity();
+1 -1
View File
@@ -24,7 +24,7 @@
#endif
#ifndef CPU_FREQ_LOW_POWER
#define CPU_FREQ_LOW_POWER 40 // MHz — lock screen / idle standby
#define CPU_FREQ_LOW_POWER 80 // MHz — lock screen / idle standby (40 MHz breaks I2C)
#endif
#ifndef CPU_BOOST_TIMEOUT_MS
+78 -15
View File
@@ -21,7 +21,9 @@
#define KB_KEY_BACKSPACE '\b'
#define KB_KEY_ENTER '\r'
#define KB_KEY_SPACE ' '
#define KB_KEY_EMOJI 0x01 // Non-printable code for $ key (emoji picker)
#define KB_KEY_EMOJI 0x01 // Non-printable code for $ key (emoji picker)
#define KB_KEY_MIC 0x02 // Mic key press (PTT start / voice screen open)
#define KB_KEY_MIC_RELEASE 0x03 // Mic key release (PTT stop)
class TCA8418Keyboard {
private:
@@ -34,7 +36,10 @@ private:
bool _shiftUsedWhileHeld; // Was shift consumed by any key while held
bool _altActive; // Sticky alt (one-shot)
bool _symActive; // Sticky sym (one-shot)
bool _micHeld; // Mic key physically held down (for PTT release detection)
unsigned long _lastShiftTime; // For Shift+key combos
bool _enterHeld; // Enter key physically held down
unsigned long _enterPressTime; // millis() when Enter was pressed
uint8_t readReg(uint8_t reg) {
_wire->beginTransmission(_addr);
@@ -151,7 +156,8 @@ private:
public:
TCA8418Keyboard(uint8_t addr = 0x34, TwoWire* wire = &Wire)
: _addr(addr), _wire(wire), _initialized(false),
_shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _lastShiftTime(0) {}
_shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _micHeld(false), _lastShiftTime(0),
_enterHeld(false), _enterPressTime(0) {}
bool begin() {
// Check if device responds
@@ -161,24 +167,47 @@ public:
return false;
}
// Configure keyboard matrix (8 rows x 10 cols)
// --- Warm-reboot safe init sequence ---
// The TCA8418 stays powered across ESP32 resets (no dedicated RST pin),
// so the scanner may still be active from the previous session.
// We must disable it before reconfiguring the matrix.
// 1. Disable scanner — stop all scanning before touching config
writeReg(TCA8418_REG_CFG, 0x00);
// 2. Drain any stale events from the previous session
for (int i = 0; i < 16; i++) {
if ((readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) == 0) break;
readReg(TCA8418_REG_KEY_EVENT_A);
}
writeReg(TCA8418_REG_INT_STAT, 0x1F); // Clear all interrupt flags
// 3. Explicitly clear GPI event masks (prevent phantom GPI events)
writeReg(TCA8418_REG_GPI_EM1, 0x00);
writeReg(TCA8418_REG_GPI_EM2, 0x00);
writeReg(TCA8418_REG_GPI_EM3, 0x00);
// 4. Configure keyboard matrix (8 rows x 10 cols)
writeReg(TCA8418_REG_KP_GPIO1, 0xFF); // Rows 0-7 as keypad
writeReg(TCA8418_REG_KP_GPIO2, 0xFF); // Cols 0-7 as keypad
writeReg(TCA8418_REG_KP_GPIO3, 0x03); // Cols 8-9 as keypad
// Enable keypad with FIFO overflow detection
writeReg(TCA8418_REG_CFG, 0x11); // KE_IEN + INT_CFG
// Set debounce
// 5. Set debounce
writeReg(TCA8418_REG_DEBOUNCE, 0x03);
// Clear any pending interrupts
// 6. Final pre-enable cleanup
writeReg(TCA8418_REG_INT_STAT, 0x1F);
// Flush the FIFO
while (readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) {
// 7. Enable scanner — matrix config is stable, safe to start scanning
writeReg(TCA8418_REG_CFG, 0x11); // KE_IEN + INT_CFG
// 8. Let scanner stabilise, then flush any spurious first-scan events
delay(5);
for (int i = 0; i < 16; i++) {
if ((readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) == 0) break;
readReg(TCA8418_REG_KEY_EVENT_A);
}
writeReg(TCA8418_REG_INT_STAT, 0x1F);
_initialized = true;
Serial.println("TCA8418: Keyboard initialized OK");
@@ -219,7 +248,22 @@ public:
return 0;
}
// Track mic key release — return KB_KEY_MIC_RELEASE for PTT stop
if (!pressed && keyCode == 34) {
if (_micHeld) {
_micHeld = false;
Serial.println("KB: Mic released -> KB_KEY_MIC_RELEASE");
return KB_KEY_MIC_RELEASE;
}
return 0;
}
// Only act on key press, not release
// (Enter release tracked for long-press detection)
if (!pressed && keyCode == 21) {
_enterHeld = false;
return 0;
}
if (!pressed || keyCode == 0) {
return 0;
}
@@ -243,6 +287,13 @@ public:
Serial.println("KB: Sym activated");
return 0;
}
// Track Enter press for long-press detection
if (keyCode == 21) {
_enterHeld = true;
_enterPressTime = millis();
// Fall through to normal processing — '\r' is returned below
}
// Handle dedicated $ key (key code 22, next to M)
// Bare press = emoji picker, Sym+$ = literal '$'
@@ -256,12 +307,17 @@ public:
return KB_KEY_EMOJI;
}
// Handle Mic key - always produces '0' (silk-screened on key)
// Sym+Mic also produces '0' (consumes sym so it doesn't leak)
// Handle Mic key — bare press returns KB_KEY_MIC for PTT / voice screen
// Sym+Mic produces '0' (silk-screened on key) for text input
if (keyCode == 34) {
_symActive = false;
Serial.println("KB: Mic -> '0'");
return '0';
if (_symActive) {
_symActive = false;
Serial.println("KB: Sym+Mic -> '0'");
return '0';
}
_micHeld = true;
Serial.println("KB: Mic -> KB_KEY_MIC");
return KB_KEY_MIC;
}
// Get the character
@@ -315,6 +371,7 @@ public:
}
bool isReady() const { return _initialized; }
bool isMicHeld() const { return _micHeld; }
// Check if shift was pressed within the last N milliseconds
bool wasShiftRecentlyPressed(unsigned long withinMs = 500) const {
@@ -326,4 +383,10 @@ public:
bool wasShiftConsumed() const {
return _shiftConsumed;
}
// Enter long-press detection
bool isEnterHeld() const { return _enterHeld; }
unsigned long enterHeldMs() const {
return _enterHeld ? (millis() - _enterPressTime) : 0;
}
};
+53 -7
View File
@@ -96,6 +96,9 @@ lib_deps =
zinggjm/GxEPD2@^1.5.9
adafruit/Adafruit GFX Library@^1.11.0
bitbank2/PNGdec@^1.0.1
WebServer
DNSServer
Update
; ---------------------------------------------------------------------------
; Meck unified builds — one codebase, six variants via build flags
@@ -108,12 +111,13 @@ extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=500
-D MAX_CONTACTS=510
-D MAX_GROUP_CHANNELS=20
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
-D MECK_AUDIO_VARIANT
-D MECK_WEB_READER=1
-D MECK_OTA_UPDATE=1
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -125,6 +129,7 @@ lib_deps =
densaugeo/base64 @ ~1.4.0
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
https://github.com/sh123/esp32_codec2_arduino.git
; Audio + WiFi companion (audio-player hardware with WiFi app bridging)
; No BLE — WiFi companion uses SerialWifiInterface (TCP socket on port 5000).
@@ -146,7 +151,8 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=256
-D MECK_AUDIO_VARIANT
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.2.WiFi"'
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.6.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -158,9 +164,11 @@ lib_deps =
densaugeo/base64 @ ~1.4.0
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
https://github.com/sh123/esp32_codec2_arduino.git
; Audio standalone (audio-player hardware, no BLE/WiFi — maximum battery life)
; No MECK_WEB_READER: WiFi power draw conflicts with zero-radio-power design.
; OTA enabled: WiFi AP activates only during user-initiated firmware updates.
; Contacts and sort arrays allocated in PSRAM — 1500 contacts uses ~290KB of 8MB.
[env:meck_audio_standalone]
extends = LilyGo_TDeck_Pro
@@ -171,6 +179,7 @@ build_flags =
-D MAX_GROUP_CHANNELS=20
-D OFFLINE_QUEUE_SIZE=1
-D MECK_AUDIO_VARIANT
-D MECK_OTA_UPDATE=1
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -182,6 +191,7 @@ lib_deps =
densaugeo/base64 @ ~1.4.0
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
https://github.com/sh123/esp32_codec2_arduino.git
; 4G + BLE companion (4G modem hardware, no audio — GPIO conflict with PCM5102A)
; MAX_CONTACTS=500 is near BLE protocol ceiling (MAX_CONTACTS/2 sent as uint8_t, max 510)
@@ -190,13 +200,14 @@ extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=500
-D MAX_CONTACTS=510
-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 v1.2.4G"'
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.6.4G"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -226,7 +237,8 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.2.4G.WiFi"'
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.6.4G.WiFi"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -252,7 +264,8 @@ build_flags =
-D OFFLINE_QUEUE_SIZE=1
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=1
-D FIRMWARE_VERSION='"Meck v1.2.4G.SA"'
-D MECK_OTA_UPDATE=1
-D FIRMWARE_VERSION='"Meck v1.6.4G.SA"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -261,4 +274,37 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
densaugeo/base64 @ ~1.4.0
; ---------------------------------------------------------------------------
; Remote Repeater (T-Deck Pro 4G, cellular MQTT remote management)
;
; MeshCore repeater firmware + A7682E cellular MQTT for remote admin.
; No BLE, no SMS/calls, no companion protocol. All management via MQTT
; or USB serial CLI.
;
; SD card config required: /remote/mqtt.cfg (broker, port, user, pass)
; Optional: /remote/apn.cfg (APN override)
;
; Add this block to the bottom of platformio.ini
; Flash with: pio run -e meck_remote_repeater
; ---------------------------------------------------------------------------
[env:meck_remote_repeater]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/simple_repeater
-D ADMIN_PASSWORD='"admin"'
-D HAS_4G_MODEM=1
-D DISABLE_WIFI_OTA=1
-D MECK_REMOTE_REPEATER=1
-D MAX_NEIGHBOURS=16
-D FIRMWARE_VERSION='"Meck RemRptr v0.1"'
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<helpers/ui/GxEPDDisplay.cpp>
+<../examples/simple_repeater/*.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}