mirror of
https://github.com/pelgraine/Meck.git
synced 2026-05-09 14:55:02 +02:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60ec294ee6 | |||
| 5497950892 | |||
| c687133b05 | |||
| c7d0449181 | |||
| 9ddb692806 | |||
| 0cab2ddfa7 | |||
| d07ad71d5d | |||
| b4983e48f0 | |||
| b991eb0fe7 | |||
| c15b30079c | |||
| 9d7cbd4866 | |||
| b9283af7fc | |||
| 39cd30890b | |||
| 902577ed10 | |||
| ce93cfa033 | |||
| 2be399f65a | |||
| 5679cda38e | |||
| 1ea883783c | |||
| bf8cf32bc2 | |||
| 465a29bb23 | |||
| 81eca29b69 | |||
| 342cf4e745 | |||
| c52a190ace | |||
| a7bc7a4733 | |||
| 47a0d2cc95 | |||
| 5dda0b686e | |||
| 60dcd6a89e |
@@ -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)
|
||||
|
||||
@@ -33,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)
|
||||
@@ -49,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)
|
||||
@@ -138,7 +148,7 @@ If you're loading firmware from an SD card via the LilyGo Launcher firmware, use
|
||||
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 → Firmware Update → Enter** (T-Deck Pro) or **tap** (T5S3)
|
||||
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**
|
||||
@@ -148,6 +158,8 @@ The partition layout supports dual OTA slots — the old firmware remains on the
|
||||
|
||||
> **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+)
|
||||
@@ -184,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
|
||||
|
||||
@@ -206,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) |
|
||||
@@ -277,12 +292,16 @@ 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 5–10 seconds for confirmation popup) |
|
||||
| R | Import contacts from SD card (wait 5–10 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.
|
||||
@@ -348,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) |
|
||||
@@ -426,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 (1–5) 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.
|
||||
@@ -436,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.
|
||||
@@ -530,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.
|
||||
@@ -662,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.
|
||||
@@ -754,12 +973,16 @@ There are a number of fairly major features in the pipeline, with no particular
|
||||
- [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
|
||||
- [ ] Figure out a way to silence the ringtone
|
||||
- [ ] Figure out a way to customise the ringtone
|
||||
- [ ] Customised user option for larger-font mode
|
||||
|
||||
**T5S3 E-Paper Pro:**
|
||||
- [X] Core port: display, touch input, LoRa, battery, RTC
|
||||
@@ -780,8 +1003,8 @@ There are a number of fairly major features in the pipeline, with no particular
|
||||
- [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
|
||||
- [ ] Customised user option for larger-font mode
|
||||
|
||||
## 📞 Get Support
|
||||
|
||||
|
||||
@@ -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 1–13 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 1–10 (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 1–10 (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.
|
||||
|
||||
@@ -268,10 +268,26 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
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;
|
||||
@@ -324,6 +340,10 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -560,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) {
|
||||
@@ -582,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);
|
||||
}
|
||||
|
||||
@@ -736,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)) {
|
||||
@@ -831,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) {
|
||||
@@ -1001,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);
|
||||
}
|
||||
|
||||
@@ -1085,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);
|
||||
@@ -1490,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);
|
||||
}
|
||||
@@ -2255,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",
|
||||
@@ -2315,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);
|
||||
@@ -2710,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 ||
|
||||
@@ -2807,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:");
|
||||
@@ -3031,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
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
#define FIRMWARE_VER_CODE 10
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "23 March 2026"
|
||||
#define FIRMWARE_BUILD_DATE "31 March 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v1.3"
|
||||
#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
|
||||
|
||||
@@ -39,4 +39,41 @@ struct NodePrefs { // persisted to file
|
||||
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
|
||||
}
|
||||
};
|
||||
+1159
-128
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),
|
||||
|
||||
@@ -637,8 +637,8 @@ public:
|
||||
}
|
||||
|
||||
// Render inbox list
|
||||
display.setTextSize(0);
|
||||
int lineH = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineH = the_mesh.getNodePrefs()->smallLineH();
|
||||
int headerH = 14;
|
||||
int footerH = 14;
|
||||
int maxY = display.height() - footerH;
|
||||
@@ -672,7 +672,7 @@ public:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineH);
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
@@ -745,8 +745,8 @@ public:
|
||||
|
||||
// --- 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();
|
||||
@@ -942,7 +942,7 @@ 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);
|
||||
if (_viewChannelIdx == 0xFF) {
|
||||
@@ -975,8 +975,8 @@ public:
|
||||
// =================================================================
|
||||
// DM Inbox: list of contacts/rooms you have DM history with
|
||||
// =================================================================
|
||||
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;
|
||||
@@ -1056,7 +1056,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 {
|
||||
@@ -1094,8 +1094,8 @@ public:
|
||||
}
|
||||
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
|
||||
@@ -1163,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
|
||||
}
|
||||
|
||||
@@ -1324,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
|
||||
}
|
||||
}
|
||||
@@ -1646,7 +1646,26 @@ public:
|
||||
}
|
||||
}
|
||||
} else if (_viewChannelIdx > 0) {
|
||||
_viewChannelIdx--;
|
||||
// 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;
|
||||
@@ -1667,11 +1686,17 @@ public:
|
||||
// DM tab → wrap to channel 0
|
||||
_viewChannelIdx = 0;
|
||||
} else {
|
||||
ChannelDetails ch;
|
||||
uint8_t nextIdx = _viewChannelIdx + 1;
|
||||
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
|
||||
_viewChannelIdx = nextIdx;
|
||||
} 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;
|
||||
|
||||
@@ -43,6 +43,10 @@ private:
|
||||
// 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) {
|
||||
@@ -133,16 +137,30 @@ 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
|
||||
}
|
||||
|
||||
@@ -158,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;
|
||||
|
||||
@@ -219,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
|
||||
@@ -235,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;
|
||||
@@ -268,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) {
|
||||
@@ -275,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 {
|
||||
@@ -285,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));
|
||||
@@ -300,10 +380,19 @@ 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];
|
||||
@@ -343,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
|
||||
@@ -378,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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -777,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) {
|
||||
@@ -862,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
|
||||
@@ -1025,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
|
||||
@@ -1033,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);
|
||||
}
|
||||
|
||||
@@ -1071,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);
|
||||
|
||||
@@ -1166,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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include <SD.h>
|
||||
#endif
|
||||
#include <WebServer.h>
|
||||
#include <DNSServer.h>
|
||||
#include <Update.h>
|
||||
#include <esp_ota_ops.h>
|
||||
#endif
|
||||
@@ -112,6 +113,7 @@ enum SettingsRowType : uint8_t {
|
||||
ROW_UTC_OFFSET, // UTC offset (-12 to +14)
|
||||
ROW_MSG_NOTIFY, // Keyboard flash on new msg toggle
|
||||
ROW_DARK_MODE, // Dark mode toggle (inverted display)
|
||||
ROW_LARGE_FONT, // Font size toggle: 0=tiny (default), 1=larger
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
ROW_PORTRAIT_MODE, // Portrait orientation toggle
|
||||
#endif
|
||||
@@ -142,7 +144,9 @@ enum SettingsRowType : uint8_t {
|
||||
ROW_ADD_CHANNEL, // "+ Add Hashtag Channel"
|
||||
ROW_INFO_HEADER, // "--- Info ---" separator
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
ROW_OTA_TOOLS_SUBMENU, // Folder row → enters OTA Tools sub-screen
|
||||
ROW_FW_UPDATE, // "Firmware Update" — WiFi upload + flash
|
||||
ROW_SD_FILE_MGR, // "SD File Manager" — WiFi file browser
|
||||
#endif
|
||||
ROW_PUB_KEY, // Public key display
|
||||
ROW_FIRMWARE, // Firmware version
|
||||
@@ -167,6 +171,7 @@ enum EditMode : uint8_t {
|
||||
#endif
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
EDIT_OTA, // OTA firmware update flow (multi-phase overlay)
|
||||
EDIT_FILEMGR, // SD file manager flow (WiFi file browser)
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -177,6 +182,9 @@ enum SubScreen : uint8_t {
|
||||
SUB_NONE, // Top-level settings list
|
||||
SUB_CONTACTS, // Contacts settings sub-screen
|
||||
SUB_CHANNELS, // Channels management sub-screen
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
SUB_OTA_TOOLS, // OTA Tools sub-screen (FW update + File Manager)
|
||||
#endif
|
||||
};
|
||||
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
@@ -191,6 +199,13 @@ enum OtaPhase : uint8_t {
|
||||
OTA_PHASE_DONE, // Success, rebooting
|
||||
OTA_PHASE_ERROR, // Error with message
|
||||
};
|
||||
|
||||
// File manager phases
|
||||
enum FmPhase : uint8_t {
|
||||
FM_PHASE_CONFIRM, // "Start SD file manager? Enter:Yes Q:No"
|
||||
FM_PHASE_WAITING, // AP up, file browser active
|
||||
FM_PHASE_ERROR, // Error with message
|
||||
};
|
||||
#endif
|
||||
|
||||
// Max rows in the settings list (increased for contact sub-toggles + WiFi)
|
||||
@@ -242,6 +257,9 @@ private:
|
||||
// Dirty flag for radio params  prompt to apply
|
||||
bool _radioChanged;
|
||||
|
||||
// T5S3: signal UITask to open VKB when entering text edit mode
|
||||
bool _needsTextVKB;
|
||||
|
||||
// 4G modem state (runtime cache of config)
|
||||
#ifdef HAS_4G_MODEM
|
||||
bool _modemEnabled;
|
||||
@@ -277,6 +295,10 @@ private:
|
||||
bool _otaUploadOk;
|
||||
char _otaApName[24];
|
||||
const char* _otaError;
|
||||
// File manager state
|
||||
FmPhase _fmPhase;
|
||||
const char* _fmError;
|
||||
DNSServer* _dnsServer;
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -349,15 +371,21 @@ private:
|
||||
}
|
||||
} else if (_subScreen == SUB_CHANNELS) {
|
||||
// --- Channels sub-screen: only channel-related rows ---
|
||||
// Scan ALL slots — companion app may write non-contiguously, and
|
||||
// gaps can appear after channel deletion if compaction is incomplete.
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
addRow(ROW_CHANNEL, i);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
addRow(ROW_ADD_CHANNEL);
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
} else if (_subScreen == SUB_OTA_TOOLS) {
|
||||
// --- OTA Tools sub-screen ---
|
||||
addRow(ROW_FW_UPDATE);
|
||||
addRow(ROW_SD_FILE_MGR);
|
||||
#endif
|
||||
} else {
|
||||
// --- Top-level settings list ---
|
||||
addRow(ROW_NAME);
|
||||
@@ -372,6 +400,7 @@ private:
|
||||
addRow(ROW_GPS_BAUD);
|
||||
addRow(ROW_PATH_HASH_SIZE);
|
||||
addRow(ROW_DARK_MODE);
|
||||
addRow(ROW_LARGE_FONT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
addRow(ROW_PORTRAIT_MODE);
|
||||
#endif
|
||||
@@ -389,12 +418,12 @@ private:
|
||||
// Folder rows for sub-screens
|
||||
addRow(ROW_CONTACTS_SUBMENU);
|
||||
addRow(ROW_CHANNELS_SUBMENU);
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
addRow(ROW_OTA_TOOLS_SUBMENU);
|
||||
#endif
|
||||
|
||||
// Info section (stays at top level)
|
||||
addRow(ROW_INFO_HEADER);
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
addRow(ROW_FW_UPDATE);
|
||||
#endif
|
||||
addRow(ROW_PUB_KEY);
|
||||
addRow(ROW_FIRMWARE);
|
||||
|
||||
@@ -501,14 +530,12 @@ private:
|
||||
ChannelDetails empty;
|
||||
memset(&empty, 0, sizeof(empty));
|
||||
|
||||
// Find total channel count
|
||||
// Find highest used channel slot (scan all — gaps may exist)
|
||||
int total = 0;
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
total = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,7 +572,7 @@ public:
|
||||
_editMode(EDIT_NONE), _editPos(0), _editPickerIdx(0),
|
||||
_editFloat(0), _editInt(0), _confirmAction(0),
|
||||
_onboarding(false), _subScreen(SUB_NONE), _savedTopCursor(0),
|
||||
_radioChanged(false) {
|
||||
_radioChanged(false), _needsTextVKB(false) {
|
||||
memset(_editBuf, 0, sizeof(_editBuf));
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
_otaServer = nullptr;
|
||||
@@ -553,6 +580,9 @@ public:
|
||||
_otaBytesReceived = 0;
|
||||
_otaUploadOk = false;
|
||||
_otaError = nullptr;
|
||||
_fmPhase = FM_PHASE_CONFIRM;
|
||||
_fmError = nullptr;
|
||||
_dnsServer = nullptr;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -603,13 +633,13 @@ public:
|
||||
// and move cursor there. Returns: 0=miss, 1=moved to new row, 2=tapped current row.
|
||||
int selectRowAtVY(int vy) {
|
||||
if (_editMode != EDIT_NONE) return 0; // Don't change cursor while editing
|
||||
const int headerH = 14, footerH = 14, lineH = 9;
|
||||
// T-Deck Pro render offsets fillRect by +5 (GxEPD baseline compensation),
|
||||
// so visual rows start 5 units below headerH. T5S3 renders at y directly.
|
||||
const int headerH = 14, footerH = 14, lineH = _prefs->smallLineH();
|
||||
// bodyTop must match where the visual rows start (highlight bar position).
|
||||
// T5S3 renders highlight at y directly. T-Deck Pro offsets by smallHighlightOff().
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = headerH;
|
||||
#else
|
||||
const int bodyTop = headerH + 5;
|
||||
const int bodyTop = headerH + _prefs->smallHighlightOff();
|
||||
#endif
|
||||
if (vy < bodyTop || vy >= 128 - footerH) return 0; // Outside body area
|
||||
|
||||
@@ -740,6 +770,19 @@ public:
|
||||
|
||||
#endif
|
||||
|
||||
// T5S3 VKB integration for text editing (channel name, device name, freq, APN)
|
||||
bool needsTextVKB() const { return _needsTextVKB; }
|
||||
void clearTextNeedsVKB() { _needsTextVKB = false; }
|
||||
const char* getEditBuf() const { return _editBuf; }
|
||||
SettingsRowType getCurrentRowType() const { return _rows[_cursor].type; }
|
||||
void submitEditText(const char* text) {
|
||||
strncpy(_editBuf, text, SETTINGS_TEXT_BUF - 1);
|
||||
_editBuf[SETTINGS_TEXT_BUF - 1] = '\0';
|
||||
_editPos = strlen(_editBuf);
|
||||
// Simulate Enter to confirm the edit through the normal path
|
||||
handleInput('\r');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OTA firmware update
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -963,7 +1006,7 @@ public:
|
||||
display.fillRect(2, 14, display.width() - 4, display.height() - 28);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(2, 14, display.width() - 4, display.height() - 28);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.drawTextCentered(display.width() / 2, 22, "Flashing Firmware");
|
||||
snprintf(tmp, sizeof(tmp), "%d / %d KB", (int)(totalWritten / 1024), (int)(fileSize / 1024));
|
||||
display.drawTextCentered(display.width() / 2, 42, tmp);
|
||||
@@ -987,10 +1030,18 @@ public:
|
||||
return true;
|
||||
}
|
||||
|
||||
// Called from render loop AND main loop to poll the web server
|
||||
// Called from render loop AND main loop to poll the web server.
|
||||
// Handles both OTA firmware upload and SD file manager modes.
|
||||
void pollOTAServer() {
|
||||
if (_otaServer && (_otaPhase == OTA_PHASE_WAITING || _otaPhase == OTA_PHASE_RECEIVING)) {
|
||||
_otaServer->handleClient();
|
||||
if (_otaServer) {
|
||||
if ((_editMode == EDIT_OTA && (_otaPhase == OTA_PHASE_WAITING || _otaPhase == OTA_PHASE_RECEIVING)) ||
|
||||
(_editMode == EDIT_FILEMGR && _fmPhase == FM_PHASE_WAITING)) {
|
||||
_otaServer->handleClient();
|
||||
}
|
||||
}
|
||||
// Process DNS for captive portal redirect (file manager only)
|
||||
if (_dnsServer && _editMode == EDIT_FILEMGR && _fmPhase == FM_PHASE_WAITING) {
|
||||
_dnsServer->processNextRequest();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1037,7 +1088,7 @@ public:
|
||||
display.fillRect(2, 14, display.width() - 4, display.height() - 28);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(2, 14, display.width() - 4, display.height() - 28);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(display.width() / 2, 30, "Update Complete!");
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -1057,6 +1108,443 @@ public:
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SD File Manager — WiFi file browser, upload, download, delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void startFileMgr() {
|
||||
_editMode = EDIT_FILEMGR;
|
||||
_fmPhase = FM_PHASE_CONFIRM;
|
||||
_fmError = nullptr;
|
||||
}
|
||||
|
||||
void startFileMgrServer() {
|
||||
// Build AP name with last 4 of MAC for uniqueness
|
||||
uint8_t mac[6];
|
||||
WiFi.macAddress(mac);
|
||||
snprintf(_otaApName, sizeof(_otaApName), "Meck-Files-%02X%02X", mac[4], mac[5]);
|
||||
|
||||
// Pause LoRa radio — SD and LoRa share the same SPI bus on both
|
||||
// platforms. Incoming packets during SD writes cause bus contention.
|
||||
extern void otaPauseRadio();
|
||||
otaPauseRadio();
|
||||
|
||||
// Clean WiFi init from any state
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
delay(200);
|
||||
WiFi.mode(WIFI_AP);
|
||||
WiFi.softAP(_otaApName);
|
||||
delay(500);
|
||||
Serial.printf("FM: AP '%s' started, IP: %s\n",
|
||||
_otaApName, WiFi.softAPIP().toString().c_str());
|
||||
|
||||
// Start DNS server — redirect ALL DNS lookups to our AP IP.
|
||||
// This triggers captive portal detection on phones, which opens the
|
||||
// page in a real browser instead of the restricted captive webview.
|
||||
if (_dnsServer) { delete _dnsServer; }
|
||||
_dnsServer = new DNSServer();
|
||||
_dnsServer->start(53, "*", WiFi.softAPIP());
|
||||
Serial.println("FM: DNS captive portal started");
|
||||
|
||||
// Start web server
|
||||
if (_otaServer) { _otaServer->stop(); delete _otaServer; }
|
||||
_otaServer = new WebServer(80);
|
||||
|
||||
// --- Captive portal detection handlers ---
|
||||
// Phones/OS probe these URLs to detect captive portals. Redirecting
|
||||
// them to our page causes the OS to open a real browser.
|
||||
// iOS / macOS
|
||||
_otaServer->on("/hotspot-detect.html", HTTP_GET, [this]() {
|
||||
Serial.println("FM: captive probe (Apple)");
|
||||
_otaServer->sendHeader("Location", "http://192.168.4.1/");
|
||||
_otaServer->send(302, "text/plain", "");
|
||||
});
|
||||
// Android
|
||||
_otaServer->on("/generate_204", HTTP_GET, [this]() {
|
||||
Serial.println("FM: captive probe (Android)");
|
||||
_otaServer->sendHeader("Location", "http://192.168.4.1/");
|
||||
_otaServer->send(302, "text/plain", "");
|
||||
});
|
||||
_otaServer->on("/gen_204", HTTP_GET, [this]() {
|
||||
_otaServer->sendHeader("Location", "http://192.168.4.1/");
|
||||
_otaServer->send(302, "text/plain", "");
|
||||
});
|
||||
// Windows
|
||||
_otaServer->on("/connecttest.txt", HTTP_GET, [this]() {
|
||||
_otaServer->sendHeader("Location", "http://192.168.4.1/");
|
||||
_otaServer->send(302, "text/plain", "");
|
||||
});
|
||||
_otaServer->on("/redirect", HTTP_GET, [this]() {
|
||||
_otaServer->sendHeader("Location", "http://192.168.4.1/");
|
||||
_otaServer->send(302, "text/plain", "");
|
||||
});
|
||||
// Firefox
|
||||
_otaServer->on("/canonical.html", HTTP_GET, [this]() {
|
||||
_otaServer->sendHeader("Location", "http://192.168.4.1/");
|
||||
_otaServer->send(302, "text/plain", "");
|
||||
});
|
||||
_otaServer->on("/success.txt", HTTP_GET, [this]() {
|
||||
_otaServer->send(200, "text/plain", "success");
|
||||
});
|
||||
|
||||
// --- Main page: server-rendered directory listing (no JS needed) ---
|
||||
_otaServer->on("/", HTTP_GET, [this]() {
|
||||
String path = _otaServer->arg("path");
|
||||
if (path.isEmpty()) path = "/";
|
||||
String msg = _otaServer->arg("msg");
|
||||
Serial.printf("FM: page request path='%s'\n", path.c_str());
|
||||
String html = fmBuildPage(path, msg);
|
||||
_otaServer->send(200, "text/html", html);
|
||||
});
|
||||
|
||||
// --- File download: GET /dl?path=/file.txt ---
|
||||
_otaServer->on("/dl", HTTP_GET, [this]() {
|
||||
String path = _otaServer->arg("path");
|
||||
File f = SD.open(path, FILE_READ);
|
||||
if (!f || f.isDirectory()) {
|
||||
if (f) f.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
_otaServer->send(404, "text/plain", "Not found");
|
||||
return;
|
||||
}
|
||||
String name = path;
|
||||
int lastSlash = name.lastIndexOf('/');
|
||||
if (lastSlash >= 0) name = name.substring(lastSlash + 1);
|
||||
_otaServer->sendHeader("Content-Disposition",
|
||||
"attachment; filename=\"" + name + "\"");
|
||||
size_t fileSize = f.size();
|
||||
_otaServer->setContentLength(fileSize);
|
||||
_otaServer->send(200, "application/octet-stream", "");
|
||||
uint8_t* buf = (uint8_t*)ps_malloc(4096);
|
||||
if (!buf) buf = (uint8_t*)malloc(4096);
|
||||
if (buf) {
|
||||
while (f.available()) {
|
||||
int n = f.read(buf, 4096);
|
||||
if (n > 0) _otaServer->sendContent((const char*)buf, n);
|
||||
}
|
||||
free(buf);
|
||||
}
|
||||
f.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
});
|
||||
|
||||
// --- File upload: POST /upload?dir=/ → redirect back to listing ---
|
||||
_otaServer->on("/upload", HTTP_POST,
|
||||
[this]() {
|
||||
String dir = _otaServer->arg("dir");
|
||||
if (dir.isEmpty()) dir = "/";
|
||||
_otaServer->sendHeader("Location", "/?path=" + dir + "&msg=Upload+complete");
|
||||
_otaServer->send(303, "text/plain", "Redirecting...");
|
||||
},
|
||||
[this]() {
|
||||
HTTPUpload& upload = _otaServer->upload();
|
||||
static File fmUploadFile;
|
||||
|
||||
if (upload.status == UPLOAD_FILE_START) {
|
||||
String dir = _otaServer->arg("dir");
|
||||
if (dir.isEmpty()) dir = "/";
|
||||
if (!dir.endsWith("/")) dir += "/";
|
||||
String fullPath = dir + upload.filename;
|
||||
Serial.printf("FM: Upload start: %s\n", fullPath.c_str());
|
||||
fmUploadFile = SD.open(fullPath, FILE_WRITE);
|
||||
if (!fmUploadFile) Serial.println("FM: Failed to open file for write");
|
||||
|
||||
} else if (upload.status == UPLOAD_FILE_WRITE) {
|
||||
if (fmUploadFile) fmUploadFile.write(upload.buf, upload.currentSize);
|
||||
|
||||
} else if (upload.status == UPLOAD_FILE_END) {
|
||||
if (fmUploadFile) {
|
||||
fmUploadFile.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
Serial.printf("FM: Upload done: %s (%d bytes)\n",
|
||||
upload.filename.c_str(), upload.totalSize);
|
||||
}
|
||||
|
||||
} else if (upload.status == UPLOAD_FILE_ABORTED) {
|
||||
if (fmUploadFile) fmUploadFile.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
Serial.println("FM: Upload aborted");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// --- Create directory: GET /mkdir?name=xxx&dir=/path ---
|
||||
_otaServer->on("/mkdir", HTTP_GET, [this]() {
|
||||
String dir = _otaServer->arg("dir");
|
||||
String name = _otaServer->arg("name");
|
||||
if (dir.isEmpty()) dir = "/";
|
||||
if (name.isEmpty()) {
|
||||
_otaServer->sendHeader("Location", "/?path=" + dir + "&msg=No+name");
|
||||
_otaServer->send(303);
|
||||
return;
|
||||
}
|
||||
String full = dir + (dir.endsWith("/") ? "" : "/") + name;
|
||||
bool ok = SD.mkdir(full);
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
Serial.printf("FM: mkdir '%s' %s\n", full.c_str(), ok ? "OK" : "FAIL");
|
||||
_otaServer->sendHeader("Location",
|
||||
"/?path=" + dir + "&msg=" + (ok ? "Folder+created" : "mkdir+failed"));
|
||||
_otaServer->send(303);
|
||||
});
|
||||
|
||||
// --- Delete file/folder: GET /rm?path=/file&ret=/parent ---
|
||||
_otaServer->on("/rm", HTTP_GET, [this]() {
|
||||
String path = _otaServer->arg("path");
|
||||
String ret = _otaServer->arg("ret");
|
||||
if (ret.isEmpty()) ret = "/";
|
||||
if (path.isEmpty() || path == "/") {
|
||||
_otaServer->sendHeader("Location", "/?path=" + ret + "&msg=Bad+path");
|
||||
_otaServer->send(303);
|
||||
return;
|
||||
}
|
||||
File f = SD.open(path);
|
||||
bool ok = false;
|
||||
if (f) {
|
||||
bool isDir = f.isDirectory();
|
||||
f.close();
|
||||
ok = isDir ? SD.rmdir(path) : SD.remove(path);
|
||||
}
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
Serial.printf("FM: rm '%s' %s\n", path.c_str(), ok ? "OK" : "FAIL");
|
||||
_otaServer->sendHeader("Location",
|
||||
"/?path=" + ret + "&msg=" + (ok ? "Deleted" : "Delete+failed"));
|
||||
_otaServer->send(303);
|
||||
});
|
||||
|
||||
// --- Confirm delete page: GET /confirm-rm?path=/file&ret=/parent ---
|
||||
_otaServer->on("/confirm-rm", HTTP_GET, [this]() {
|
||||
String path = _otaServer->arg("path");
|
||||
String ret = _otaServer->arg("ret");
|
||||
if (ret.isEmpty()) ret = "/";
|
||||
String name = path;
|
||||
int sl = name.lastIndexOf('/');
|
||||
if (sl >= 0) name = name.substring(sl + 1);
|
||||
String html = "<!DOCTYPE html><html><head>"
|
||||
"<meta charset='UTF-8'>"
|
||||
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||
"<title>Confirm Delete</title>"
|
||||
"<style>"
|
||||
"body{font-family:-apple-system,sans-serif;max-width:480px;margin:40px auto;"
|
||||
"padding:0 20px;background:#1a1a2e;color:#e0e0e0;text-align:center}"
|
||||
".b{display:inline-block;padding:10px 24px;border-radius:6px;text-decoration:none;"
|
||||
"font-weight:bold;margin:8px;font-size:1em}"
|
||||
".br{background:#e74c3c;color:#fff}.bg{background:#4ecca3;color:#1a1a2e}"
|
||||
"</style></head><body>"
|
||||
"<h2 style='color:#e74c3c'>Delete?</h2>"
|
||||
"<p style='font-size:1.1em'>" + fmHtmlEscape(name) + "</p>"
|
||||
"<a class='b br' href='/rm?path=" + fmUrlEncode(path) + "&ret=" + fmUrlEncode(ret) + "'>Delete</a>"
|
||||
"<a class='b bg' href='/?path=" + fmUrlEncode(ret) + "'>Cancel</a>"
|
||||
"</body></html>";
|
||||
_otaServer->send(200, "text/html", html);
|
||||
});
|
||||
|
||||
// Catch-all: redirect unknown URLs to file manager (catches captive portal probes)
|
||||
_otaServer->onNotFound([this]() {
|
||||
Serial.printf("FM: redirect %s -> /\n", _otaServer->uri().c_str());
|
||||
_otaServer->sendHeader("Location", "http://192.168.4.1/");
|
||||
_otaServer->send(302, "text/plain", "");
|
||||
});
|
||||
|
||||
_otaServer->begin();
|
||||
Serial.println("FM: Web server started on port 80");
|
||||
_fmPhase = FM_PHASE_WAITING;
|
||||
}
|
||||
|
||||
void stopFileMgr() {
|
||||
if (_otaServer) { _otaServer->stop(); delete _otaServer; _otaServer = nullptr; }
|
||||
if (_dnsServer) { _dnsServer->stop(); delete _dnsServer; _dnsServer = nullptr; }
|
||||
WiFi.softAPdisconnect(true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
delay(100);
|
||||
_editMode = EDIT_NONE;
|
||||
extern void otaResumeRadio();
|
||||
otaResumeRadio();
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
WiFi.mode(WIFI_STA);
|
||||
wifiReconnectSaved();
|
||||
#endif
|
||||
Serial.println("FM: Stopped, AP down, radio resumed");
|
||||
}
|
||||
|
||||
// --- Helpers for server-rendered HTML ---
|
||||
|
||||
static String fmHtmlEscape(const String& s) {
|
||||
String r;
|
||||
r.reserve(s.length());
|
||||
for (unsigned int i = 0; i < s.length(); i++) {
|
||||
char c = s[i];
|
||||
if (c == '&') r += "&";
|
||||
else if (c == '<') r += "<";
|
||||
else if (c == '>') r += ">";
|
||||
else if (c == '"') r += """;
|
||||
else r += c;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static String fmUrlEncode(const String& s) {
|
||||
String r;
|
||||
for (unsigned int i = 0; i < s.length(); i++) {
|
||||
char c = s[i];
|
||||
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '/' || c == '~') {
|
||||
r += c;
|
||||
} else {
|
||||
char hex[4];
|
||||
snprintf(hex, sizeof(hex), "%%%02X", (uint8_t)c);
|
||||
r += hex;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static String fmFormatSize(size_t bytes) {
|
||||
if (bytes < 1024) return String(bytes) + " B";
|
||||
if (bytes < 1048576) return String(bytes / 1024) + " KB";
|
||||
return String(bytes / 1048576) + "." + String((bytes % 1048576) * 10 / 1048576) + " MB";
|
||||
}
|
||||
|
||||
// Build the complete HTML page with inline directory listing
|
||||
String fmBuildPage(const String& path, const String& msg) {
|
||||
String html;
|
||||
html.reserve(4096);
|
||||
|
||||
// --- Head + CSS ---
|
||||
html += "<!DOCTYPE html><html><head>"
|
||||
"<meta charset='UTF-8'>"
|
||||
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||
"<title>Meck SD Files</title>"
|
||||
"<style>"
|
||||
"body{font-family:-apple-system,sans-serif;max-width:600px;margin:20px auto;"
|
||||
"padding:0 16px;background:#1a1a2e;color:#e0e0e0}"
|
||||
"h1{color:#4ecca3;font-size:1.3em;margin:8px 0}"
|
||||
".pa{background:#16213e;padding:8px 12px;border-radius:6px;margin:8px 0;"
|
||||
"font-family:monospace;font-size:0.9em;word-break:break-all}"
|
||||
".tb{display:flex;gap:6px;margin:8px 0;flex-wrap:wrap}"
|
||||
".b{background:#4ecca3;color:#1a1a2e;border:none;padding:7px 14px;"
|
||||
"border-radius:5px;font-size:0.85em;font-weight:bold;cursor:pointer;"
|
||||
"text-decoration:none;display:inline-block}"
|
||||
".b:active{background:#3ba88f}"
|
||||
".br{background:#e74c3c;color:#fff;padding:3px 8px;font-size:0.75em}.br:active{background:#c0392b}"
|
||||
".it{display:flex;align-items:center;padding:8px 4px;border-bottom:1px solid #16213e;gap:6px}"
|
||||
".ic{font-size:1.1em;width:22px;text-align:center}"
|
||||
".nm{flex:1;word-break:break-all;color:#e0e0e0;text-decoration:none}"
|
||||
".nm:hover{color:#4ecca3}"
|
||||
".sz{color:#888;font-size:0.8em;min-width:54px;text-align:right;margin-right:4px}"
|
||||
".up{background:#16213e;border:2px dashed #4ecca3;border-radius:8px;"
|
||||
"padding:14px;margin:10px 0;text-align:center}"
|
||||
".em{color:#888;text-align:center;padding:20px}"
|
||||
".ms{background:#16213e;padding:8px 12px;border-radius:6px;margin:8px 0;"
|
||||
"border-left:3px solid #4ecca3;font-size:0.9em}"
|
||||
"</style></head><body>";
|
||||
|
||||
// --- Title + path ---
|
||||
html += "<h1>Meck SD File Manager</h1>";
|
||||
html += "<div class='pa'>" + fmHtmlEscape(path) + "</div>";
|
||||
|
||||
// --- Status message (from redirects) ---
|
||||
if (msg.length() > 0) {
|
||||
html += "<div class='ms'>" + fmHtmlEscape(msg) + "</div>";
|
||||
}
|
||||
|
||||
// --- Navigation buttons ---
|
||||
html += "<div class='tb'>";
|
||||
if (path != "/") {
|
||||
// Compute parent
|
||||
String parent = path;
|
||||
if (parent.endsWith("/")) parent = parent.substring(0, parent.length() - 1);
|
||||
int sl = parent.lastIndexOf('/');
|
||||
parent = (sl <= 0) ? "/" : parent.substring(0, sl);
|
||||
html += "<a class='b' href='/?path=" + fmUrlEncode(parent) + "'>.. Up</a>";
|
||||
}
|
||||
html += "<a class='b' href='/?path=" + fmUrlEncode(path) + "'>Refresh</a>";
|
||||
html += "</div>";
|
||||
|
||||
// --- Directory listing (server-rendered) ---
|
||||
File dir = SD.open(path, FILE_READ);
|
||||
if (!dir || !dir.isDirectory()) {
|
||||
if (dir) dir.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
html += "<div class='em'>Cannot open directory</div>";
|
||||
} else {
|
||||
// Collect entries into arrays for sorting (dirs first, then alpha)
|
||||
struct FmEntry { String name; size_t size; bool isDir; };
|
||||
FmEntry entries[128]; // max entries to display
|
||||
int count = 0;
|
||||
File entry = dir.openNextFile();
|
||||
while (entry && count < 128) {
|
||||
const char* fullName = entry.name();
|
||||
const char* baseName = strrchr(fullName, '/');
|
||||
baseName = baseName ? baseName + 1 : fullName;
|
||||
entries[count].name = baseName;
|
||||
entries[count].size = entry.size();
|
||||
entries[count].isDir = entry.isDirectory();
|
||||
count++;
|
||||
entry.close();
|
||||
entry = dir.openNextFile();
|
||||
}
|
||||
dir.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
Serial.printf("FM: listing %d entries for '%s'\n", count, path.c_str());
|
||||
|
||||
// Sort: dirs first, then alphabetical
|
||||
for (int i = 0; i < count - 1; i++) {
|
||||
for (int j = i + 1; j < count; j++) {
|
||||
bool swap = false;
|
||||
if (entries[i].isDir != entries[j].isDir) {
|
||||
swap = !entries[i].isDir && entries[j].isDir;
|
||||
} else {
|
||||
swap = entries[i].name.compareTo(entries[j].name) > 0;
|
||||
}
|
||||
if (swap) {
|
||||
FmEntry tmp = entries[i];
|
||||
entries[i] = entries[j];
|
||||
entries[j] = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count == 0) {
|
||||
html += "<div class='em'>Empty folder</div>";
|
||||
} else {
|
||||
for (int i = 0; i < count; i++) {
|
||||
String fp = path + (path.endsWith("/") ? "" : "/") + entries[i].name;
|
||||
html += "<div class='it'>";
|
||||
html += "<span class='ic'>" + String(entries[i].isDir ? "\xF0\x9F\x93\x81" : "\xF0\x9F\x93\x84") + "</span>";
|
||||
if (entries[i].isDir) {
|
||||
html += "<a class='nm' href='/?path=" + fmUrlEncode(fp) + "'>" + fmHtmlEscape(entries[i].name) + "</a>";
|
||||
} else {
|
||||
html += "<a class='nm' href='/dl?path=" + fmUrlEncode(fp) + "'>" + fmHtmlEscape(entries[i].name) + "</a>";
|
||||
html += "<span class='sz'>" + fmFormatSize(entries[i].size) + "</span>";
|
||||
}
|
||||
html += "<a class='b br' href='/confirm-rm?path=" + fmUrlEncode(fp) + "&ret=" + fmUrlEncode(path) + "'>Del</a>";
|
||||
html += "</div>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Upload form (standard HTML form, no JS needed) ---
|
||||
html += "<div class='up'>"
|
||||
"<form method='POST' action='/upload?dir=" + fmUrlEncode(path) + "' enctype='multipart/form-data'>"
|
||||
"<p>Select files to upload</p>"
|
||||
"<input type='file' name='file' multiple><br><br>"
|
||||
"<button class='b' type='submit'>Upload</button>"
|
||||
"</form></div>";
|
||||
|
||||
// --- New folder (tiny inline form) ---
|
||||
html += "<form action='/mkdir' method='GET' style='margin:8px 0;display:flex;gap:6px'>"
|
||||
"<input type='hidden' name='dir' value='" + fmHtmlEscape(path) + "'>"
|
||||
"<input type='text' name='name' placeholder='New folder name' "
|
||||
"style='flex:1;padding:7px;border-radius:5px;border:1px solid #4ecca3;"
|
||||
"background:#16213e;color:#e0e0e0'>"
|
||||
"<button class='b' type='submit'>Create</button>"
|
||||
"</form>";
|
||||
|
||||
html += "</body></html>";
|
||||
return html;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1068,6 +1556,9 @@ public:
|
||||
strncpy(_editBuf, initial, SETTINGS_TEXT_BUF - 1);
|
||||
_editBuf[SETTINGS_TEXT_BUF - 1] = '\0';
|
||||
_editPos = strlen(_editBuf);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
_needsTextVKB = true; // Signal UITask to open virtual keyboard
|
||||
#endif
|
||||
}
|
||||
|
||||
void startEditPicker(int initialIdx) {
|
||||
@@ -1102,6 +1593,10 @@ public:
|
||||
display.print("Settings > Contacts");
|
||||
} else if (_subScreen == SUB_CHANNELS) {
|
||||
display.print("Settings > Channels");
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
} else if (_subScreen == SUB_OTA_TOOLS) {
|
||||
display.print("Settings > OTA Tools");
|
||||
#endif
|
||||
} else {
|
||||
display.print("Settings");
|
||||
}
|
||||
@@ -1114,8 +1609,8 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body ===
|
||||
display.setTextSize(0); // tiny font
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(_prefs->smallTextSize()); // tiny font
|
||||
int lineHeight = _prefs->smallLineH();
|
||||
int headerH = 14;
|
||||
int footerH = 14;
|
||||
int maxY = display.height() - footerH;
|
||||
@@ -1140,7 +1635,7 @@ public:
|
||||
// Highlight needs to start above the baseline to cover ascenders.
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
@@ -1233,7 +1728,7 @@ public:
|
||||
break;
|
||||
|
||||
case ROW_MSG_NOTIFY:
|
||||
snprintf(tmp, sizeof(tmp), "Msg Rcvd LED Light Pulse: %s",
|
||||
snprintf(tmp, sizeof(tmp), "Msg LED Flash: %s",
|
||||
_prefs->kb_flash_notify ? "ON" : "OFF");
|
||||
display.print(tmp);
|
||||
break;
|
||||
@@ -1266,6 +1761,12 @@ public:
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_LARGE_FONT:
|
||||
snprintf(tmp, sizeof(tmp), "Font Size: %s",
|
||||
_prefs->large_font ? "LARGER" : "TINY");
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
case ROW_PORTRAIT_MODE:
|
||||
snprintf(tmp, sizeof(tmp), "Portrait Mode: %s",
|
||||
@@ -1421,9 +1922,18 @@ public:
|
||||
break;
|
||||
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
case ROW_OTA_TOOLS_SUBMENU:
|
||||
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
|
||||
display.print("OTA Tools >>");
|
||||
break;
|
||||
|
||||
case ROW_FW_UPDATE:
|
||||
display.print("Firmware Update");
|
||||
break;
|
||||
|
||||
case ROW_SD_FILE_MGR:
|
||||
display.print("SD File Manager");
|
||||
break;
|
||||
#endif
|
||||
|
||||
case ROW_INFO_HEADER:
|
||||
@@ -1506,7 +2016,7 @@ public:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(bx, by, bw, bh);
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
if (_confirmAction == 1) {
|
||||
uint8_t chIdx = _rows[_cursor].param;
|
||||
ChannelDetails ch;
|
||||
@@ -1534,7 +2044,7 @@ public:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(bx, by, bw, bh);
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int wy = by + 4;
|
||||
|
||||
if (_wifiPhase == WIFI_PHASE_SCANNING) {
|
||||
@@ -1620,7 +2130,7 @@ public:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(bx, by, bw, bh);
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int oy = by + 4;
|
||||
|
||||
if (_otaPhase == OTA_PHASE_CONFIRM) {
|
||||
@@ -1700,6 +2210,75 @@ public:
|
||||
|
||||
display.setTextSize(1);
|
||||
}
|
||||
|
||||
// === File Manager overlay ===
|
||||
if (_editMode == EDIT_FILEMGR) {
|
||||
int bx = 2, by = 14, bw = display.width() - 4;
|
||||
int bh = display.height() - 28;
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.fillRect(bx, by, bw, bh);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(bx, by, bw, bh);
|
||||
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int oy = by + 4;
|
||||
|
||||
if (_fmPhase == FM_PHASE_CONFIRM) {
|
||||
display.drawTextCentered(display.width() / 2, oy, "SD File Manager");
|
||||
oy += 14;
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print("Start WiFi file server?");
|
||||
oy += 10;
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print("Upload and download files");
|
||||
oy += 8;
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print("on SD card via browser.");
|
||||
oy += 10;
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("LoRa paused while active.");
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
} else if (_fmPhase == FM_PHASE_WAITING) {
|
||||
display.drawTextCentered(display.width() / 2, oy, "SD File Manager");
|
||||
oy += 14;
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print("Connect to WiFi network:");
|
||||
oy += 10;
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print(_otaApName);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
oy += 12;
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print("Then open browser:");
|
||||
oy += 10;
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(bx + 4, oy);
|
||||
char ipBuf[32];
|
||||
snprintf(ipBuf, sizeof(ipBuf), "http://%s", WiFi.softAPIP().toString().c_str());
|
||||
display.print(ipBuf);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
oy += 12;
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print("File server active...");
|
||||
|
||||
pollOTAServer();
|
||||
|
||||
} else if (_fmPhase == FM_PHASE_ERROR) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.drawTextCentered(display.width() / 2, oy, "File Manager Error");
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
oy += 14;
|
||||
if (_fmError) {
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print(_fmError);
|
||||
}
|
||||
}
|
||||
|
||||
display.setTextSize(1);
|
||||
}
|
||||
#endif
|
||||
|
||||
// === Footer ===
|
||||
@@ -1712,7 +2291,12 @@ public:
|
||||
if (_editMode == EDIT_NONE) {
|
||||
if (_subScreen != SUB_NONE) {
|
||||
display.print("Boot:Back");
|
||||
const char* r = (_subScreen == SUB_CHANNELS) ? "Tap:Select Hold:Del" : "Tap:Toggle Hold:Edit";
|
||||
const char* r;
|
||||
if (_subScreen == SUB_CHANNELS) r = "Tap:Select Hold:Del";
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
else if (_subScreen == SUB_OTA_TOOLS) r = "Tap:Select";
|
||||
#endif
|
||||
else r = "Tap:Toggle Hold:Edit";
|
||||
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
|
||||
display.print(r);
|
||||
} else {
|
||||
@@ -1761,6 +2345,19 @@ public:
|
||||
} else {
|
||||
display.print("Please wait...");
|
||||
}
|
||||
} else if (_editMode == EDIT_FILEMGR) {
|
||||
if (_fmPhase == FM_PHASE_CONFIRM) {
|
||||
display.print("Boot:Cancel");
|
||||
const char* r = "Tap:Start";
|
||||
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
|
||||
display.print(r);
|
||||
} else if (_fmPhase == FM_PHASE_WAITING) {
|
||||
display.print("Boot:Stop");
|
||||
} else if (_fmPhase == FM_PHASE_ERROR) {
|
||||
display.print("Boot:Back");
|
||||
} else {
|
||||
display.print("Please wait...");
|
||||
}
|
||||
#endif
|
||||
} else if (_editMode == EDIT_TEXT) {
|
||||
display.print("Hold:Type");
|
||||
@@ -1798,6 +2395,16 @@ public:
|
||||
} else {
|
||||
display.print("Please wait...");
|
||||
}
|
||||
} else if (_editMode == EDIT_FILEMGR) {
|
||||
if (_fmPhase == FM_PHASE_CONFIRM) {
|
||||
display.print("Enter:Start Q:Cancel");
|
||||
} else if (_fmPhase == FM_PHASE_WAITING) {
|
||||
display.print("Q:Stop");
|
||||
} else if (_fmPhase == FM_PHASE_ERROR) {
|
||||
display.print("Q:Back");
|
||||
} else {
|
||||
display.print("Please wait...");
|
||||
}
|
||||
#endif
|
||||
} else if (_editMode == EDIT_PICKER) {
|
||||
display.print("A/D:Choose Enter:Ok");
|
||||
@@ -1818,9 +2425,10 @@ public:
|
||||
#endif
|
||||
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
// Poll web server frequently during OTA waiting/receiving phases
|
||||
if (_editMode == EDIT_OTA &&
|
||||
(_otaPhase == OTA_PHASE_WAITING || _otaPhase == OTA_PHASE_RECEIVING)) {
|
||||
// Poll web server frequently during OTA waiting/receiving or file manager phases
|
||||
if ((_editMode == EDIT_OTA &&
|
||||
(_otaPhase == OTA_PHASE_WAITING || _otaPhase == OTA_PHASE_RECEIVING)) ||
|
||||
(_editMode == EDIT_FILEMGR && _fmPhase == FM_PHASE_WAITING)) {
|
||||
return 200; // 200ms — fast enough for web server responsiveness
|
||||
}
|
||||
#endif
|
||||
@@ -1887,6 +2495,32 @@ public:
|
||||
// Consume all keys during OTA
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- File Manager flow ---
|
||||
if (_editMode == EDIT_FILEMGR) {
|
||||
if (_fmPhase == FM_PHASE_CONFIRM) {
|
||||
if (c == '\r' || c == 13) {
|
||||
startFileMgrServer();
|
||||
return true;
|
||||
}
|
||||
if (c == 'q' || c == 'Q') {
|
||||
_editMode = EDIT_NONE;
|
||||
return true;
|
||||
}
|
||||
} else if (_fmPhase == FM_PHASE_WAITING) {
|
||||
if (c == 'q' || c == 'Q') {
|
||||
stopFileMgr();
|
||||
return true;
|
||||
}
|
||||
} else if (_fmPhase == FM_PHASE_ERROR) {
|
||||
if (c == 'q' || c == 'Q') {
|
||||
stopFileMgr();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Consume all keys during file manager
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
@@ -2311,6 +2945,12 @@ public:
|
||||
Serial.printf("Settings: Dark mode = %s\n",
|
||||
_prefs->dark_mode ? "ON" : "OFF");
|
||||
break;
|
||||
case ROW_LARGE_FONT:
|
||||
_prefs->large_font = _prefs->large_font ? 0 : 1;
|
||||
the_mesh.savePrefs();
|
||||
Serial.printf("Settings: Font size = %s\n",
|
||||
_prefs->large_font ? "LARGER" : "TINY");
|
||||
break;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
case ROW_PORTRAIT_MODE:
|
||||
_prefs->portrait_mode = _prefs->portrait_mode ? 0 : 1;
|
||||
@@ -2453,9 +3093,20 @@ public:
|
||||
startEditText("");
|
||||
break;
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
case ROW_OTA_TOOLS_SUBMENU:
|
||||
_savedTopCursor = _cursor;
|
||||
_subScreen = SUB_OTA_TOOLS;
|
||||
_cursor = 0;
|
||||
_scrollTop = 0;
|
||||
rebuildRows();
|
||||
Serial.println("Settings: entered OTA Tools sub-screen");
|
||||
break;
|
||||
case ROW_FW_UPDATE:
|
||||
startOTA();
|
||||
break;
|
||||
case ROW_SD_FILE_MGR:
|
||||
startFileMgr();
|
||||
break;
|
||||
#endif
|
||||
case ROW_CHANNEL:
|
||||
case ROW_PUB_KEY:
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <vector>
|
||||
#include "Utf8CP437.h"
|
||||
#include "EpubProcessor.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
@@ -327,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;
|
||||
@@ -396,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
|
||||
|
||||
@@ -1084,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;
|
||||
@@ -1106,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 {
|
||||
@@ -1114,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 ? "> " : " ";
|
||||
|
||||
@@ -1125,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);
|
||||
@@ -1141,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
|
||||
@@ -1163,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);
|
||||
@@ -1177,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;
|
||||
@@ -1270,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";
|
||||
@@ -1287,8 +1280,8 @@ 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),
|
||||
_textAreaHeight(100), _headerHeight(14), _footerHeight(14),
|
||||
@@ -1313,16 +1306,24 @@ 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 for monospace (T-Deck Pro).
|
||||
// T5S3 overrides this below with average-width measurement.
|
||||
// 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;
|
||||
@@ -1343,6 +1344,15 @@ public:
|
||||
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
|
||||
@@ -1362,13 +1372,17 @@ 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)
|
||||
@@ -1389,6 +1403,107 @@ public:
|
||||
// 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;
|
||||
|
||||
@@ -1430,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])) {
|
||||
@@ -1509,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);
|
||||
|
||||
@@ -1574,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;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "../MyMesh.h"
|
||||
#include "NotesScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
#include "PathEditorScreen.h"
|
||||
#include "DiscoveryScreen.h"
|
||||
#include "LastHeardScreen.h"
|
||||
#ifdef MECK_WEB_READER
|
||||
@@ -12,12 +13,15 @@
|
||||
#include "MapScreen.h"
|
||||
#endif
|
||||
#include "target.h"
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_AUDIO_VARIANT)
|
||||
#include "HomeIcons.h"
|
||||
#endif
|
||||
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
#include <WiFi.h>
|
||||
#endif
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) && !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION)
|
||||
#include "esp_sleep.h"
|
||||
#endif
|
||||
|
||||
#ifndef AUTO_OFF_MILLIS
|
||||
#define AUTO_OFF_MILLIS 15000 // 15 seconds
|
||||
@@ -55,6 +59,7 @@
|
||||
#include "SettingsScreen.h"
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
#include "AudiobookPlayerScreen.h"
|
||||
#include "VoiceMessageScreen.h"
|
||||
#endif
|
||||
#ifdef HAS_4G_MODEM
|
||||
#include "SMSScreen.h"
|
||||
@@ -156,7 +161,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// T5S3: text-only battery indicator — "Batt 99% 4.1v"
|
||||
@@ -170,7 +175,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
|
||||
display.print(battStr);
|
||||
display.setTextSize(1); // restore default text size
|
||||
#else
|
||||
// T-Deck Pro: icon + percentage text
|
||||
// T-Deck Pro: icon + percentage text (icon hidden in large font)
|
||||
int iconWidth = 16;
|
||||
int iconHeight = 6;
|
||||
int iconY = 0;
|
||||
@@ -181,26 +186,35 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
|
||||
sprintf(pctStr, "%d%%", batteryPercentage);
|
||||
uint16_t textWidth = display.getTextWidth(pctStr);
|
||||
|
||||
// layout: [icon][cap 2px][gap 2px][text][margin 2px]
|
||||
int totalWidth = iconWidth + 2 + 2 + textWidth + 2;
|
||||
int iconX = display.width() - totalWidth;
|
||||
if (_node_prefs->large_font) {
|
||||
// Large font: text only — no room for icon in header
|
||||
int textX = display.width() - textWidth - 2;
|
||||
if (outIconX) *outIconX = textX;
|
||||
display.setCursor(textX, textY);
|
||||
display.print(pctStr);
|
||||
} else {
|
||||
// Tiny font: icon + text
|
||||
// layout: [icon][cap 2px][gap 2px][text][margin 2px]
|
||||
int totalWidth = iconWidth + 2 + 2 + textWidth + 2;
|
||||
int iconX = display.width() - totalWidth;
|
||||
|
||||
if (outIconX) *outIconX = iconX;
|
||||
if (outIconX) *outIconX = iconX;
|
||||
|
||||
// battery outline
|
||||
display.drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||
// battery outline
|
||||
display.drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||
|
||||
// battery "cap"
|
||||
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 2, iconHeight / 2);
|
||||
// battery "cap"
|
||||
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 2, iconHeight / 2);
|
||||
|
||||
// fill the battery based on the percentage
|
||||
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
|
||||
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
|
||||
// fill the battery based on the percentage
|
||||
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
|
||||
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
|
||||
|
||||
// draw percentage text after the battery cap
|
||||
int textX = iconX + iconWidth + 2 + 2; // after cap + gap
|
||||
display.setCursor(textX, textY);
|
||||
display.print(pctStr);
|
||||
// draw percentage text after the battery cap
|
||||
int textX = iconX + iconWidth + 2 + 2; // after cap + gap
|
||||
display.setCursor(textX, textY);
|
||||
display.print(pctStr);
|
||||
}
|
||||
display.setTextSize(1); // restore default text size
|
||||
#endif
|
||||
}
|
||||
@@ -215,12 +229,31 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
|
||||
if (!_task->isAudioPlayingInBackground()) return;
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(0); // tiny font (same as clock & battery %)
|
||||
display.setTextSize(_node_prefs->smallTextSize()); // tiny font (same as clock & battery %)
|
||||
int x = batteryLeftX - display.getTextWidth(">>") - 2;
|
||||
display.setCursor(x, -3); // align vertically with battery text
|
||||
display.print(">>");
|
||||
display.setTextSize(1); // restore
|
||||
}
|
||||
|
||||
// ---- Alarm enabled indicator ----
|
||||
// Shows a small bell icon to the left of the audio indicator
|
||||
// (or battery icon if no audio playing) when any alarm is enabled.
|
||||
void renderAlarmIndicator(DisplayDriver& display, int batteryLeftX) {
|
||||
AlarmScreen* alarmScr = (AlarmScreen*)_task->getAlarmScreen();
|
||||
if (!alarmScr || alarmScr->enabledCount() == 0) return;
|
||||
|
||||
// Calculate X: shift left past audio indicator if it's showing
|
||||
int rightEdge = batteryLeftX;
|
||||
if (_task->isAudioPlayingInBackground()) {
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
rightEdge = rightEdge - display.getTextWidth(">>") - 2;
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
int x = rightEdge - BELL_ICON_W - 2;
|
||||
display.drawXbm(x, 1, icon_bell_small, BELL_ICON_W, BELL_ICON_H);
|
||||
}
|
||||
#endif
|
||||
|
||||
CayenneLPP sensors_lpp;
|
||||
@@ -276,7 +309,7 @@ public:
|
||||
_task->setHomeShowingTiles(false); // Reset — only set true on FIRST page
|
||||
#endif
|
||||
// node name (tinyfont to avoid overlapping clock)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
char filtered_name[sizeof(_node_prefs->node_name)];
|
||||
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
|
||||
@@ -290,18 +323,21 @@ public:
|
||||
display.setCursor(0, HOME_HDR_Y);
|
||||
display.print(filtered_name);
|
||||
|
||||
// battery voltage
|
||||
// battery voltage + status icons
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
int battLeftX = display.width(); // default if battery doesn't render
|
||||
renderBatteryIndicator(display, _task->getBattMilliVolts(), &battLeftX);
|
||||
|
||||
// audio background playback indicator (>> icon next to battery)
|
||||
renderAudioIndicator(display, battLeftX);
|
||||
|
||||
// alarm enabled indicator (AL icon, left of audio or battery)
|
||||
renderAlarmIndicator(display, battLeftX);
|
||||
#else
|
||||
renderBatteryIndicator(display, _task->getBattMilliVolts());
|
||||
#endif
|
||||
|
||||
// centered clock (tinyfont) - only show when time is valid
|
||||
// centered clock — only show when time is valid
|
||||
{
|
||||
uint32_t now = _rtc->getCurrentTime();
|
||||
if (now > 1700000000) { // valid timestamp (after ~Nov 2023)
|
||||
@@ -315,11 +351,14 @@ public:
|
||||
char timeBuf[6];
|
||||
sprintf(timeBuf, "%02d:%02d", hrs, mins);
|
||||
|
||||
display.setTextSize(0); // tinyfont
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
uint16_t tw = display.getTextWidth(timeBuf);
|
||||
int clockX = (display.width() - tw) / 2;
|
||||
display.setCursor(clockX, HOME_HDR_Y); // align with node name Y
|
||||
// Ensure clock doesn't overlap the node name
|
||||
int nameRight = display.getTextWidth(filtered_name) + 4;
|
||||
if (clockX < nameRight) clockX = nameRight;
|
||||
display.setCursor(clockX, HOME_HDR_Y);
|
||||
display.print(timeBuf);
|
||||
display.setTextSize(1); // restore
|
||||
}
|
||||
@@ -362,17 +401,17 @@ public:
|
||||
IPAddress ip = WiFi.localIP();
|
||||
if (ip != IPAddress(0,0,0,0)) {
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d:%d", ip[0], ip[1], ip[2], ip[3], TCP_PORT);
|
||||
display.setTextSize(0); // Tiny font for IP
|
||||
display.setTextSize(_node_prefs->smallTextSize()); // Tiny font for IP
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 8;
|
||||
y += _node_prefs->smallLineH() - 1;
|
||||
}
|
||||
#endif
|
||||
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(0); // Tiny font for Connected
|
||||
display.setTextSize(_node_prefs->smallTextSize()); // Tiny font for Connected
|
||||
display.drawTextCentered(display.width() / 2, y, "< Connected >");
|
||||
y += 8; // Reduced from 12
|
||||
y += _node_prefs->smallLineH() - 1;
|
||||
#ifdef BLE_PIN_CODE
|
||||
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
@@ -423,7 +462,7 @@ public:
|
||||
display.drawXbm(iconX, iconY, tiles[row][col].icon, HOME_ICON_W, HOME_ICON_H);
|
||||
|
||||
// Label centered below icon
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
display.drawTextCentered(tx + tileW / 2, ty + 18, tiles[row][col].label);
|
||||
}
|
||||
}
|
||||
@@ -431,47 +470,99 @@ public:
|
||||
// Nav hint below grid
|
||||
y = gridY + 2 * tileH + gapY + 2;
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
display.drawTextCentered(display.width() / 2, y, "Tap tile to open");
|
||||
}
|
||||
display.setTextSize(1);
|
||||
|
||||
#else
|
||||
// ----- T-Deck Pro: Keyboard shortcut text menu -----
|
||||
// Menu shortcuts - tinyfont monospaced grid
|
||||
y += 6;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(0); // tinyfont 6x8 monospaced
|
||||
display.drawTextCentered(display.width() / 2, y, "Press:");
|
||||
y += 12;
|
||||
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts ");
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
|
||||
y += 10;
|
||||
#if HAS_GPS
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps ");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
|
||||
#endif
|
||||
y += 10;
|
||||
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[T] Phone [B] Browser ");
|
||||
#elif defined(HAS_4G_MODEM)
|
||||
display.drawTextCentered(display.width() / 2, y, "[T] Phone [F] Discover ");
|
||||
#elif defined(MECK_AUDIO_VARIANT) && defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [B] Browser ");
|
||||
#elif defined(MECK_AUDIO_VARIANT)
|
||||
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [F] Discover ");
|
||||
#elif defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, y, "[F] Discover ");
|
||||
#endif
|
||||
y += 14;
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
int menuLH = _node_prefs->smallLineH();
|
||||
|
||||
// Nav hint
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(display.width() / 2, y, "Press A/D to cycle home views");
|
||||
if (_node_prefs->large_font) {
|
||||
// Proportional font: two-column layout with fixed X positions
|
||||
y += 2;
|
||||
int col1 = 2;
|
||||
int col2 = display.width() / 2;
|
||||
|
||||
display.setCursor(col1, y); display.print("[M] Messages");
|
||||
display.setCursor(col2, y); display.print("[C] Contacts");
|
||||
y += menuLH;
|
||||
display.setCursor(col1, y); display.print("[N] Notes");
|
||||
display.setCursor(col2, y); display.print("[S] Settings");
|
||||
y += menuLH;
|
||||
#if HAS_GPS
|
||||
display.setCursor(col1, y); display.print("[E] Reader");
|
||||
display.setCursor(col2, y); display.print("[G] Maps");
|
||||
#else
|
||||
display.setCursor(col1, y); display.print("[E] Reader");
|
||||
#endif
|
||||
y += menuLH;
|
||||
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
|
||||
display.setCursor(col1, y); display.print("[T] Phone");
|
||||
display.setCursor(col2, y); display.print("[B] Browser");
|
||||
#elif defined(HAS_4G_MODEM)
|
||||
display.setCursor(col1, y); display.print("[T] Phone");
|
||||
display.setCursor(col2, y); display.print("[F] Discover");
|
||||
#elif defined(MECK_AUDIO_VARIANT)
|
||||
display.setCursor(col1, y); display.print("[P] Audio");
|
||||
display.setCursor(col2, y); display.print("[K] Alarm");
|
||||
y += menuLH;
|
||||
#ifdef MECK_WEB_READER
|
||||
display.setCursor(col1, y); display.print("[B] Browser");
|
||||
display.setCursor(col2, y); display.print("[F] Discover");
|
||||
#else
|
||||
display.setCursor(col1, y); display.print("[F] Discover");
|
||||
#endif
|
||||
#elif defined(MECK_WEB_READER)
|
||||
display.setCursor(col1, y); display.print("[B] Browser");
|
||||
#else
|
||||
display.setCursor(col1, y); display.print("[F] Discover");
|
||||
#endif
|
||||
y += menuLH + 2;
|
||||
} else {
|
||||
// Monospaced built-in font: centered space-padded strings
|
||||
y += 6;
|
||||
display.drawTextCentered(display.width() / 2, y, "Press:");
|
||||
y += 12;
|
||||
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts ");
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
|
||||
y += 10;
|
||||
#if HAS_GPS
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps ");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
|
||||
#endif
|
||||
y += 10;
|
||||
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[T] Phone [B] Browser ");
|
||||
#elif defined(HAS_4G_MODEM)
|
||||
display.drawTextCentered(display.width() / 2, y, "[T] Phone [F] Discover ");
|
||||
#elif defined(MECK_AUDIO_VARIANT)
|
||||
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [K] Alarm ");
|
||||
y += 10;
|
||||
#ifdef MECK_WEB_READER
|
||||
display.drawTextCentered(display.width() / 2, y, "[B] Browser [F] Discover ");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, y, "[F] Discover ");
|
||||
#endif
|
||||
#elif defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, y, "[F] Discover ");
|
||||
#endif
|
||||
y += 14;
|
||||
}
|
||||
|
||||
// Nav hint (only if room)
|
||||
if (y < display.height() - 14) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(display.width() / 2, y,
|
||||
_node_prefs->large_font ? "A/D: cycle views" : "Press A/D to cycle home views");
|
||||
}
|
||||
display.setTextSize(1); // restore
|
||||
#endif
|
||||
} else if (_page == HomePage::RECENT) {
|
||||
@@ -501,7 +592,7 @@ public:
|
||||
}
|
||||
// Hint for full Last Heard screen
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawTextCentered(display.width() / 2, display.height() - 24,
|
||||
"Tap here for full Last Heard list");
|
||||
@@ -571,19 +662,20 @@ public:
|
||||
display.drawTextCentered(display.width() / 2, 18, "WiFi Companion");
|
||||
|
||||
int wy = 36;
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
int wLH = _node_prefs->smallLineH() + 1;
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
snprintf(tmp, sizeof(tmp), "SSID: %s", WiFi.SSID().c_str());
|
||||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||||
wy += 10;
|
||||
wy += wLH;
|
||||
IPAddress ip = WiFi.localIP();
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||||
wy += 10;
|
||||
wy += wLH;
|
||||
snprintf(tmp, sizeof(tmp), "Port: %d", TCP_PORT);
|
||||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||||
wy += 12;
|
||||
wy += wLH + 2;
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
@@ -596,7 +688,7 @@ public:
|
||||
} else {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.drawTextCentered(display.width() / 2, wy, "Not connected");
|
||||
wy += 12;
|
||||
wy += wLH + 2;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawTextCentered(display.width() / 2, wy, "Configure in Settings");
|
||||
}
|
||||
@@ -697,7 +789,7 @@ public:
|
||||
display.drawTextCentered(display.width() / 2, by + 4, buf);
|
||||
|
||||
// Show controls hint
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
display.drawTextCentered(display.width() / 2, by + bh - 10, "W/S:adj Enter:ok Q:cancel");
|
||||
display.setTextSize(1);
|
||||
}
|
||||
@@ -1107,12 +1199,10 @@ public:
|
||||
}
|
||||
|
||||
// ---- Unlock hint ----
|
||||
display.setTextSize(0);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawTextCentered(display.width() / 2, 120, "Hold button to unlock");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, 120, "Dbl-press to unlock");
|
||||
#endif
|
||||
|
||||
return 30000;
|
||||
@@ -1198,18 +1288,23 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
((ChannelScreen*)channel_screen)->setDMUnreadPtr(_dmUnread);
|
||||
contacts_screen = new ContactsScreen(this, &rtc_clock);
|
||||
((ContactsScreen*)contacts_screen)->setDMUnreadPtr(_dmUnread);
|
||||
text_reader = new TextReaderScreen(this);
|
||||
notes_screen = new NotesScreen(this);
|
||||
text_reader = new TextReaderScreen(this, node_prefs);
|
||||
notes_screen = new NotesScreen(this, node_prefs);
|
||||
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
|
||||
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
|
||||
path_editor = nullptr; // Lazy-initialized on first use from contacts screen
|
||||
discovery_screen = new DiscoveryScreen(this, &rtc_clock);
|
||||
last_heard_screen = new LastHeardScreen(&rtc_clock);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
|
||||
lock_screen = new LockScreen(this, &rtc_clock, node_prefs);
|
||||
#endif
|
||||
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
alarm_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
|
||||
voice_screen = nullptr; // Created and assigned from main.cpp on first mic key press
|
||||
#endif
|
||||
#ifdef HAS_4G_MODEM
|
||||
sms_screen = new SMSScreen(this);
|
||||
sms_screen = new SMSScreen(this, node_prefs);
|
||||
#endif
|
||||
#if HAS_GPS
|
||||
map_screen = new MapScreen(this);
|
||||
@@ -1758,6 +1853,21 @@ if (curr) curr->poll();
|
||||
}
|
||||
#endif
|
||||
|
||||
// Check if settings screen needs VKB for text editing (channel name, freq, APN)
|
||||
if (isOnSettingsScreen() && !_vkbActive) {
|
||||
SettingsScreen* ss = (SettingsScreen*)settings_screen;
|
||||
if (ss->needsTextVKB()) {
|
||||
ss->clearTextNeedsVKB();
|
||||
// Pick a context-appropriate label
|
||||
const char* label = "Edit";
|
||||
SettingsRowType rt = ss->getCurrentRowType();
|
||||
if (rt == ROW_NAME) label = "Node Name";
|
||||
else if (rt == ROW_ADD_CHANNEL) label = "Channel Name";
|
||||
else if (rt == ROW_FREQ) label = "Frequency";
|
||||
showVirtualKeyboard(VKB_SETTINGS_TEXT, label, ss->getEditBuf(), 31);
|
||||
}
|
||||
}
|
||||
|
||||
if (_hintActive && millis() < _hintExpiry) {
|
||||
// Boot navigation hint overlay — multi-line, larger box
|
||||
_display->setTextSize(1);
|
||||
@@ -1893,6 +2003,42 @@ if (curr) curr->poll();
|
||||
}
|
||||
#endif
|
||||
|
||||
// ── T5S3 standalone powersaving ──────────────────────────────────────────
|
||||
// When locked with display off, enter ESP32 light sleep (~8 mA total).
|
||||
// Radio stays in continuous RX — DIO1 going HIGH wakes the CPU instantly.
|
||||
// Boot button (GPIO0 LOW) and a 30-min safety timer also wake.
|
||||
// First sleep starts 60s after lock; subsequent cycles wake for 5s to let
|
||||
// the mesh stack process/relay any received packet, then sleep again.
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) && !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION)
|
||||
if (_locked && _display != NULL && !_display->isOn()) {
|
||||
unsigned long now = millis();
|
||||
if (now - _psLastActive >= _psNextSleepSecs * 1000UL) {
|
||||
Serial.println("[POWERSAVE] Entering light sleep (locked+idle)");
|
||||
board.sleep(1800); // Light sleep up to 30 min
|
||||
// ── CPU resumes here on wake ──
|
||||
unsigned long wakeAt = millis();
|
||||
_psLastActive = wakeAt;
|
||||
_psNextSleepSecs = 5; // Stay awake 5s for mesh processing
|
||||
|
||||
esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
|
||||
if (cause == ESP_SLEEP_WAKEUP_GPIO) {
|
||||
// Boot button pressed — unlock and return to normal use
|
||||
Serial.println("[POWERSAVE] Woke by button — unlocking");
|
||||
unlockScreen();
|
||||
_psNextSleepSecs = 60; // Reset to long delay after user interaction
|
||||
} else if (cause == ESP_SLEEP_WAKEUP_EXT1) {
|
||||
Serial.println("[POWERSAVE] Woke by LoRa packet");
|
||||
} else if (cause == ESP_SLEEP_WAKEUP_TIMER) {
|
||||
Serial.println("[POWERSAVE] Woke by timer");
|
||||
}
|
||||
}
|
||||
} else if (!_locked) {
|
||||
// Not locked — keep powersaving timer reset so first sleep is 60s after lock
|
||||
_psLastActive = millis();
|
||||
_psNextSleepSecs = 60;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef PIN_VIBRATION
|
||||
vibration.loop();
|
||||
#endif
|
||||
@@ -2019,6 +2165,10 @@ void UITask::lockScreen() {
|
||||
_next_refresh = 0; // Draw lock screen immediately
|
||||
_auto_off = millis() + 60000; // 60s before display off while locked
|
||||
_lastLockRefresh = millis(); // Start 15-min clock refresh cycle
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) && !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION)
|
||||
_psLastActive = millis(); // Start powersaving countdown (60s to first sleep)
|
||||
_psNextSleepSecs = 60;
|
||||
#endif
|
||||
Serial.println("[UI] Screen locked — entering low-power mode");
|
||||
}
|
||||
|
||||
@@ -2141,6 +2291,19 @@ void UITask::onVKBSubmit() {
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
case VKB_SETTINGS_TEXT: {
|
||||
// Generic settings text edit — copy text back to settings edit buffer
|
||||
// and confirm via the normal Enter path (handles name/freq/channel/APN)
|
||||
SettingsScreen* ss = (SettingsScreen*)settings_screen;
|
||||
if (strlen(text) > 0) {
|
||||
ss->submitEditText(text);
|
||||
} else {
|
||||
// Empty submission — cancel the edit
|
||||
ss->handleInput('q');
|
||||
}
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
case VKB_NOTES: {
|
||||
NotesScreen* notes = (NotesScreen*)getNotesScreen();
|
||||
if (notes && strlen(text) > 0) {
|
||||
@@ -2481,6 +2644,36 @@ void UITask::gotoAudiobookPlayer() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
void UITask::gotoAlarmScreen() {
|
||||
if (alarm_screen == nullptr) return;
|
||||
AlarmScreen* alarmScr = (AlarmScreen*)alarm_screen;
|
||||
if (_display != NULL) {
|
||||
alarmScr->enter(*_display);
|
||||
}
|
||||
setCurrScreen(alarm_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoVoiceScreen() {
|
||||
if (voice_screen == nullptr) return;
|
||||
VoiceMessageScreen* voiceScr = (VoiceMessageScreen*)voice_screen;
|
||||
if (_display != NULL) {
|
||||
voiceScr->enter(*_display);
|
||||
}
|
||||
setCurrScreen(voice_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
void UITask::gotoSMSScreen() {
|
||||
SMSScreen* smsScr = (SMSScreen*)sms_screen;
|
||||
@@ -2585,6 +2778,23 @@ void UITask::gotoRepeaterAdminDirect(int contactIdx) {
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::gotoPathEditor(int contactIdx) {
|
||||
// Lazy-initialize on first use
|
||||
if (path_editor == nullptr) {
|
||||
path_editor = new PathEditorScreen(this, &rtc_clock);
|
||||
}
|
||||
|
||||
PathEditorScreen* editor = (PathEditorScreen*)path_editor;
|
||||
editor->openForContact(contactIdx);
|
||||
setCurrScreen(path_editor);
|
||||
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoDiscoveryScreen() {
|
||||
((DiscoveryScreen*)discovery_screen)->resetScroll();
|
||||
setCurrScreen(discovery_screen);
|
||||
@@ -2611,7 +2821,7 @@ void UITask::gotoWebReader() {
|
||||
if (web_reader == nullptr) {
|
||||
Serial.printf("WebReader: lazy init - free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
web_reader = new WebReaderScreen(this);
|
||||
web_reader = new WebReaderScreen(this, _node_prefs);
|
||||
Serial.printf("WebReader: init complete - free heap: %d\n", ESP.getFreeHeap());
|
||||
}
|
||||
WebReaderScreen* wr = (WebReaderScreen*)web_reader;
|
||||
|
||||
@@ -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
|
||||
@@ -82,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
|
||||
@@ -106,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
|
||||
@@ -172,8 +188,13 @@ public:
|
||||
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
|
||||
@@ -221,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; }
|
||||
@@ -286,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,
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
+1064
-1927
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
+39
-4
@@ -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
@@ -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();
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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?
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ build_src_filter = ${esp32_base.build_src_filter}
|
||||
lib_deps =
|
||||
${esp32_base.lib_deps}
|
||||
WebServer
|
||||
DNSServer
|
||||
Update
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
@@ -110,7 +111,7 @@ 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
|
||||
|
||||
@@ -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
|
||||
@@ -242,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;
|
||||
}
|
||||
@@ -266,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 '$'
|
||||
@@ -279,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
|
||||
@@ -338,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 {
|
||||
@@ -349,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;
|
||||
}
|
||||
};
|
||||
@@ -97,6 +97,7 @@ lib_deps =
|
||||
adafruit/Adafruit GFX Library@^1.11.0
|
||||
bitbank2/PNGdec@^1.0.1
|
||||
WebServer
|
||||
DNSServer
|
||||
Update
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
@@ -110,7 +111,7 @@ 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
|
||||
@@ -128,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).
|
||||
@@ -150,7 +152,7 @@ build_flags =
|
||||
-D MECK_AUDIO_VARIANT
|
||||
-D MECK_WEB_READER=1
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.3.WiFi"'
|
||||
-D FIRMWARE_VERSION='"Meck v1.6.WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -162,6 +164,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 standalone (audio-player hardware, no BLE/WiFi — maximum battery life)
|
||||
; No MECK_WEB_READER: WiFi power draw conflicts with zero-radio-power design.
|
||||
@@ -188,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)
|
||||
@@ -196,14 +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 MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.3.4G"'
|
||||
-D FIRMWARE_VERSION='"Meck v1.6.4G"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -234,7 +238,7 @@ build_flags =
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.3.4G.WiFi"'
|
||||
-D FIRMWARE_VERSION='"Meck v1.6.4G.WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -261,7 +265,7 @@ build_flags =
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.3.4G.SA"'
|
||||
-D FIRMWARE_VERSION='"Meck v1.6.4G.SA"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -270,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}
|
||||
|
||||
Reference in New Issue
Block a user